From f44cb3059b1abfa3a571085f02ca8709ebea3f17 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:24:23 -0700 Subject: [PATCH 01/48] Ship serve-sim camera dylib as data and materialize it at runtime (#7168) Co-authored-by: Orca --- config/electron-builder.config.cjs | 2 + .../serve-sim-camera-packaging.test.mjs | 104 ++++++++++++ config/serve-sim-camera-packaging.cjs | 35 ++++ src/main/emulator/serve-sim-execution.ts | 37 ++++ .../serve-sim-runtime-materializer.test.ts | 158 ++++++++++++++++++ .../serve-sim-runtime-materializer.ts | 110 ++++++++++++ 6 files changed, 446 insertions(+) create mode 100644 config/scripts/serve-sim-camera-packaging.test.mjs create mode 100644 config/serve-sim-camera-packaging.cjs create mode 100644 src/main/emulator/serve-sim-runtime-materializer.test.ts create mode 100644 src/main/emulator/serve-sim-runtime-materializer.ts diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 9e482825a73..778600f24ca 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -7,6 +7,7 @@ const { prunePackagedRuntimeNodeModules, verifyPackagedMainRuntimeDeps } = require('./packaged-runtime-node-modules.cjs') +const { compressMacServeSimCameraDylibs } = require('./serve-sim-camera-packaging.cjs') const isMacRelease = process.env.ORCA_MAC_RELEASE === '1' const isLinuxArm64Release = process.env.ORCA_LINUX_ARM64_RELEASE === '1' @@ -131,6 +132,7 @@ module.exports = { verifyPackagedMainRuntimeDeps(resourcesDir) chmodUnixCliLaunchers(resourcesDir, context.electronPlatformName) chmodMacServeSimHelpers(resourcesDir, context.electronPlatformName) + compressMacServeSimCameraDylibs(resourcesDir, context.electronPlatformName) for (const filename of readdirSync(resourcesDir)) { if (!filename.startsWith('agent-browser-')) { continue diff --git a/config/scripts/serve-sim-camera-packaging.test.mjs b/config/scripts/serve-sim-camera-packaging.test.mjs new file mode 100644 index 00000000000..88eeaf86290 --- /dev/null +++ b/config/scripts/serve-sim-camera-packaging.test.mjs @@ -0,0 +1,104 @@ +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { gunzipSync } from 'node:zlib' +import { afterEach, describe, expect, it } from 'vitest' + +const require = createRequire(import.meta.url) +const { compressMacServeSimCameraDylibs } = require('../serve-sim-camera-packaging.cjs') + +const DYLIB_CONTENT = Buffer.from('fake-ios-simulator-dylib') + +async function createServeSimPackage(packageDir, { withSources = true } = {}) { + await mkdir(join(packageDir, 'dist', 'simcam'), { recursive: true }) + await writeFile(join(packageDir, 'dist', 'simcam', 'libSimCameraInjector.dylib'), DYLIB_CONTENT) + await writeFile(join(packageDir, 'dist', 'simcam', 'serve-sim-camera-helper'), 'helper') + if (withSources) { + await mkdir(join(packageDir, 'Sources', 'SimCameraInjector'), { recursive: true }) + await writeFile(join(packageDir, 'Sources', 'SimCameraInjector', 'build.sh'), 'echo build') + } +} + +describe('compressMacServeSimCameraDylibs', () => { + const cleanupPaths = [] + + afterEach(async () => { + for (const path of cleanupPaths.splice(0)) { + await rm(path, { recursive: true, force: true }) + } + }) + + async function createResourcesDir() { + const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-simcam-packaging-')) + cleanupPaths.push(resourcesDir) + return resourcesDir + } + + it('replaces raw dylibs with gzip payloads in both packaged copies', async () => { + const resourcesDir = await createResourcesDir() + await createServeSimPackage(join(resourcesDir, 'serve-sim')) + await createServeSimPackage(join(resourcesDir, 'node_modules', 'serve-sim')) + + compressMacServeSimCameraDylibs(resourcesDir, 'darwin') + + for (const packageDir of ['serve-sim', join('node_modules', 'serve-sim')]) { + const dylibPath = join( + resourcesDir, + packageDir, + 'dist', + 'simcam', + 'libSimCameraInjector.dylib' + ) + await expect(stat(dylibPath)).rejects.toThrow() + expect(gunzipSync(await readFile(`${dylibPath}.gz`))).toEqual(DYLIB_CONTENT) + const helperPath = join(resourcesDir, packageDir, 'dist', 'simcam', 'serve-sim-camera-helper') + expect(await readFile(helperPath, 'utf8')).toBe('helper') + } + }) + + it('removes Sources so the build-from-source fallback cannot write into the sealed bundle', async () => { + const resourcesDir = await createResourcesDir() + await createServeSimPackage(join(resourcesDir, 'serve-sim')) + + compressMacServeSimCameraDylibs(resourcesDir, 'darwin') + + await expect(stat(join(resourcesDir, 'serve-sim', 'Sources'))).rejects.toThrow() + }) + + it('throws when a packaged copy is missing its dylib', async () => { + const resourcesDir = await createResourcesDir() + await createServeSimPackage(join(resourcesDir, 'serve-sim')) + await rm(join(resourcesDir, 'serve-sim', 'dist', 'simcam', 'libSimCameraInjector.dylib')) + + expect(() => compressMacServeSimCameraDylibs(resourcesDir, 'darwin')).toThrow( + /camera dylib missing/ + ) + }) + + it('throws when no packaged serve-sim copy exists on darwin', async () => { + const resourcesDir = await createResourcesDir() + + expect(() => compressMacServeSimCameraDylibs(resourcesDir, 'darwin')).toThrow( + /No packaged serve-sim copies/ + ) + }) + + it('leaves non-darwin packaging untouched', async () => { + const resourcesDir = await createResourcesDir() + await createServeSimPackage(join(resourcesDir, 'serve-sim')) + + compressMacServeSimCameraDylibs(resourcesDir, 'win32') + compressMacServeSimCameraDylibs(resourcesDir, 'linux') + + const dylibPath = join( + resourcesDir, + 'serve-sim', + 'dist', + 'simcam', + 'libSimCameraInjector.dylib' + ) + expect(await readFile(dylibPath)).toEqual(DYLIB_CONTENT) + await expect(stat(join(resourcesDir, 'serve-sim', 'Sources'))).resolves.toBeDefined() + }) +}) diff --git a/config/serve-sim-camera-packaging.cjs b/config/serve-sim-camera-packaging.cjs new file mode 100644 index 00000000000..aa3bd50aab4 --- /dev/null +++ b/config/serve-sim-camera-packaging.cjs @@ -0,0 +1,35 @@ +const { existsSync, readFileSync, rmSync, writeFileSync } = require('node:fs') +const { join } = require('node:path') +const { gzipSync } = require('node:zlib') + +// Why: libSimCameraInjector.dylib targets the iOS-simulator platform, which +// Apple's notary service never tickets for arm64 — a raw copy anywhere in the +// bundle is permanently rejected by Gatekeeper assessment. Shipping it gzipped +// makes it plain data (unsigned, unassessed); the app materializes a +// quarantine-free copy at runtime before serve-sim injects it. +function compressMacServeSimCameraDylibs(resourcesDir, electronPlatformName) { + if (electronPlatformName !== 'darwin') { + return + } + const packageDirs = [ + join(resourcesDir, 'serve-sim'), + join(resourcesDir, 'node_modules', 'serve-sim') + ].filter((packageDir) => existsSync(packageDir)) + if (packageDirs.length === 0) { + throw new Error(`No packaged serve-sim copies found under ${resourcesDir}`) + } + for (const packageDir of packageDirs) { + const dylibPath = join(packageDir, 'dist', 'simcam', 'libSimCameraInjector.dylib') + if (!existsSync(dylibPath)) { + throw new Error(`Expected serve-sim camera dylib missing: ${dylibPath}`) + } + writeFileSync(`${dylibPath}.gz`, gzipSync(readFileSync(dylibPath))) + rmSync(dylibPath) + // Why: with the prebuilt dylib gone, serve-sim's build-from-source fallback + // would compile into the sealed bundle and break its signature. Without + // Sources the fallback fails with serve-sim's own reinstall error instead. + rmSync(join(packageDir, 'Sources'), { recursive: true, force: true }) + } +} + +module.exports = { compressMacServeSimCameraDylibs } diff --git a/src/main/emulator/serve-sim-execution.ts b/src/main/emulator/serve-sim-execution.ts index 62631cf23aa..bda31aa3a49 100644 --- a/src/main/emulator/serve-sim-execution.ts +++ b/src/main/emulator/serve-sim-execution.ts @@ -12,6 +12,7 @@ import { app } from 'electron' import { platform, tmpdir } from 'node:os' import { delimiter, dirname, join } from 'node:path' import { EmulatorError } from './emulator-errors' +import { materializeServeSimRuntime } from './serve-sim-runtime-materializer' const EXEC_TIMEOUT_MS = 90_000 const MAC_OPEN_SHIM_DIR = join(tmpdir(), 'orca-serve-sim-open-shim') @@ -67,6 +68,27 @@ function getServeSimEnv(executable: ServeSimExecutable): NodeJS.ProcessEnv { return env } +// Cached per process: materialization is a one-time copy; later calls only stat. +let materializedServeSimPackageDir: string | null | undefined + +function resolveMaterializedServeSimPackageDir(bundledPackageDir: string): string | null { + if (materializedServeSimPackageDir !== undefined) { + return materializedServeSimPackageDir + } + materializedServeSimPackageDir = materializeServeSimRuntime({ + bundledPackageDir, + targetRootDir: join(app.getPath('userData'), 'serve-sim-runtime'), + version: app.getVersion() + }) + if (materializedServeSimPackageDir === null) { + console.warn( + '[serve-sim] runtime materialization failed; running from the app bundle ' + + '(camera injection may be blocked by Gatekeeper)' + ) + } + return materializedServeSimPackageDir +} + export function resolveServeSimExecutable(): ServeSimExecutable { const bundledResourcesPath = process.resourcesPath ?? @@ -75,6 +97,21 @@ export function resolveServeSimExecutable(): ServeSimExecutable { : join(app.getPath('exe'), '..', 'resources')) const bundled = join(bundledResourcesPath, 'serve-sim', 'dist', 'serve-sim.js') if (existsSync(bundled)) { + if (process.platform === 'darwin') { + // Why: the macOS bundle ships the camera dylib gzipped (an iOS-simulator + // binary Apple never notarizes), so serve-sim must run from a + // quarantine-free materialized copy for camera injection to work. + const materializedDir = resolveMaterializedServeSimPackageDir( + join(bundledResourcesPath, 'serve-sim') + ) + if (materializedDir) { + return { + command: process.execPath, + baseArgs: [join(materializedDir, 'dist', 'serve-sim.js')], + usesElectronAsNode: true + } + } + } return { command: process.execPath, baseArgs: [bundled], usesElectronAsNode: true } } diff --git a/src/main/emulator/serve-sim-runtime-materializer.test.ts b/src/main/emulator/serve-sim-runtime-materializer.test.ts new file mode 100644 index 00000000000..90848f717cc --- /dev/null +++ b/src/main/emulator/serve-sim-runtime-materializer.test.ts @@ -0,0 +1,158 @@ +import { mkdtemp, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { gzipSync } from 'node:zlib' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { materializeServeSimRuntime } from './serve-sim-runtime-materializer' + +const DYLIB_CONTENT = Buffer.from('fake-simcam-dylib-mach-o-bytes') + +async function createBundledServeSimPackage(root: string): Promise { + const packageDir = join(root, 'bundled-serve-sim') + await mkdir(join(packageDir, 'dist', 'simcam'), { recursive: true }) + await mkdir(join(packageDir, 'bin'), { recursive: true }) + await writeFile(join(packageDir, 'dist', 'serve-sim.js'), 'console.log("serve-sim")') + await writeFile( + join(packageDir, 'dist', 'simcam', 'libSimCameraInjector.dylib.gz'), + gzipSync(DYLIB_CONTENT) + ) + await writeFile(join(packageDir, 'dist', 'simcam', 'serve-sim-camera-helper'), 'helper', { + mode: 0o644 + }) + await writeFile(join(packageDir, 'bin', 'serve-sim-bin'), 'bin', { mode: 0o644 }) + return packageDir +} + +describe('materializeServeSimRuntime', () => { + const cleanupPaths: string[] = [] + + afterEach(async () => { + for (const path of cleanupPaths.splice(0)) { + await rm(path, { recursive: true, force: true }) + } + }) + + async function createRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-simcam-materializer-')) + cleanupPaths.push(root) + return root + } + + it('copies the package, restores the gzipped dylib, and clears quarantine', async () => { + const root = await createRoot() + const bundledPackageDir = await createBundledServeSimPackage(root) + const clearQuarantine = vi.fn() + + const materialized = materializeServeSimRuntime({ + bundledPackageDir, + targetRootDir: join(root, 'runtime'), + version: '1.2.3', + clearQuarantine + }) + + expect(materialized).toBe(join(root, 'runtime', '1.2.3')) + const dylibPath = join(materialized!, 'dist', 'simcam', 'libSimCameraInjector.dylib') + expect(await readFile(dylibPath)).toEqual(DYLIB_CONTENT) + await expect(stat(`${dylibPath}.gz`)).rejects.toThrow() + expect(clearQuarantine).toHaveBeenCalledTimes(1) + if (process.platform !== 'win32') { + for (const executable of [ + join(materialized!, 'bin', 'serve-sim-bin'), + join(materialized!, 'dist', 'simcam', 'serve-sim-camera-helper') + ]) { + expect(((await stat(executable)).mode & 0o111) !== 0).toBe(true) + } + } + }) + + it('returns the existing runtime without re-copying', async () => { + const root = await createRoot() + const bundledPackageDir = await createBundledServeSimPackage(root) + const clearQuarantine = vi.fn() + const options = { + bundledPackageDir, + targetRootDir: join(root, 'runtime'), + version: '1.2.3', + clearQuarantine + } + + const first = materializeServeSimRuntime(options) + const second = materializeServeSimRuntime(options) + + expect(second).toBe(first) + expect(clearQuarantine).toHaveBeenCalledTimes(1) + }) + + it('prunes runtimes left behind by older app versions', async () => { + const root = await createRoot() + const bundledPackageDir = await createBundledServeSimPackage(root) + const targetRootDir = join(root, 'runtime') + await mkdir(join(targetRootDir, '1.0.0', 'dist'), { recursive: true }) + await writeFile(join(targetRootDir, '1.0.0', 'dist', 'serve-sim.js'), 'old') + + materializeServeSimRuntime({ + bundledPackageDir, + targetRootDir, + version: '1.2.3', + clearQuarantine: () => {} + }) + + await expect(stat(join(targetRootDir, '1.0.0'))).rejects.toThrow() + }) + + it('materializes packages that still ship a raw dylib', async () => { + const root = await createRoot() + const bundledPackageDir = await createBundledServeSimPackage(root) + await rm(join(bundledPackageDir, 'dist', 'simcam', 'libSimCameraInjector.dylib.gz')) + await writeFile( + join(bundledPackageDir, 'dist', 'simcam', 'libSimCameraInjector.dylib'), + DYLIB_CONTENT + ) + + const materialized = materializeServeSimRuntime({ + bundledPackageDir, + targetRootDir: join(root, 'runtime'), + version: '1.2.3', + clearQuarantine: () => {} + }) + + expect(materialized).not.toBeNull() + expect( + await readFile(join(materialized!, 'dist', 'simcam', 'libSimCameraInjector.dylib')) + ).toEqual(DYLIB_CONTENT) + }) + + it('returns null and leaves no staging behind when quarantine clearing fails', async () => { + const root = await createRoot() + const bundledPackageDir = await createBundledServeSimPackage(root) + const targetRootDir = join(root, 'runtime') + + const materialized = materializeServeSimRuntime({ + bundledPackageDir, + targetRootDir, + version: '1.2.3', + clearQuarantine: () => { + throw new Error('xattr failed') + } + }) + + expect(materialized).toBeNull() + await expect(stat(join(targetRootDir, '1.2.3'))).rejects.toThrow() + const { readdir } = await import('node:fs/promises') + const leftovers = (await readdir(targetRootDir)).filter((name) => name.startsWith('.staging')) + expect(leftovers).toEqual([]) + }) + + it('returns null when the bundled package is missing', async () => { + const root = await createRoot() + + const materialized = materializeServeSimRuntime({ + bundledPackageDir: join(root, 'does-not-exist'), + targetRootDir: join(root, 'runtime'), + version: '1.2.3', + clearQuarantine: () => {} + }) + + expect(materialized).toBeNull() + }) +}) diff --git a/src/main/emulator/serve-sim-runtime-materializer.ts b/src/main/emulator/serve-sim-runtime-materializer.ts new file mode 100644 index 00000000000..baffd81e9cc --- /dev/null +++ b/src/main/emulator/serve-sim-runtime-materializer.ts @@ -0,0 +1,110 @@ +import { execFileSync } from 'node:child_process' +import { + chmodSync, + cpSync, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + renameSync, + rmSync, + writeFileSync +} from 'node:fs' +import { join } from 'node:path' +import { gunzipSync } from 'node:zlib' + +export type ServeSimRuntimeMaterializerOptions = { + bundledPackageDir: string + targetRootDir: string + version: string + clearQuarantine?: (dir: string) => void +} + +const CAMERA_DYLIB_RELATIVE_PATH = join('dist', 'simcam', 'libSimCameraInjector.dylib') +const EXECUTABLE_RELATIVE_PATHS = [ + join('bin', 'serve-sim-bin'), + join('dist', 'simcam', 'serve-sim-camera-helper') +] + +function defaultClearQuarantine(dir: string): void { + if (process.platform !== 'darwin') { + return + } + // Why: bundle files carry com.apple.quarantine after download/update and + // cpSync can clone xattrs; a quarantined camera dylib injected into a + // simulator process is what syspolicyd malware-rejects. + execFileSync('/usr/bin/xattr', ['-cr', dir], { timeout: 30_000 }) +} + +function pruneStaleServeSimRuntimes(targetRootDir: string, keepVersion: string): void { + let entries: string[] + try { + entries = readdirSync(targetRootDir) + } catch { + return + } + for (const entryName of entries) { + if (entryName === keepVersion) { + continue + } + try { + rmSync(join(targetRootDir, entryName), { recursive: true, force: true }) + } catch { + // Old-version cleanup is best-effort; a locked file must not block materialization. + } + } +} + +// Copies the bundled serve-sim package to a per-version directory outside the +// signed app bundle, restores the gzipped camera dylib, and strips quarantine. +// Why: the bundle cannot ship the raw dylib (iOS-simulator platform, never +// notarizable), and serve-sim resolves camera assets relative to its own +// entry, so the whole package must run from the materialized copy. +export function materializeServeSimRuntime( + options: ServeSimRuntimeMaterializerOptions +): string | null { + const { bundledPackageDir, targetRootDir, version } = options + const clearQuarantine = options.clearQuarantine ?? defaultClearQuarantine + const targetDir = join(targetRootDir, version) + const entryPath = join(targetDir, 'dist', 'serve-sim.js') + if (existsSync(entryPath)) { + return targetDir + } + const stagingDir = join(targetRootDir, `.staging-${version}-${process.pid}`) + try { + mkdirSync(targetRootDir, { recursive: true }) + pruneStaleServeSimRuntimes(targetRootDir, version) + rmSync(stagingDir, { recursive: true, force: true }) + rmSync(targetDir, { recursive: true, force: true }) + cpSync(bundledPackageDir, stagingDir, { recursive: true }) + const gzippedDylibPath = join(stagingDir, `${CAMERA_DYLIB_RELATIVE_PATH}.gz`) + if (existsSync(gzippedDylibPath)) { + writeFileSync( + join(stagingDir, CAMERA_DYLIB_RELATIVE_PATH), + gunzipSync(readFileSync(gzippedDylibPath)), + { mode: 0o755 } + ) + rmSync(gzippedDylibPath) + } + for (const relativePath of EXECUTABLE_RELATIVE_PATHS) { + const executablePath = join(stagingDir, relativePath) + if (existsSync(executablePath)) { + chmodSync(executablePath, 0o755) + } + } + clearQuarantine(stagingDir) + try { + renameSync(stagingDir, targetDir) + } catch (error) { + // Another app instance sharing userData may have finished first. + if (!existsSync(entryPath)) { + throw error + } + } + return existsSync(entryPath) ? targetDir : null + } catch { + return null + } finally { + rmSync(stagingDir, { recursive: true, force: true }) + } +} From 777b757cdb8090a6243d7e8d6bc14033e3f67e80 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 01:25:04 +0000 Subject: [PATCH 02/48] release: v1.4.120-rc.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 82c0f8493c6..52db1a3a848 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.120-rc.2", + "version": "1.4.120-rc.3", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 087ad66aa2758215684a482d771057320a6cc771 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:46:00 -0700 Subject: [PATCH 03/48] Revert "Ship serve-sim camera dylib as data and materialize it at runtime" (#7172) --- config/electron-builder.config.cjs | 2 - .../serve-sim-camera-packaging.test.mjs | 104 ------------ config/serve-sim-camera-packaging.cjs | 35 ---- src/main/emulator/serve-sim-execution.ts | 37 ---- .../serve-sim-runtime-materializer.test.ts | 158 ------------------ .../serve-sim-runtime-materializer.ts | 110 ------------ 6 files changed, 446 deletions(-) delete mode 100644 config/scripts/serve-sim-camera-packaging.test.mjs delete mode 100644 config/serve-sim-camera-packaging.cjs delete mode 100644 src/main/emulator/serve-sim-runtime-materializer.test.ts delete mode 100644 src/main/emulator/serve-sim-runtime-materializer.ts diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 778600f24ca..9e482825a73 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -7,7 +7,6 @@ const { prunePackagedRuntimeNodeModules, verifyPackagedMainRuntimeDeps } = require('./packaged-runtime-node-modules.cjs') -const { compressMacServeSimCameraDylibs } = require('./serve-sim-camera-packaging.cjs') const isMacRelease = process.env.ORCA_MAC_RELEASE === '1' const isLinuxArm64Release = process.env.ORCA_LINUX_ARM64_RELEASE === '1' @@ -132,7 +131,6 @@ module.exports = { verifyPackagedMainRuntimeDeps(resourcesDir) chmodUnixCliLaunchers(resourcesDir, context.electronPlatformName) chmodMacServeSimHelpers(resourcesDir, context.electronPlatformName) - compressMacServeSimCameraDylibs(resourcesDir, context.electronPlatformName) for (const filename of readdirSync(resourcesDir)) { if (!filename.startsWith('agent-browser-')) { continue diff --git a/config/scripts/serve-sim-camera-packaging.test.mjs b/config/scripts/serve-sim-camera-packaging.test.mjs deleted file mode 100644 index 88eeaf86290..00000000000 --- a/config/scripts/serve-sim-camera-packaging.test.mjs +++ /dev/null @@ -1,104 +0,0 @@ -import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' -import { createRequire } from 'node:module' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { gunzipSync } from 'node:zlib' -import { afterEach, describe, expect, it } from 'vitest' - -const require = createRequire(import.meta.url) -const { compressMacServeSimCameraDylibs } = require('../serve-sim-camera-packaging.cjs') - -const DYLIB_CONTENT = Buffer.from('fake-ios-simulator-dylib') - -async function createServeSimPackage(packageDir, { withSources = true } = {}) { - await mkdir(join(packageDir, 'dist', 'simcam'), { recursive: true }) - await writeFile(join(packageDir, 'dist', 'simcam', 'libSimCameraInjector.dylib'), DYLIB_CONTENT) - await writeFile(join(packageDir, 'dist', 'simcam', 'serve-sim-camera-helper'), 'helper') - if (withSources) { - await mkdir(join(packageDir, 'Sources', 'SimCameraInjector'), { recursive: true }) - await writeFile(join(packageDir, 'Sources', 'SimCameraInjector', 'build.sh'), 'echo build') - } -} - -describe('compressMacServeSimCameraDylibs', () => { - const cleanupPaths = [] - - afterEach(async () => { - for (const path of cleanupPaths.splice(0)) { - await rm(path, { recursive: true, force: true }) - } - }) - - async function createResourcesDir() { - const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-simcam-packaging-')) - cleanupPaths.push(resourcesDir) - return resourcesDir - } - - it('replaces raw dylibs with gzip payloads in both packaged copies', async () => { - const resourcesDir = await createResourcesDir() - await createServeSimPackage(join(resourcesDir, 'serve-sim')) - await createServeSimPackage(join(resourcesDir, 'node_modules', 'serve-sim')) - - compressMacServeSimCameraDylibs(resourcesDir, 'darwin') - - for (const packageDir of ['serve-sim', join('node_modules', 'serve-sim')]) { - const dylibPath = join( - resourcesDir, - packageDir, - 'dist', - 'simcam', - 'libSimCameraInjector.dylib' - ) - await expect(stat(dylibPath)).rejects.toThrow() - expect(gunzipSync(await readFile(`${dylibPath}.gz`))).toEqual(DYLIB_CONTENT) - const helperPath = join(resourcesDir, packageDir, 'dist', 'simcam', 'serve-sim-camera-helper') - expect(await readFile(helperPath, 'utf8')).toBe('helper') - } - }) - - it('removes Sources so the build-from-source fallback cannot write into the sealed bundle', async () => { - const resourcesDir = await createResourcesDir() - await createServeSimPackage(join(resourcesDir, 'serve-sim')) - - compressMacServeSimCameraDylibs(resourcesDir, 'darwin') - - await expect(stat(join(resourcesDir, 'serve-sim', 'Sources'))).rejects.toThrow() - }) - - it('throws when a packaged copy is missing its dylib', async () => { - const resourcesDir = await createResourcesDir() - await createServeSimPackage(join(resourcesDir, 'serve-sim')) - await rm(join(resourcesDir, 'serve-sim', 'dist', 'simcam', 'libSimCameraInjector.dylib')) - - expect(() => compressMacServeSimCameraDylibs(resourcesDir, 'darwin')).toThrow( - /camera dylib missing/ - ) - }) - - it('throws when no packaged serve-sim copy exists on darwin', async () => { - const resourcesDir = await createResourcesDir() - - expect(() => compressMacServeSimCameraDylibs(resourcesDir, 'darwin')).toThrow( - /No packaged serve-sim copies/ - ) - }) - - it('leaves non-darwin packaging untouched', async () => { - const resourcesDir = await createResourcesDir() - await createServeSimPackage(join(resourcesDir, 'serve-sim')) - - compressMacServeSimCameraDylibs(resourcesDir, 'win32') - compressMacServeSimCameraDylibs(resourcesDir, 'linux') - - const dylibPath = join( - resourcesDir, - 'serve-sim', - 'dist', - 'simcam', - 'libSimCameraInjector.dylib' - ) - expect(await readFile(dylibPath)).toEqual(DYLIB_CONTENT) - await expect(stat(join(resourcesDir, 'serve-sim', 'Sources'))).resolves.toBeDefined() - }) -}) diff --git a/config/serve-sim-camera-packaging.cjs b/config/serve-sim-camera-packaging.cjs deleted file mode 100644 index aa3bd50aab4..00000000000 --- a/config/serve-sim-camera-packaging.cjs +++ /dev/null @@ -1,35 +0,0 @@ -const { existsSync, readFileSync, rmSync, writeFileSync } = require('node:fs') -const { join } = require('node:path') -const { gzipSync } = require('node:zlib') - -// Why: libSimCameraInjector.dylib targets the iOS-simulator platform, which -// Apple's notary service never tickets for arm64 — a raw copy anywhere in the -// bundle is permanently rejected by Gatekeeper assessment. Shipping it gzipped -// makes it plain data (unsigned, unassessed); the app materializes a -// quarantine-free copy at runtime before serve-sim injects it. -function compressMacServeSimCameraDylibs(resourcesDir, electronPlatformName) { - if (electronPlatformName !== 'darwin') { - return - } - const packageDirs = [ - join(resourcesDir, 'serve-sim'), - join(resourcesDir, 'node_modules', 'serve-sim') - ].filter((packageDir) => existsSync(packageDir)) - if (packageDirs.length === 0) { - throw new Error(`No packaged serve-sim copies found under ${resourcesDir}`) - } - for (const packageDir of packageDirs) { - const dylibPath = join(packageDir, 'dist', 'simcam', 'libSimCameraInjector.dylib') - if (!existsSync(dylibPath)) { - throw new Error(`Expected serve-sim camera dylib missing: ${dylibPath}`) - } - writeFileSync(`${dylibPath}.gz`, gzipSync(readFileSync(dylibPath))) - rmSync(dylibPath) - // Why: with the prebuilt dylib gone, serve-sim's build-from-source fallback - // would compile into the sealed bundle and break its signature. Without - // Sources the fallback fails with serve-sim's own reinstall error instead. - rmSync(join(packageDir, 'Sources'), { recursive: true, force: true }) - } -} - -module.exports = { compressMacServeSimCameraDylibs } diff --git a/src/main/emulator/serve-sim-execution.ts b/src/main/emulator/serve-sim-execution.ts index bda31aa3a49..62631cf23aa 100644 --- a/src/main/emulator/serve-sim-execution.ts +++ b/src/main/emulator/serve-sim-execution.ts @@ -12,7 +12,6 @@ import { app } from 'electron' import { platform, tmpdir } from 'node:os' import { delimiter, dirname, join } from 'node:path' import { EmulatorError } from './emulator-errors' -import { materializeServeSimRuntime } from './serve-sim-runtime-materializer' const EXEC_TIMEOUT_MS = 90_000 const MAC_OPEN_SHIM_DIR = join(tmpdir(), 'orca-serve-sim-open-shim') @@ -68,27 +67,6 @@ function getServeSimEnv(executable: ServeSimExecutable): NodeJS.ProcessEnv { return env } -// Cached per process: materialization is a one-time copy; later calls only stat. -let materializedServeSimPackageDir: string | null | undefined - -function resolveMaterializedServeSimPackageDir(bundledPackageDir: string): string | null { - if (materializedServeSimPackageDir !== undefined) { - return materializedServeSimPackageDir - } - materializedServeSimPackageDir = materializeServeSimRuntime({ - bundledPackageDir, - targetRootDir: join(app.getPath('userData'), 'serve-sim-runtime'), - version: app.getVersion() - }) - if (materializedServeSimPackageDir === null) { - console.warn( - '[serve-sim] runtime materialization failed; running from the app bundle ' + - '(camera injection may be blocked by Gatekeeper)' - ) - } - return materializedServeSimPackageDir -} - export function resolveServeSimExecutable(): ServeSimExecutable { const bundledResourcesPath = process.resourcesPath ?? @@ -97,21 +75,6 @@ export function resolveServeSimExecutable(): ServeSimExecutable { : join(app.getPath('exe'), '..', 'resources')) const bundled = join(bundledResourcesPath, 'serve-sim', 'dist', 'serve-sim.js') if (existsSync(bundled)) { - if (process.platform === 'darwin') { - // Why: the macOS bundle ships the camera dylib gzipped (an iOS-simulator - // binary Apple never notarizes), so serve-sim must run from a - // quarantine-free materialized copy for camera injection to work. - const materializedDir = resolveMaterializedServeSimPackageDir( - join(bundledResourcesPath, 'serve-sim') - ) - if (materializedDir) { - return { - command: process.execPath, - baseArgs: [join(materializedDir, 'dist', 'serve-sim.js')], - usesElectronAsNode: true - } - } - } return { command: process.execPath, baseArgs: [bundled], usesElectronAsNode: true } } diff --git a/src/main/emulator/serve-sim-runtime-materializer.test.ts b/src/main/emulator/serve-sim-runtime-materializer.test.ts deleted file mode 100644 index 90848f717cc..00000000000 --- a/src/main/emulator/serve-sim-runtime-materializer.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { mkdtemp, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { gzipSync } from 'node:zlib' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { materializeServeSimRuntime } from './serve-sim-runtime-materializer' - -const DYLIB_CONTENT = Buffer.from('fake-simcam-dylib-mach-o-bytes') - -async function createBundledServeSimPackage(root: string): Promise { - const packageDir = join(root, 'bundled-serve-sim') - await mkdir(join(packageDir, 'dist', 'simcam'), { recursive: true }) - await mkdir(join(packageDir, 'bin'), { recursive: true }) - await writeFile(join(packageDir, 'dist', 'serve-sim.js'), 'console.log("serve-sim")') - await writeFile( - join(packageDir, 'dist', 'simcam', 'libSimCameraInjector.dylib.gz'), - gzipSync(DYLIB_CONTENT) - ) - await writeFile(join(packageDir, 'dist', 'simcam', 'serve-sim-camera-helper'), 'helper', { - mode: 0o644 - }) - await writeFile(join(packageDir, 'bin', 'serve-sim-bin'), 'bin', { mode: 0o644 }) - return packageDir -} - -describe('materializeServeSimRuntime', () => { - const cleanupPaths: string[] = [] - - afterEach(async () => { - for (const path of cleanupPaths.splice(0)) { - await rm(path, { recursive: true, force: true }) - } - }) - - async function createRoot(): Promise { - const root = await mkdtemp(join(tmpdir(), 'orca-simcam-materializer-')) - cleanupPaths.push(root) - return root - } - - it('copies the package, restores the gzipped dylib, and clears quarantine', async () => { - const root = await createRoot() - const bundledPackageDir = await createBundledServeSimPackage(root) - const clearQuarantine = vi.fn() - - const materialized = materializeServeSimRuntime({ - bundledPackageDir, - targetRootDir: join(root, 'runtime'), - version: '1.2.3', - clearQuarantine - }) - - expect(materialized).toBe(join(root, 'runtime', '1.2.3')) - const dylibPath = join(materialized!, 'dist', 'simcam', 'libSimCameraInjector.dylib') - expect(await readFile(dylibPath)).toEqual(DYLIB_CONTENT) - await expect(stat(`${dylibPath}.gz`)).rejects.toThrow() - expect(clearQuarantine).toHaveBeenCalledTimes(1) - if (process.platform !== 'win32') { - for (const executable of [ - join(materialized!, 'bin', 'serve-sim-bin'), - join(materialized!, 'dist', 'simcam', 'serve-sim-camera-helper') - ]) { - expect(((await stat(executable)).mode & 0o111) !== 0).toBe(true) - } - } - }) - - it('returns the existing runtime without re-copying', async () => { - const root = await createRoot() - const bundledPackageDir = await createBundledServeSimPackage(root) - const clearQuarantine = vi.fn() - const options = { - bundledPackageDir, - targetRootDir: join(root, 'runtime'), - version: '1.2.3', - clearQuarantine - } - - const first = materializeServeSimRuntime(options) - const second = materializeServeSimRuntime(options) - - expect(second).toBe(first) - expect(clearQuarantine).toHaveBeenCalledTimes(1) - }) - - it('prunes runtimes left behind by older app versions', async () => { - const root = await createRoot() - const bundledPackageDir = await createBundledServeSimPackage(root) - const targetRootDir = join(root, 'runtime') - await mkdir(join(targetRootDir, '1.0.0', 'dist'), { recursive: true }) - await writeFile(join(targetRootDir, '1.0.0', 'dist', 'serve-sim.js'), 'old') - - materializeServeSimRuntime({ - bundledPackageDir, - targetRootDir, - version: '1.2.3', - clearQuarantine: () => {} - }) - - await expect(stat(join(targetRootDir, '1.0.0'))).rejects.toThrow() - }) - - it('materializes packages that still ship a raw dylib', async () => { - const root = await createRoot() - const bundledPackageDir = await createBundledServeSimPackage(root) - await rm(join(bundledPackageDir, 'dist', 'simcam', 'libSimCameraInjector.dylib.gz')) - await writeFile( - join(bundledPackageDir, 'dist', 'simcam', 'libSimCameraInjector.dylib'), - DYLIB_CONTENT - ) - - const materialized = materializeServeSimRuntime({ - bundledPackageDir, - targetRootDir: join(root, 'runtime'), - version: '1.2.3', - clearQuarantine: () => {} - }) - - expect(materialized).not.toBeNull() - expect( - await readFile(join(materialized!, 'dist', 'simcam', 'libSimCameraInjector.dylib')) - ).toEqual(DYLIB_CONTENT) - }) - - it('returns null and leaves no staging behind when quarantine clearing fails', async () => { - const root = await createRoot() - const bundledPackageDir = await createBundledServeSimPackage(root) - const targetRootDir = join(root, 'runtime') - - const materialized = materializeServeSimRuntime({ - bundledPackageDir, - targetRootDir, - version: '1.2.3', - clearQuarantine: () => { - throw new Error('xattr failed') - } - }) - - expect(materialized).toBeNull() - await expect(stat(join(targetRootDir, '1.2.3'))).rejects.toThrow() - const { readdir } = await import('node:fs/promises') - const leftovers = (await readdir(targetRootDir)).filter((name) => name.startsWith('.staging')) - expect(leftovers).toEqual([]) - }) - - it('returns null when the bundled package is missing', async () => { - const root = await createRoot() - - const materialized = materializeServeSimRuntime({ - bundledPackageDir: join(root, 'does-not-exist'), - targetRootDir: join(root, 'runtime'), - version: '1.2.3', - clearQuarantine: () => {} - }) - - expect(materialized).toBeNull() - }) -}) diff --git a/src/main/emulator/serve-sim-runtime-materializer.ts b/src/main/emulator/serve-sim-runtime-materializer.ts deleted file mode 100644 index baffd81e9cc..00000000000 --- a/src/main/emulator/serve-sim-runtime-materializer.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { execFileSync } from 'node:child_process' -import { - chmodSync, - cpSync, - existsSync, - mkdirSync, - readFileSync, - readdirSync, - renameSync, - rmSync, - writeFileSync -} from 'node:fs' -import { join } from 'node:path' -import { gunzipSync } from 'node:zlib' - -export type ServeSimRuntimeMaterializerOptions = { - bundledPackageDir: string - targetRootDir: string - version: string - clearQuarantine?: (dir: string) => void -} - -const CAMERA_DYLIB_RELATIVE_PATH = join('dist', 'simcam', 'libSimCameraInjector.dylib') -const EXECUTABLE_RELATIVE_PATHS = [ - join('bin', 'serve-sim-bin'), - join('dist', 'simcam', 'serve-sim-camera-helper') -] - -function defaultClearQuarantine(dir: string): void { - if (process.platform !== 'darwin') { - return - } - // Why: bundle files carry com.apple.quarantine after download/update and - // cpSync can clone xattrs; a quarantined camera dylib injected into a - // simulator process is what syspolicyd malware-rejects. - execFileSync('/usr/bin/xattr', ['-cr', dir], { timeout: 30_000 }) -} - -function pruneStaleServeSimRuntimes(targetRootDir: string, keepVersion: string): void { - let entries: string[] - try { - entries = readdirSync(targetRootDir) - } catch { - return - } - for (const entryName of entries) { - if (entryName === keepVersion) { - continue - } - try { - rmSync(join(targetRootDir, entryName), { recursive: true, force: true }) - } catch { - // Old-version cleanup is best-effort; a locked file must not block materialization. - } - } -} - -// Copies the bundled serve-sim package to a per-version directory outside the -// signed app bundle, restores the gzipped camera dylib, and strips quarantine. -// Why: the bundle cannot ship the raw dylib (iOS-simulator platform, never -// notarizable), and serve-sim resolves camera assets relative to its own -// entry, so the whole package must run from the materialized copy. -export function materializeServeSimRuntime( - options: ServeSimRuntimeMaterializerOptions -): string | null { - const { bundledPackageDir, targetRootDir, version } = options - const clearQuarantine = options.clearQuarantine ?? defaultClearQuarantine - const targetDir = join(targetRootDir, version) - const entryPath = join(targetDir, 'dist', 'serve-sim.js') - if (existsSync(entryPath)) { - return targetDir - } - const stagingDir = join(targetRootDir, `.staging-${version}-${process.pid}`) - try { - mkdirSync(targetRootDir, { recursive: true }) - pruneStaleServeSimRuntimes(targetRootDir, version) - rmSync(stagingDir, { recursive: true, force: true }) - rmSync(targetDir, { recursive: true, force: true }) - cpSync(bundledPackageDir, stagingDir, { recursive: true }) - const gzippedDylibPath = join(stagingDir, `${CAMERA_DYLIB_RELATIVE_PATH}.gz`) - if (existsSync(gzippedDylibPath)) { - writeFileSync( - join(stagingDir, CAMERA_DYLIB_RELATIVE_PATH), - gunzipSync(readFileSync(gzippedDylibPath)), - { mode: 0o755 } - ) - rmSync(gzippedDylibPath) - } - for (const relativePath of EXECUTABLE_RELATIVE_PATHS) { - const executablePath = join(stagingDir, relativePath) - if (existsSync(executablePath)) { - chmodSync(executablePath, 0o755) - } - } - clearQuarantine(stagingDir) - try { - renameSync(stagingDir, targetDir) - } catch (error) { - // Another app instance sharing userData may have finished first. - if (!existsSync(entryPath)) { - throw error - } - } - return existsSync(entryPath) ? targetDir : null - } catch { - return null - } finally { - rmSync(stagingDir, { recursive: true, force: true }) - } -} From 0ea354d454e412d93dceab3e3b3a72e2953fd084 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 2 Jul 2026 20:39:49 -0700 Subject: [PATCH 04/48] Keep terminals responsive under agent output load (#7139) Co-authored-by: Neil --- .../pane-terminal-output-scheduler.test.ts | 62 ++++++++++++++++++- .../pane-terminal-output-scheduler.ts | 18 +++++- 2 files changed, 75 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts index 2e107d9b218..bca02b5fcc5 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts @@ -898,10 +898,66 @@ describe('pane terminal output scheduler', () => { expect(terminal.write).not.toHaveBeenCalled() vi.advanceTimersByTime(0) - expect(terminal.write).toHaveBeenCalledTimes(16) + expect(terminal.write).toHaveBeenCalledTimes(2) - vi.advanceTimersByTime(1) - expect(terminal.write).toHaveBeenCalledTimes(32) + vi.advanceTimersByTime(4) + expect(terminal.write).toHaveBeenCalledTimes(4) + }) + + it('yields high-priority backlog drains when writes spend the frame budget', async () => { + vi.useFakeTimers() + const { writeTerminalOutput } = await loadScheduler() + const terminal = createTerminal() + const chunk = 'x'.repeat(16 * 1024) + let now = 0 + const nowSpy = vi.spyOn(performance, 'now').mockImplementation(() => now) + terminal.write.mockImplementation((_data: string, callback?: () => void) => { + now += 9 + callback?.() + }) + + try { + for (let i = 0; i < 64; i++) { + writeTerminalOutput(terminal, chunk, { foreground: false }) + } + + vi.advanceTimersByTime(0) + expect(terminal.write).toHaveBeenCalledTimes(1) + + vi.advanceTimersByTime(4) + expect(terminal.write).toHaveBeenCalledTimes(2) + } finally { + nowSpy.mockRestore() + } + }) + + it('uses Date.now for drain budgeting when performance is unavailable', async () => { + vi.useFakeTimers() + vi.stubGlobal('performance', undefined) + const { writeTerminalOutput } = await loadScheduler() + const terminal = createTerminal() + const chunk = 'x'.repeat(16 * 1024) + let now = 0 + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => now) + terminal.write.mockImplementation((_data: string, callback?: () => void) => { + now += 9 + callback?.() + }) + + try { + for (let i = 0; i < 64; i++) { + writeTerminalOutput(terminal, chunk, { foreground: false }) + } + + vi.advanceTimersByTime(0) + expect(terminal.write).toHaveBeenCalledTimes(1) + expect(nowSpy).toHaveBeenCalled() + + vi.advanceTimersByTime(4) + expect(terminal.write).toHaveBeenCalledTimes(2) + } finally { + nowSpy.mockRestore() + } }) it('caps hidden backlog memory and writes a warning instead of retaining all output', async () => { diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts index c43fc8cf161..20a3b8138ca 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts @@ -68,10 +68,11 @@ type QueueEntry = { const BACKGROUND_FLUSH_DELAY_MS = 50 const BACKGROUND_DRAIN_INTERVAL_MS = 16 -const HIGH_PRIORITY_DRAIN_INTERVAL_MS = 1 +const HIGH_PRIORITY_DRAIN_INTERVAL_MS = 4 const BACKGROUND_CHUNK_CHARS = 16 * 1024 const MAX_WRITES_PER_DRAIN = 2 -const HIGH_PRIORITY_MAX_WRITES_PER_DRAIN = 16 +const HIGH_PRIORITY_MAX_WRITES_PER_DRAIN = 2 +const DRAIN_TIME_BUDGET_MS = 8 const LARGE_BACKLOG_CHARS = 512 * 1024 const SYNC_FOREGROUND_FLUSH_CHARS = 256 * 1024 const MAX_BACKGROUND_QUEUE_CHARS = 2 * 1024 * 1024 @@ -728,10 +729,18 @@ function writeQueuedChunk(entry: QueueEntry): 'foreground' | 'background' | null return queuedWrite.foreground ? 'foreground' : 'background' } +function getDrainNow(): number { + if (typeof performance !== 'undefined') { + return performance.now() + } + return Date.now() +} + function drainQueuedOutput(): void { drainTimer = null drainTimerDelayMs = null let writes = 0 + const startedAt = getDrainNow() const maxWrites = hasHighPriorityBacklog() ? HIGH_PRIORITY_MAX_WRITES_PER_DRAIN : MAX_WRITES_PER_DRAIN @@ -760,6 +769,11 @@ function drainQueuedOutput(): void { clearForegroundCoalesce(entry) clearForegroundHoldSafety(entry) } + // Why: xterm parsing and DOM work share the renderer thread with input. + // Keep backlog draining cooperative so WSL/agent output cannot pin the UI. + if (writes > 0 && getDrainNow() - startedAt >= DRAIN_TIME_BUDGET_MS) { + break + } } if (debugEnabled && writes > 0) { From 3e4ace0562e0a73d16d27c4a891ba0a8d1359ef5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 2 Jul 2026 21:00:03 -0700 Subject: [PATCH 05/48] Fix Codex pane output tearing after app occlusion (#7173) Co-authored-by: Orca --- .../terminal-pane/pty-connection.test.ts | 276 ++++++++++++++++++ .../terminal-pane/pty-connection.ts | 111 ++++++- 2 files changed, 381 insertions(+), 6 deletions(-) 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 211e6c1c819..873d4f793ce 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -1,6 +1,7 @@ /* oxlint-disable max-lines */ import type * as React from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Terminal } from '@xterm/headless' import { POST_REPLAY_LIVE_AGENT_REATTACH_RESET, POST_REPLAY_MODE_RESET, @@ -45,6 +46,26 @@ async function drainPendingTimeouts(pendingTimeouts: (() => void)[], limit = 100 } } +function writeHeadlessTerminal(term: Terminal, data: string): Promise { + return new Promise((resolve) => term.write(data, resolve)) +} + +async function renderHeadlessBuffer(writes: string[], cols = 80, rows = 8): Promise { + const term = new Terminal({ cols, rows, allowProposedApi: true }) + try { + for (const write of writes) { + await writeHeadlessTerminal(term, write) + } + const lines: string[] = [] + for (let lineIndex = 0; lineIndex < term.buffer.active.length; lineIndex++) { + lines.push(term.buffer.active.getLine(lineIndex)?.translateToString(true) ?? '') + } + return lines + } finally { + term.dispose() + } +} + const toastInfo = vi.fn() const LEAF_1 = '11111111-1111-4111-8111-111111111111' as const const LEAF_2 = '22222222-2222-4222-8222-222222222222' as const @@ -7000,6 +7021,261 @@ describe('connectPanePty', () => { binding.dispose() }) + it('does not apply stale background Codex query chunks after hidden snapshot restore', async () => { + const visibilityChangeHandler: { current: (() => void) | null } = { current: null } + ;(globalThis as { document?: Document }).document = { + visibilityState: 'visible', + addEventListener: vi.fn((type: string, listener: EventListenerOrEventListenerObject) => { + if (type === 'visibilitychange') { + visibilityChangeHandler.current = listener as () => void + } + }), + removeEventListener: vi.fn() + } as unknown as Document + + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: + | (( + data: string, + meta?: { seq?: number; rawLength?: number; background?: boolean } + ) => void) + | null + } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + + const hiddenFrame = '\r\x1b[2KWorking hidden' + const staleQueryFrame = '\r\x1b[2KWorking stale\x1b[6n' + const currentFrame = '\r\x1b[2KWorking current' + const staleSeq = hiddenFrame.length + staleQueryFrame.length + const currentSeq = staleSeq + currentFrame.length + // The main emulator has already parsed every frame, so the snapshot covers + // the query frame that is still in flight on the pty:data channel. + getMainBufferSnapshot.mockResolvedValue({ + data: currentFrame, + cols: 80, + rows: 8, + seq: currentSeq + }) + + const pane = createPane(1) + pane.terminal.cols = 80 + pane.terminal.rows = 8 + const { writes } = captureCallbackTerminalWrites(pane) + const manager = createManager(1) + const deps = createDeps({ + isVisibleRef: { current: false }, + startup: { command: 'codex' } + }) + const binding = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.(hiddenFrame, { + seq: hiddenFrame.length, + rawLength: hiddenFrame.length + }) + ;(deps.isVisibleRef as { current: boolean }).current = true + visibilityChangeHandler.current?.() + await flushAsyncTicks(20) + + // The in-flight chunks arrive in channel order after the restore already + // rebuilt the buffer from the newer snapshot. + vi.useFakeTimers() + try { + capturedDataCallback.current?.(staleQueryFrame, { + seq: staleSeq, + rawLength: staleQueryFrame.length, + background: true + }) + capturedDataCallback.current?.(currentFrame, { + seq: currentSeq, + rawLength: currentFrame.length, + background: true + }) + vi.advanceTimersByTime(50) + await flushAsyncTicks(6) + } finally { + vi.useRealTimers() + } + + const rendererBuffer = await renderHeadlessBuffer(writes, 80, 8) + const referenceBuffer = await renderHeadlessBuffer( + [hiddenFrame, staleQueryFrame, currentFrame], + 80, + 8 + ) + + expect(writes).not.toContain(staleQueryFrame) + expect(rendererBuffer).toEqual(referenceBuffer) + binding.dispose() + }) + + it('restores hidden Codex output after a suppressed exit revives the same ptyId with a restarted seq counter', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: + | (( + data: string, + meta?: { seq?: number; rawLength?: number; background?: boolean } + ) => void) + | null + } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + + const hiddenFrame = '\r\x1b[2KWorking hidden' + const visibleFrame = '\r\x1b[2KWorking now' + const hiddenSeq = hiddenFrame.length + const visibleSeq = hiddenSeq + visibleFrame.length + // Why 3 extra bytes: the main emulator typically runs ahead of the chunks + // the renderer has received, so the snapshot seq exceeds the channel seq. + const preExitSnapshotSeq = visibleSeq + 3 + getMainBufferSnapshot.mockResolvedValue({ + data: visibleFrame, + cols: 80, + rows: 8, + seq: preExitSnapshotSeq + }) + + const pane = createPane(1) + pane.terminal.cols = 80 + pane.terminal.rows = 8 + const { writes } = captureCallbackTerminalWrites(pane) + const manager = createManager(1) + const deps = createDeps({ + isVisibleRef: { current: false }, + startup: { command: 'codex' }, + consumeSuppressedPtyExit: vi.fn(() => true) + }) + const binding = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.(hiddenFrame, { seq: hiddenSeq, rawLength: hiddenFrame.length }) + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.(visibleFrame, { + seq: visibleSeq, + rawLength: visibleFrame.length + }) + await flushAsyncTicks(20) + + const onPtyExit = createdTransportOptions[0]?.onPtyExit as ((ptyId: string) => void) | undefined + expect(onPtyExit).toBeTypeOf('function') + onPtyExit?.('pty-id') + + // Revived session: same ptyId, main seq counter restarted. The first + // revived chunk lands below the pre-exit snapshot seq but above the last + // channel seq, so only the exit reset can stop it being judged covered. + ;(deps.isVisibleRef as { current: boolean }).current = false + const revivedHiddenFrame = '\r\x1b[2KWorking revived' + capturedDataCallback.current?.(revivedHiddenFrame, { + seq: preExitSnapshotSeq - 1, + rawLength: revivedHiddenFrame.length + }) + + const revivedSnapshotFrame = '\r\x1b[2KREVIVED SNAPSHOT' + getMainBufferSnapshot.mockResolvedValue({ + data: revivedSnapshotFrame, + cols: 80, + rows: 8, + seq: preExitSnapshotSeq - 1 + }) + ;(deps.isVisibleRef as { current: boolean }).current = true + const revivedVisibleFrame = '\r\x1b[2Kafter revive' + capturedDataCallback.current?.(revivedVisibleFrame, { + seq: preExitSnapshotSeq - 1 + revivedVisibleFrame.length, + rawLength: revivedVisibleFrame.length + }) + await flushAsyncTicks(20) + + const written = writes.join('') + expect(written).toContain('REVIVED SNAPSHOT') + expect(written).toContain('after revive') + binding.dispose() + }) + + it('restores hidden Codex output when the pty seq counter restarts without an observed exit', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: + | (( + data: string, + meta?: { seq?: number; rawLength?: number; background?: boolean } + ) => void) + | null + } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + + const pane = createPane(1) + pane.terminal.cols = 80 + pane.terminal.rows = 8 + const { writes } = captureCallbackTerminalWrites(pane) + const manager = createManager(1) + const deps = createDeps({ + isVisibleRef: { current: true }, + startup: { command: 'codex' } + }) + const binding = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + const establishedFrame = '\r\x1b[2KWorking established' + capturedDataCallback.current?.(establishedFrame, { + seq: 50_000, + rawLength: establishedFrame.length + }) + await flushAsyncTicks(6) + + // Main lost the pty silently (no exit reached the renderer) and the seq + // counter restarted: the channel seq regression must invalidate the old + // high-water mark instead of covering the new stream. + ;(deps.isVisibleRef as { current: boolean }).current = false + const revivedHiddenFrame = '\r\x1b[2KWorking revived' + capturedDataCallback.current?.(revivedHiddenFrame, { + seq: revivedHiddenFrame.length, + rawLength: revivedHiddenFrame.length + }) + + const revivedSnapshotFrame = '\r\x1b[2KREVIVED SNAPSHOT2' + getMainBufferSnapshot.mockResolvedValue({ + data: revivedSnapshotFrame, + cols: 80, + rows: 8, + seq: revivedHiddenFrame.length + }) + ;(deps.isVisibleRef as { current: boolean }).current = true + const revivedVisibleFrame = '\r\x1b[2Kafter revive' + capturedDataCallback.current?.(revivedVisibleFrame, { + seq: revivedHiddenFrame.length + revivedVisibleFrame.length, + rawLength: revivedVisibleFrame.length + }) + await flushAsyncTicks(20) + + expect(writes.join('')).toContain('REVIVED SNAPSHOT2') + binding.dispose() + }) + it('writes ordinary hidden output live instead of proactively restoring a snapshot', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 01f0132c3fc..49ce4062a02 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -1035,6 +1035,7 @@ export function connectPanePty( let unregisterDocumentVisibilityRecovery: (() => void) | null = null let cleanupHiddenOutputRestoreDeferredRetry = (): void => {} let cleanupHiddenOutputRestoreForegroundDeadline = (): void => {} + let resetRendererOrderedSeqForPtyExit: (exitedPtyId: string) => void = () => {} let cleanupStartupDraftPasteTimers = (): void => {} let unregisterE2ePtyDataInjection = (): void => {} let startupInjectTimer: ReturnType | null = null @@ -1700,6 +1701,7 @@ export function connectPanePty( if (handledExitPtyId === ptyId) { return } + resetRendererOrderedSeqForPtyExit(ptyId) const currentPaneTransport = deps.paneTransportsRef.current.get(pane.id) if (currentPaneTransport && currentPaneTransport !== transport) { // Why: an old transport can deliver a late exit after this pane has @@ -3646,6 +3648,10 @@ export function connectPanePty( let hiddenSynchronizedOutputMarkerTail = '' let hiddenRewriteChunkEndedWithCarriageReturn = false let hiddenRewriteCsiScanTail = '' + let rendererOrderedPtyId: string | null = null + let rendererOrderedSeq: number | null = null + let rendererChannelSeqPtyId: string | null = null + let rendererChannelSeq: number | null = null function canUseMainBufferSnapshot(ptyId: string | null): ptyId is string { return Boolean(ptyId) && !isRemoteRuntimePtyId(ptyId) @@ -4120,6 +4126,79 @@ export function connectPanePty( return chunk.data.slice(offset) } + function recordRendererOrderedSeq(meta?: Pick): void { + if (typeof meta?.seq !== 'number') { + return + } + const ptyId = transport.getPtyId() + if (!ptyId) { + return + } + if (rendererOrderedPtyId !== ptyId) { + rendererOrderedPtyId = ptyId + rendererOrderedSeq = meta.seq + return + } + rendererOrderedSeq = Math.max(rendererOrderedSeq ?? 0, meta.seq) + } + + resetRendererOrderedSeqForPtyExit = (exitedPtyId: string): void => { + // Why: an exit ends this ptyId's seq domain. A revived session can reuse + // the id with a restarted main-side counter, and a stale high-water mark + // would wrongly cover — and silently drop — every hidden byte it emits. + if (rendererOrderedPtyId === exitedPtyId) { + rendererOrderedPtyId = null + rendererOrderedSeq = null + } + if (rendererChannelSeqPtyId === exitedPtyId) { + rendererChannelSeqPtyId = null + rendererChannelSeq = null + } + } + + function observeRendererOrderedSeqRegression(meta: PtyDataMeta | undefined): void { + if (typeof meta?.seq !== 'number') { + return + } + const ptyId = transport.getPtyId() + if (!ptyId) { + return + } + if (rendererChannelSeqPtyId !== ptyId) { + rendererChannelSeqPtyId = ptyId + rendererChannelSeq = meta.seq + return + } + if (rendererChannelSeq !== null && meta.seq < rendererChannelSeq) { + // Why: pty:data delivery is FIFO per pty, so seq only moves backwards + // when the session was revived without an observed exit and restarted + // its counter. Drop the stale ordered baseline instead of letting it + // cover the new stream's bytes. + if (rendererOrderedPtyId === ptyId) { + rendererOrderedPtyId = null + rendererOrderedSeq = null + } + } + rendererChannelSeq = meta.seq + } + + function getHiddenRendererDataAfterOrderedSeq( + data: string, + meta: PtyDataMeta | undefined + ): string | null { + if ( + rendererOrderedPtyId === null || + rendererOrderedSeq === null || + transport.getPtyId() !== rendererOrderedPtyId + ) { + return data + } + return getChunkDataAfterSnapshot( + { data, seq: meta?.seq, rawLength: meta?.rawLength }, + rendererOrderedSeq + ) + } + function drainPendingLiveChunksAfterSnapshot(snapshotSeq: number | undefined): boolean { if (hiddenOutputRestorePendingOverflow) { hiddenOutputRestorePendingOverflow = false @@ -4143,6 +4222,7 @@ export function connectPanePty( } if (data) { writePtyOutputToXterm(data, true) + recordRendererOrderedSeq(chunk) } } if (hiddenOutputRestorePendingOverflow) { @@ -4404,6 +4484,7 @@ export function connectPanePty( writeReplayData(snapshot.data) writeReplayData(POST_REPLAY_LIVE_SNAPSHOT_RESET) hiddenRendererStateDirty = false + recordRendererOrderedSeq(snapshot) resetHiddenRendererRiskState() recordTerminalOutput(pane.terminal) const currentPtyId = transport.getPtyId() @@ -4632,6 +4713,21 @@ export function connectPanePty( meta, pendingForegroundQuery?.consumedCurrentChars ?? 0 ) + observeRendererOrderedSeqRegression(meta) + const orderedRendererData = foreground + ? rendererData + : getHiddenRendererDataAfterOrderedSeq(rendererData, rendererMeta) + if (orderedRendererData === null) { + // Why: renderer-side filtering cannot map cleaned text back onto raw + // sequence offsets. Rebuild from main instead of risking stale bytes. + markHiddenOutputRestoreNeeded() + schedulePendingStartupCommandDelivery() + return + } + if (!foreground && orderedRendererData.length === 0) { + schedulePendingStartupCommandDelivery() + return + } if (pendingForegroundQuery?.statelessQueryData) { writePtyOutputToXterm(pendingForegroundQuery.statelessQueryData, true, { hiddenStartupRendererQuery: true @@ -4650,11 +4746,11 @@ export function connectPanePty( meta?.background === true && shouldWritePtyOutputForeground(deps.isVisibleRef.current) && pane.terminal.buffer.active.type === 'alternate' && - !containsStatefulRendererQuery(data) + !containsStatefulRendererQuery(orderedRendererData) if (skipBackgroundAlternateScreenFrame) { - skipBackgroundAlternateScreenOutput(data) - } else if (shouldSkipHiddenRendererOutput(foreground, data)) { - skipHiddenRendererOutput(data) + skipBackgroundAlternateScreenOutput(orderedRendererData) + } else if (shouldSkipHiddenRendererOutput(foreground, orderedRendererData)) { + skipHiddenRendererOutput(orderedRendererData) } else if ( (hiddenOutputRestoreNeeded || hiddenOutputRestoreInFlight) && restoreAppliesToCurrentPty @@ -4663,7 +4759,7 @@ export function connectPanePty( if (pendingForegroundQuery?.statefulQueryData) { queueLiveChunkDuringRestore(pendingForegroundQuery.statefulQueryData) } - queueLiveChunkDuringRestore(rendererData, rendererMeta) + queueLiveChunkDuringRestore(orderedRendererData, rendererMeta) requestHiddenOutputRestoreIfNeeded() } else if (hiddenOutputRestoreInFlight) { resetSkippedHiddenRendererRiskState() @@ -4676,7 +4772,10 @@ export function connectPanePty( hiddenStartupRendererQuery: true }) } - writePtyOutputToXterm(rendererData, foreground) + writePtyOutputToXterm(orderedRendererData, foreground) + if (foreground) { + recordRendererOrderedSeq(rendererMeta) + } } schedulePendingStartupCommandDelivery() From 827d2bb6aa6f20e03b28af61641799bdea51fdb5 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 2 Jul 2026 21:27:16 -0700 Subject: [PATCH 06/48] Refine TUI wheel scroll feel (#7179) Co-authored-by: Orca --- .../pane-terminal-mouse-wheel.test.ts | 236 ++++++++++++- .../pane-manager/pane-terminal-mouse-wheel.ts | 162 ++++++--- .../pane-terminal-tui-wheel-reports.ts | 206 ++++++++++++ .../fixtures/visible-tui-scroll-fixture.cjs | 61 ++++ tests/e2e/terminal-tui-wheel-reports.spec.ts | 309 +++++++++++++++++- 5 files changed, 919 insertions(+), 55 deletions(-) create mode 100644 src/renderer/src/lib/pane-manager/pane-terminal-tui-wheel-reports.ts create mode 100644 tests/e2e/fixtures/visible-tui-scroll-fixture.cjs diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.test.ts b/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.test.ts index 0d515ce8ec1..3a15574255f 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.test.ts @@ -2,7 +2,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER, attachTerminalMouseWheelMultiplier, + createTerminalTuiMouseWheelDistanceState, normalizeTerminalTuiMouseWheelMultiplier, + resolveTerminalTuiMouseWheelReportCount, shouldMultiplyTerminalMouseWheel } from './pane-terminal-mouse-wheel' @@ -88,6 +90,177 @@ describe('terminal mouse wheel multiplier', () => { expect(normalizeTerminalTuiMouseWheelMultiplier(20)).toBe(10) }) + it('keeps deliberate TUI wheel ticks precise at the one-report setting', () => { + const state = createTerminalTuiMouseWheelDistanceState() + + const reports = [0, 200, 400, 600].map(() => + resolveTerminalTuiMouseWheelReportCount( + { deltaY: 12, deltaMode: DOM_DELTA_PIXEL, wheelDeltaY: -120 }, + 1, + state, + { cellHeight: 16 } + ) + ) + + expect(reports).toEqual([1, 1, 1, 1]) + }) + + it('scales notched TUI wheel ticks by the configured multiplier', () => { + const state = createTerminalTuiMouseWheelDistanceState() + + const reports = [0, 50, 100, 150].map(() => + resolveTerminalTuiMouseWheelReportCount( + { deltaY: 12, deltaMode: DOM_DELTA_PIXEL, wheelDeltaY: -120 }, + 5, + state, + { cellHeight: 16 } + ) + ) + + expect(reports).toEqual([5, 5, 5, 5]) + }) + + it('keeps paced 1x TUI wheel ticks precise', () => { + const state = createTerminalTuiMouseWheelDistanceState() + + const reports = [0, 80, 160, 240].map((timeStamp) => + resolveTerminalTuiMouseWheelReportCount( + { deltaY: 12, deltaMode: DOM_DELTA_PIXEL, wheelDeltaY: -120, timeStamp }, + 1, + state, + { cellHeight: 16 } + ) + ) + + expect(reports).toEqual([1, 1, 1, 1]) + }) + + it('adds a burst boost for very fast 1x TUI wheel scrolling', () => { + const state = createTerminalTuiMouseWheelDistanceState() + + const reports = [0, 16, 32, 48, 64].map((timeStamp) => + resolveTerminalTuiMouseWheelReportCount( + { deltaY: 12, deltaMode: DOM_DELTA_PIXEL, wheelDeltaY: -120, timeStamp }, + 1, + state, + { cellHeight: 16 } + ) + ) + + expect(reports).toEqual([1, 1, 3, 3, 4]) + }) + + it('uses a hotter compressed wheel distance curve for larger TUI wheel movements', () => { + const state = createTerminalTuiMouseWheelDistanceState() + + const reports = [ + resolveTerminalTuiMouseWheelReportCount( + { deltaY: 16, deltaMode: DOM_DELTA_PIXEL, wheelDeltaY: -120 }, + 1, + state, + { cellHeight: 16 } + ), + resolveTerminalTuiMouseWheelReportCount( + { deltaY: 16 * 12, deltaMode: DOM_DELTA_PIXEL, wheelDeltaY: -120 * 12 }, + 1, + state, + { cellHeight: 16 } + ) + ] + + expect(reports).toEqual([1, 6]) + }) + + it('caps a single accelerated TUI wheel event before it becomes a huge jump', () => { + const state = createTerminalTuiMouseWheelDistanceState() + + const reports = resolveTerminalTuiMouseWheelReportCount( + { deltaY: 16 * 200, deltaMode: DOM_DELTA_PIXEL, wheelDeltaY: -120 * 200 }, + 1, + state, + { cellHeight: 16 } + ) + + expect(reports).toBe(6) + }) + + it('lets aggressive repeated TUI wheel events exceed the single-event cap', () => { + const state = createTerminalTuiMouseWheelDistanceState() + + const reports = [0, 16, 32, 48, 64].map((timeStamp) => + resolveTerminalTuiMouseWheelReportCount( + { + deltaY: 16 * 200, + deltaMode: DOM_DELTA_PIXEL, + timeStamp, + wheelDeltaY: -120 * 200 + }, + 1, + state, + { cellHeight: 16 } + ) + ) + + expect(reports).toEqual([6, 6, 8, 8, 9]) + }) + + it('does not carry burst boost into a decaying momentum tail', () => { + const state = createTerminalTuiMouseWheelDistanceState() + + const reports = [ + [200, 0], + [200, 16], + [200, 32], + [80, 48], + [20, 64], + [5, 80] + ].map(([rows, timeStamp]) => + resolveTerminalTuiMouseWheelReportCount( + { + deltaY: 16 * rows, + deltaMode: DOM_DELTA_PIXEL, + timeStamp, + wheelDeltaY: -120 * rows + }, + 1, + state, + { cellHeight: 16 } + ) + ) + + expect(reports).toEqual([6, 6, 8, 6, 6, 4]) + }) + + it('retains fractional trackpad distance until it reaches a full row', () => { + const state = createTerminalTuiMouseWheelDistanceState() + + const reports = [4, 4, 4, 4].map((deltaY) => + resolveTerminalTuiMouseWheelReportCount({ deltaY, deltaMode: DOM_DELTA_PIXEL }, 1, state, { + cellHeight: 16 + }) + ) + + expect(reports).toEqual([0, 0, 0, 1]) + }) + + it('resets pending fractional distance when the user changes direction', () => { + const state = createTerminalTuiMouseWheelDistanceState() + + const reports = [ + resolveTerminalTuiMouseWheelReportCount({ deltaY: 4, deltaMode: DOM_DELTA_PIXEL }, 1, state, { + cellHeight: 16 + }), + resolveTerminalTuiMouseWheelReportCount( + { deltaY: -12, deltaMode: DOM_DELTA_PIXEL }, + 1, + state, + { cellHeight: 16 } + ) + ] + + expect(reports).toEqual([0, 0]) + }) + it('multiplies discrete wheel events when mouse reporting is active', () => { expect(shouldMultiplyTerminalMouseWheel(wheelEvent(), terminalElement())).toBe(true) }) @@ -159,7 +332,8 @@ describe('terminal mouse wheel multiplier', () => { attachCustomWheelEventHandler: (handler) => { handlers.push(handler) }, - element: target + element: target, + rows: 24 }, { getTuiMouseWheelMultiplier: () => 1 } ) @@ -183,4 +357,64 @@ describe('terminal mouse wheel multiplier', () => { expect(dispatched.map((entry) => entry.deltaY)).toEqual([1]) expect(shouldMultiplyTerminalMouseWheel(dispatched[0]!, target)).toBe(false) }) + + it('drains resolved TUI wheel reports without a frame-rate cap', async () => { + vi.stubGlobal('WheelEvent', TestWheelEvent) + const handlers: ((event: WheelEvent) => boolean)[] = [] + const target = Object.assign(new EventTarget(), { + classList: { + contains: (className: string) => className === 'enable-mouse-events' + } + }) as unknown as EventTarget & HTMLElement + const dispatched: WheelEvent[] = [] + target.addEventListener('wheel', (event) => dispatched.push(event as WheelEvent)) + attachTerminalMouseWheelMultiplier( + { + attachCustomWheelEventHandler: (handler) => { + handlers.push(handler) + }, + element: target, + rows: 24 + }, + { + getTuiMouseWheelMultiplier: () => 3 + } + ) + const firstEvent = new TestWheelEvent('wheel', { + bubbles: true, + cancelable: true, + deltaMode: DOM_DELTA_PIXEL, + deltaY: 12 + }) as WheelEvent + Object.defineProperty(firstEvent, 'wheelDeltaY', { + configurable: true, + value: -120 + }) + Object.defineProperty(firstEvent, 'timeStamp', { + configurable: true, + value: 0 + }) + const secondEvent = new TestWheelEvent('wheel', { + bubbles: true, + cancelable: true, + deltaMode: DOM_DELTA_PIXEL, + deltaY: 12 + }) as WheelEvent + Object.defineProperty(secondEvent, 'wheelDeltaY', { + configurable: true, + value: -120 + }) + Object.defineProperty(secondEvent, 'timeStamp', { + configurable: true, + value: 50 + }) + + expect(handlers[0]?.(firstEvent)).toBe(false) + await Promise.resolve() + expect(dispatched).toHaveLength(3) + + expect(handlers[0]?.(secondEvent)).toBe(false) + await Promise.resolve() + expect(dispatched).toHaveLength(6) + }) }) diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.ts b/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.ts index b8654f7c3ce..ab192e393f7 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.ts @@ -1,17 +1,28 @@ import type { Terminal } from '@xterm/xterm' +import { + createTerminalTuiMouseWheelDistanceState, + isDiscreteTerminalTuiWheelEvent, + normalizeTerminalTuiMouseWheelMultiplier, + resolveTerminalTuiMouseWheelReportCount, + resolveTerminalWheelDirection +} from './pane-terminal-tui-wheel-reports' +import type { TerminalTuiMouseWheelDistanceState } from './pane-terminal-tui-wheel-reports' + +export { + TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER, + TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER_MAX, + TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER_MIN, + createTerminalTuiMouseWheelDistanceState, + normalizeTerminalTuiMouseWheelMultiplier, + resolveTerminalTuiMouseWheelReportCount +} from './pane-terminal-tui-wheel-reports' +export type { TerminalTuiMouseWheelDistanceState } from './pane-terminal-tui-wheel-reports' const XTERM_MOUSE_REPORTING_CLASS = 'enable-mouse-events' const REPLAYED_WHEEL_EVENT_PROPERTY = '__orcaReplayedTerminalWheelEvent' -const DOM_DELTA_PIXEL = 0 const DOM_DELTA_LINE = 1 -const DISCRETE_PIXEL_WHEEL_DELTA_MIN = 50 -const LEGACY_MOUSE_WHEEL_DELTA_MIN = 100 -export const TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER = 1 -export const TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER_MIN = 1 -export const TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER_MAX = 10 - -type TerminalWheelTarget = Pick +type TerminalWheelTarget = Pick type TerminalMouseWheelMultiplierOptions = { getTuiMouseWheelMultiplier?: () => number | undefined @@ -21,9 +32,24 @@ type ReplayedWheelEvent = WheelEvent & { [REPLAYED_WHEEL_EVENT_PROPERTY]?: boolean } -type WheelEventWithLegacyDelta = WheelEvent & { - wheelDelta?: number - wheelDeltaY?: number +type TerminalTuiMouseWheelReplayState = { + distance: TerminalTuiMouseWheelDistanceState + drainScheduled: boolean + pendingDirection: -1 | 0 | 1 + pendingEvent: WheelEvent | null + pendingReports: number + pendingTarget: EventTarget | null +} + +function createTerminalTuiMouseWheelReplayState(): TerminalTuiMouseWheelReplayState { + return { + distance: createTerminalTuiMouseWheelDistanceState(), + drainScheduled: false, + pendingDirection: 0, + pendingEvent: null, + pendingReports: 0, + pendingTarget: null + } } function isReplayedWheelEvent(event: WheelEvent): boolean { @@ -37,30 +63,6 @@ function markReplayedWheelEvent(event: WheelEvent): void { }) } -function legacyVerticalWheelDelta(event: WheelEvent): number | null { - const wheelEvent = event as WheelEventWithLegacyDelta - if (typeof wheelEvent.wheelDeltaY === 'number' && Number.isFinite(wheelEvent.wheelDeltaY)) { - return wheelEvent.wheelDeltaY - } - if (typeof wheelEvent.wheelDelta === 'number' && Number.isFinite(wheelEvent.wheelDelta)) { - return wheelEvent.wheelDelta - } - return null -} - -function isDiscreteWheelEvent(event: WheelEvent): boolean { - if (event.deltaMode !== DOM_DELTA_PIXEL) { - return true - } - - if (Math.abs(event.deltaY) >= DISCRETE_PIXEL_WHEEL_DELTA_MIN) { - return true - } - - const legacyDelta = legacyVerticalWheelDelta(event) - return legacyDelta !== null && Math.abs(legacyDelta) >= LEGACY_MOUSE_WHEEL_DELTA_MIN -} - function cloneWheelReportEvent(event: WheelEvent): WheelEvent { const clone = new WheelEvent(event.type, { bubbles: event.bubbles, @@ -88,6 +90,18 @@ function cloneWheelReportEvent(event: WheelEvent): WheelEvent { return clone } +function resolveTerminalWheelCellHeight(terminal: TerminalWheelTarget): number | undefined { + if (typeof terminal.element?.querySelector !== 'function') { + return undefined + } + const screen = terminal.element?.querySelector('.xterm-screen') + const rect = screen?.getBoundingClientRect() + if (!rect || rect.height <= 0 || terminal.rows <= 0) { + return undefined + } + return rect.height / terminal.rows +} + export function shouldMultiplyTerminalMouseWheel( event: WheelEvent, terminalElement: HTMLElement | null | undefined @@ -97,7 +111,7 @@ export function shouldMultiplyTerminalMouseWheel( !terminalElement?.classList.contains(XTERM_MOUSE_REPORTING_CLASS) || event.deltaY === 0 || event.shiftKey || - !isDiscreteWheelEvent(event) + !isDiscreteTerminalTuiWheelEvent(event) ) { return false } @@ -105,22 +119,62 @@ export function shouldMultiplyTerminalMouseWheel( return true } -export function normalizeTerminalTuiMouseWheelMultiplier(value: number | undefined): number { - if (typeof value !== 'number' || !Number.isFinite(value)) { - return TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER +function drainTerminalTuiWheelReports(state: TerminalTuiMouseWheelReplayState): void { + const target = state.pendingTarget + const event = state.pendingEvent + if (!target || !event || state.pendingReports <= 0) { + state.drainScheduled = false + return } - return Math.round( - Math.min( - TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER_MAX, - Math.max(TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER_MIN, value) - ) - ) + + const reportsToDispatch = state.pendingReports + for (let i = 0; i < reportsToDispatch; i += 1) { + target.dispatchEvent(cloneWheelReportEvent(event)) + } + state.pendingReports = 0 + state.drainScheduled = false + state.pendingDirection = 0 + state.pendingEvent = null + state.pendingTarget = null +} + +function queueTerminalTuiWheelReports( + state: TerminalTuiMouseWheelReplayState, + target: EventTarget, + event: WheelEvent, + reportCount: number +): void { + if (reportCount <= 0) { + return + } + + const direction = resolveTerminalWheelDirection(event) + if (state.pendingDirection !== 0 && state.pendingDirection !== direction) { + state.pendingReports = 0 + } + + state.pendingDirection = direction + state.pendingEvent = event + state.pendingTarget = target + state.pendingReports += reportCount + + if (state.drainScheduled) { + return + } + + state.drainScheduled = true + // Why: dispatch after xterm returns from the original wheel handler, but do + // not frame-cap reports; fullscreen TUIs need the full wheel distance. + queueMicrotask(() => { + drainTerminalTuiWheelReports(state) + }) } export function attachTerminalMouseWheelMultiplier( terminal: TerminalWheelTarget, options: TerminalMouseWheelMultiplierOptions = {} ): void { + const replayState = createTerminalTuiMouseWheelReplayState() terminal.attachCustomWheelEventHandler((event) => { if (!shouldMultiplyTerminalMouseWheel(event, terminal.element)) { return true @@ -133,15 +187,17 @@ export function attachTerminalMouseWheelMultiplier( } // Why: xterm dampens small pixel deltas before emitting mouse reports; - // line-mode replays make each notched wheel tick produce immediate reports. - queueMicrotask(() => { - const multiplier = normalizeTerminalTuiMouseWheelMultiplier( - options.getTuiMouseWheelMultiplier?.() - ) - for (let i = 0; i < multiplier; i++) { - target.dispatchEvent(cloneWheelReportEvent(event)) + // line-mode replays let fullscreen TUIs receive one report per resolved row. + const reportCount = resolveTerminalTuiMouseWheelReportCount( + event, + normalizeTerminalTuiMouseWheelMultiplier(options.getTuiMouseWheelMultiplier?.()), + replayState.distance, + { + cellHeight: resolveTerminalWheelCellHeight(terminal), + rows: terminal.rows } - }) + ) + queueTerminalTuiWheelReports(replayState, target, event, reportCount) return false }) diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-tui-wheel-reports.ts b/src/renderer/src/lib/pane-manager/pane-terminal-tui-wheel-reports.ts new file mode 100644 index 00000000000..1b92edb390f --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-terminal-tui-wheel-reports.ts @@ -0,0 +1,206 @@ +const DOM_DELTA_PIXEL = 0 +const DOM_DELTA_LINE = 1 +const DOM_DELTA_PAGE = 2 +const DISCRETE_PIXEL_WHEEL_DELTA_MIN = 50 +const LEGACY_MOUSE_WHEEL_DELTA_MIN = 100 +const LEGACY_MOUSE_WHEEL_DELTA_UNIT = 120 +const DEFAULT_TERMINAL_CELL_HEIGHT = 16 +const TUI_WHEEL_ACCELERATED_DISTANCE_GAIN = 1.6 +const TUI_WHEEL_BURST_FULL_INTERVAL_MS = 16 +const TUI_WHEEL_BURST_MAX_INTERVAL_MS = 45 +const TUI_WHEEL_BURST_MAX_BONUS_ROWS = 3 +const TUI_WHEEL_BURST_RAMP_EVENTS = 4 +const TUI_WHEEL_MOMENTUM_TAIL_DECAY_RATIO = 0.85 +const TUI_WHEEL_COMPRESSED_MAX_DISTANCE_ROWS_PER_EVENT = 6 +const TUI_WHEEL_BURST_MAX_DISTANCE_ROWS_PER_EVENT = 9 + +export const TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER = 1 +export const TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER_MIN = 1 +export const TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER_MAX = 10 + +type WheelEventWithLegacyDelta = WheelEvent & { + wheelDelta?: number + wheelDeltaY?: number +} + +type TerminalTuiWheelEventInput = Pick & + Partial> & { + wheelDelta?: number + wheelDeltaY?: number + } + +type TerminalTuiMouseWheelMetrics = { + cellHeight?: number + rows?: number +} + +export type TerminalTuiMouseWheelDistanceState = { + fastStreak: number + lastDistanceRows: number | null + lastInputAt: number | null + pendingDirection: -1 | 0 | 1 + pendingRows: number +} + +export function createTerminalTuiMouseWheelDistanceState(): TerminalTuiMouseWheelDistanceState { + return { + fastStreak: 0, + lastDistanceRows: null, + lastInputAt: null, + pendingDirection: 0, + pendingRows: 0 + } +} + +export function resolveTerminalWheelDirection(event: Pick): -1 | 1 { + return event.deltaY < 0 ? -1 : 1 +} + +function legacyVerticalWheelDelta(event: TerminalTuiWheelEventInput): number | null { + const wheelEvent = event as WheelEventWithLegacyDelta + if (typeof wheelEvent.wheelDeltaY === 'number' && Number.isFinite(wheelEvent.wheelDeltaY)) { + return wheelEvent.wheelDeltaY + } + if (typeof wheelEvent.wheelDelta === 'number' && Number.isFinite(wheelEvent.wheelDelta)) { + return wheelEvent.wheelDelta + } + return null +} + +export function isDiscreteTerminalTuiWheelEvent(event: TerminalTuiWheelEventInput): boolean { + if ((event.deltaMode ?? DOM_DELTA_PIXEL) !== DOM_DELTA_PIXEL) { + return true + } + + if (Math.abs(event.deltaY) >= DISCRETE_PIXEL_WHEEL_DELTA_MIN) { + return true + } + + const legacyDelta = legacyVerticalWheelDelta(event) + return legacyDelta !== null && Math.abs(legacyDelta) >= LEGACY_MOUSE_WHEEL_DELTA_MIN +} + +function wheelInputTime(event: TerminalTuiWheelEventInput): number | null { + if (typeof event.timeStamp === 'number' && Number.isFinite(event.timeStamp)) { + return event.timeStamp + } + return null +} + +function normalizeCellHeight(cellHeight: number | undefined): number { + if (typeof cellHeight === 'number' && Number.isFinite(cellHeight) && cellHeight > 0) { + return cellHeight + } + return DEFAULT_TERMINAL_CELL_HEIGHT +} + +function resolveWheelDistanceRows( + event: TerminalTuiWheelEventInput, + metrics: TerminalTuiMouseWheelMetrics +): number { + const deltaMode = event.deltaMode ?? DOM_DELTA_PIXEL + const deltaY = Math.abs(event.deltaY) + const rowsFromDelta = + deltaMode === DOM_DELTA_LINE + ? deltaY + : deltaMode === DOM_DELTA_PAGE + ? deltaY * Math.max(1, metrics.rows ?? 1) + : deltaY / normalizeCellHeight(metrics.cellHeight) + const legacyDelta = legacyVerticalWheelDelta(event) + const rowsFromLegacy = + legacyDelta === null ? 0 : Math.abs(legacyDelta) / LEGACY_MOUSE_WHEEL_DELTA_UNIT + const rows = Math.max(rowsFromDelta, rowsFromLegacy) + + return isDiscreteTerminalTuiWheelEvent(event) ? Math.max(1, rows) : rows +} + +function compressWheelDistanceRows(rows: number): number { + if (rows <= 1) { + return rows + } + + return Math.min( + TUI_WHEEL_COMPRESSED_MAX_DISTANCE_ROWS_PER_EVENT, + 1 + Math.log2(rows) * TUI_WHEEL_ACCELERATED_DISTANCE_GAIN + ) +} + +function resolveBurstWheelDistanceRows( + event: TerminalTuiWheelEventInput, + state: TerminalTuiMouseWheelDistanceState, + distanceRows: number +): number { + const currentInputAt = wheelInputTime(event) + if (currentInputAt === null) { + state.fastStreak = 0 + state.lastDistanceRows = null + state.lastInputAt = null + return 0 + } + + const elapsedMs = state.lastInputAt === null ? null : currentInputAt - state.lastInputAt + const isMomentumTail = + state.lastDistanceRows !== null && + distanceRows < state.lastDistanceRows * TUI_WHEEL_MOMENTUM_TAIL_DECAY_RATIO + state.lastDistanceRows = distanceRows + state.lastInputAt = currentInputAt + + if ( + isMomentumTail || + elapsedMs === null || + elapsedMs < 0 || + elapsedMs > TUI_WHEEL_BURST_MAX_INTERVAL_MS + ) { + state.fastStreak = 0 + return 0 + } + + const cadence = + elapsedMs <= TUI_WHEEL_BURST_FULL_INTERVAL_MS + ? 1 + : (TUI_WHEEL_BURST_MAX_INTERVAL_MS - elapsedMs) / + (TUI_WHEEL_BURST_MAX_INTERVAL_MS - TUI_WHEEL_BURST_FULL_INTERVAL_MS) + state.fastStreak = Math.min(TUI_WHEEL_BURST_RAMP_EVENTS, state.fastStreak + 1) + + return TUI_WHEEL_BURST_MAX_BONUS_ROWS * cadence * (state.fastStreak / TUI_WHEEL_BURST_RAMP_EVENTS) +} + +export function normalizeTerminalTuiMouseWheelMultiplier(value: number | undefined): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER + } + return Math.round( + Math.min( + TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER_MAX, + Math.max(TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER_MIN, value) + ) + ) +} + +export function resolveTerminalTuiMouseWheelReportCount( + event: TerminalTuiWheelEventInput, + multiplier: number, + state: TerminalTuiMouseWheelDistanceState, + metrics: TerminalTuiMouseWheelMetrics = {} +): number { + const direction = resolveTerminalWheelDirection(event) + if (state.pendingDirection !== 0 && state.pendingDirection !== direction) { + state.fastStreak = 0 + state.lastDistanceRows = null + state.lastInputAt = null + state.pendingRows = 0 + } + state.pendingDirection = direction + + const distanceRows = resolveWheelDistanceRows(event, metrics) + const rows = + Math.min( + TUI_WHEEL_BURST_MAX_DISTANCE_ROWS_PER_EVENT, + compressWheelDistanceRows(distanceRows) + + resolveBurstWheelDistanceRows(event, state, distanceRows) + ) * normalizeTerminalTuiMouseWheelMultiplier(multiplier) + const totalRows = state.pendingRows + rows + const reports = Math.trunc(totalRows) + state.pendingRows = totalRows - reports + return reports +} diff --git a/tests/e2e/fixtures/visible-tui-scroll-fixture.cjs b/tests/e2e/fixtures/visible-tui-scroll-fixture.cjs new file mode 100644 index 00000000000..207aac49cfc --- /dev/null +++ b/tests/e2e/fixtures/visible-tui-scroll-fixture.cjs @@ -0,0 +1,61 @@ +const ESC = '\x1b' +const MOUSE_REPORT_PATTERN = new RegExp(`${ESC}\\[<(64|65);\\d+;\\d+M`, 'g') + +let offset = 0 +let pending = '' + +function write(data) { + process.stdout.write(data) +} + +function visibleRows() { + return Math.max(3, process.stdout.rows || 24) +} + +function render() { + write(`${ESC}[H`) + write(`TUI_SCROLL_READY offset=${offset}${ESC}[K`) + for (let row = 1; row < visibleRows(); row += 1) { + write(`\r\nTUI_SCROLL_ROW_${String(offset + row - 1).padStart(4, '0')}${ESC}[K`) + } +} + +function cleanup() { + write(`${ESC}[?1003l${ESC}[?1006l${ESC}[?25h${ESC}[?1049l`) + process.exit(0) +} + +process.stdin.setEncoding('utf8') +if (process.stdin.isTTY) { + process.stdin.setRawMode(true) +} +process.stdin.resume() + +write(`${ESC}[?1049h${ESC}[?1003h${ESC}[?1006h${ESC}[?25l${ESC}[2J`) +render() + +process.stdin.on('data', (chunk) => { + if (chunk.includes('\x03') || chunk.includes('q')) { + cleanup() + } + + pending += chunk + let match + let lastIndex = 0 + + MOUSE_REPORT_PATTERN.lastIndex = 0 + while ((match = MOUSE_REPORT_PATTERN.exec(pending)) !== null) { + offset = Math.max(0, offset + (match[1] === '65' ? 1 : -1)) + lastIndex = MOUSE_REPORT_PATTERN.lastIndex + } + + if (lastIndex > 0) { + pending = pending.slice(lastIndex) + render() + return + } + + pending = pending.slice(-32) +}) + +process.on('SIGINT', cleanup) diff --git a/tests/e2e/terminal-tui-wheel-reports.spec.ts b/tests/e2e/terminal-tui-wheel-reports.spec.ts index 0e7ae648422..a2718e2d0ab 100644 --- a/tests/e2e/terminal-tui-wheel-reports.spec.ts +++ b/tests/e2e/terminal-tui-wheel-reports.spec.ts @@ -1,7 +1,12 @@ import type { Page } from '@stablyai/playwright-test' +import path from 'node:path' import { test, expect } from './helpers/orca-app' import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' -import { waitForActiveTerminalManager } from './helpers/terminal' +import { + execInTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager +} from './helpers/terminal' type WheelReportSample = { reportCount: number @@ -9,7 +14,15 @@ type WheelReportSample = { reports: string[] } +type TimedWheelReportSample = WheelReportSample & { + elapsedMs: number +} + const PHYSICAL_MOUSE_WHEEL_DELTA = -120 +const VISIBLE_TUI_FIXTURE_PATH = path.join( + process.cwd(), + 'tests/e2e/fixtures/visible-tui-scroll-fixture.cjs' +) async function probeSmallMouseWheelReports( page: Page, @@ -91,6 +104,181 @@ async function probeSmallMouseWheelReports( ) } +async function probeTimedSmallMouseWheelReports( + page: Page, + options: { + drainWaitMs: number + intervalMs: number + ticks: number + } +): Promise<{ + reports: string[] + samples: TimedWheelReportSample[] +}> { + return page.evaluate( + async ({ drainWaitMs, intervalMs, physicalMouseWheelDelta, tickCount }) => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!pane?.terminal.element) { + throw new Error('Active terminal pane unavailable') + } + + const reports: string[] = [] + const disposable = pane.terminal.onData((data) => reports.push(data)) + try { + await new Promise((resolve) => pane.terminal.write('\x1b[?1003h\x1b[?1006h', resolve)) + await new Promise((resolve) => requestAnimationFrame(() => resolve(undefined))) + if (!pane.terminal.element.classList.contains('enable-mouse-events')) { + throw new Error('Mouse reporting mode did not activate') + } + + const screen = pane.terminal.element.querySelector('.xterm-screen') + if (!screen) { + throw new Error('Active terminal screen unavailable') + } + + const rect = screen.getBoundingClientRect() + const cellHeight = + pane.terminal._core?._renderService?.dimensions?.css?.cell?.height ?? + rect.height / pane.terminal.rows + const scrollSensitivity = Number(pane.terminal.options.scrollSensitivity ?? 1) + // Why: this is a notched mouse wheel event that Chromium can surface as a + // small pixel delta; xterm's <50px damping accumulates it for four ticks. + const deltaY = (cellHeight * 0.28) / (scrollSensitivity * 0.3) + const samples: TimedWheelReportSample[] = [] + const startedAt = performance.now() + + for (let i = 0; i < tickCount; i += 1) { + const before = reports.length + const event = new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + clientX: rect.left + rect.width / 2, + clientY: rect.top + Math.min(rect.height - 1, cellHeight * 4), + deltaMode: WheelEvent.DOM_DELTA_PIXEL, + deltaY + }) + Object.defineProperty(event, 'wheelDeltaY', { + configurable: true, + value: physicalMouseWheelDelta + }) + Object.defineProperty(event, 'wheelDelta', { + configurable: true, + value: physicalMouseWheelDelta + }) + pane.terminal.element.dispatchEvent(event) + await new Promise((resolve) => setTimeout(resolve, intervalMs)) + samples.push({ + elapsedMs: performance.now() - startedAt, + reportCount: reports.length, + reportDelta: reports.length - before, + reports: [...reports] + }) + } + + await new Promise((resolve) => setTimeout(resolve, drainWaitMs)) + + return { + reports: [...reports], + samples + } + } finally { + disposable.dispose() + } + }, + { + drainWaitMs: options.drainWaitMs, + intervalMs: options.intervalMs, + physicalMouseWheelDelta: PHYSICAL_MOUSE_WHEEL_DELTA, + tickCount: options.ticks + } + ) +} + +async function readVisibleTuiOffset(page: Page): Promise { + return page.evaluate(() => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!pane?.terminal) { + return null + } + + for (let row = 0; row < pane.terminal.rows; row += 1) { + const text = pane.terminal.buffer.active.getLine(row)?.translateToString(true) ?? '' + const match = /TUI_SCROLL_ROW_(\d+)/.exec(text) + if (match) { + return Number(match[1]) + } + } + return null + }) +} + +async function dispatchTuiWheel( + page: Page, + options: { + deltaY: number + wheelDeltaY?: number + } +): Promise { + await page.evaluate(({ deltaY, wheelDeltaY }) => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!pane?.terminal.element) { + throw new Error('Active terminal pane unavailable') + } + + const screen = pane.terminal.element.querySelector('.xterm-screen') + if (!screen) { + throw new Error('Active terminal screen unavailable') + } + const rect = screen.getBoundingClientRect() + const event = new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + clientX: rect.left + rect.width / 2, + clientY: rect.top + Math.min(rect.height - 1, 40), + deltaMode: WheelEvent.DOM_DELTA_PIXEL, + deltaY + }) + if (wheelDeltaY !== undefined) { + Object.defineProperty(event, 'wheelDeltaY', { + configurable: true, + value: wheelDeltaY + }) + Object.defineProperty(event, 'wheelDelta', { + configurable: true, + value: wheelDeltaY + }) + } + pane.terminal.element.dispatchEvent(event) + }, options) +} + test.describe('terminal TUI wheel reports', () => { test('notched mouse wheel ticks produce immediate mouse-reporting TUI scroll reports', async ({ orcaPage @@ -111,4 +299,123 @@ test.describe('terminal TUI wheel reports', () => { ).toEqual([1, 1, 1, 1]) expect(samples.at(-1)?.reports.join('')).toContain('\x1b[<65;') }) + + test('fullscreen mouse-reporting TUI scroll distance follows wheel magnitude @headful', async ({ + electronApp, + orcaPage + }) => { + await electronApp.evaluate(({ BrowserWindow }) => { + const win = BrowserWindow.getAllWindows()[0] + if (!win) { + throw new Error('No BrowserWindow available') + } + if (win.isMinimized()) { + win.restore() + } + win.show() + win.focus() + win.setFullScreen(true) + }) + await expect + .poll(() => + electronApp.evaluate( + ({ BrowserWindow }) => BrowserWindow.getAllWindows()[0]?.isFullScreen() ?? false + ) + ) + .toBe(true) + await orcaPage.waitForTimeout(1200) + + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + await orcaPage.evaluate(() => + window.__store?.getState().updateSettings({ terminalTuiScrollSensitivity: 1 }) + ) + + const ptyId = await waitForActivePanePtyId(orcaPage) + await execInTerminal(orcaPage, ptyId, `node ${JSON.stringify(VISIBLE_TUI_FIXTURE_PATH)}`) + + await expect + .poll(() => readVisibleTuiOffset(orcaPage), { + timeout: 10_000, + message: 'visible fullscreen TUI did not render numbered rows' + }) + .toBe(0) + + await dispatchTuiWheel(orcaPage, { + deltaY: 10, + wheelDeltaY: PHYSICAL_MOUSE_WHEEL_DELTA + }) + await expect + .poll(() => readVisibleTuiOffset(orcaPage), { + timeout: 5_000, + message: 'single notched wheel tick did not visibly scroll the TUI' + }) + .toBe(1) + + const cellHeight = await orcaPage.evaluate(() => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + const screen = pane?.terminal.element?.querySelector('.xterm-screen') + if (!pane?.terminal || !screen) { + throw new Error('Active terminal screen unavailable') + } + return screen.getBoundingClientRect().height / pane.terminal.rows + }) + + await dispatchTuiWheel(orcaPage, { + deltaY: cellHeight * 12, + wheelDeltaY: PHYSICAL_MOUSE_WHEEL_DELTA * 12 + }) + + await expect + .poll(() => readVisibleTuiOffset(orcaPage), { + timeout: 5_000, + message: 'larger wheel movement did not visibly move the TUI farther' + }) + .toBe(7) + }) + + test('TUI scroll setting scales notched mouse wheel reports', async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + await orcaPage.evaluate(() => + window.__store?.getState().updateSettings({ terminalTuiScrollSensitivity: 5 }) + ) + + const slow = await probeTimedSmallMouseWheelReports(orcaPage, { + drainWaitMs: 120, + intervalMs: 220, + ticks: 5 + }) + await orcaPage.waitForTimeout(220) + const paced = await probeTimedSmallMouseWheelReports(orcaPage, { + drainWaitMs: 220, + intervalMs: 80, + ticks: 5 + }) + + expect( + slow.samples.map((sample) => sample.reportDelta), + `slow per-tick SGR mouse reports: ${JSON.stringify(slow.samples)}` + ).toEqual([5, 5, 5, 5, 5]) + expect( + paced.samples.map((sample) => sample.reportDelta), + `paced per-tick SGR mouse reports: ${JSON.stringify(paced.samples)}` + ).toEqual([5, 5, 5, 5, 5]) + expect(paced.reports.length, `paced SGR mouse reports: ${JSON.stringify(paced.samples)}`).toBe( + 25 + ) + }) }) From f7ec1c727ebbe3dc52a29eaf173fa2dc27a57648 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 04:28:04 +0000 Subject: [PATCH 07/48] release: v1.4.120-rc.4 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 52db1a3a848..41ec21ecb50 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.120-rc.3", + "version": "1.4.120-rc.4", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 4c4cbf2d763de62209202bc2abd193e1bb4a5d5e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 2 Jul 2026 21:59:17 -0700 Subject: [PATCH 08/48] Fix Vim alternate-screen terminal redraw recovery (#7142) * Fix Vim alternate-screen terminal redraw recovery * Decide alternate-screen atlas recovery from parsed buffer state Replace the raw-chunk scan for alternate-screen enter sequences with the xterm parser's own verdict: in-place rewrite chunks check buffer.active.type (plus a parse-time buffer-switch count) inside the write callback, where the buffer is authoritative. Covers enter/exit sequences split across PTY chunk boundaries and full enter-exit cycles coalesced into one write, and deletes the hand-rolled CSI parser. Adds regression tests from a real captured vim session (including a CSI sequence split at a 1024-byte PTY read boundary) and a contract pin against the real @xterm/headless parser. Co-authored-by: Orca --------- Co-authored-by: Orca --- .../terminal-pane/pty-connection.test.ts | 302 ++++++++++++++++++ .../terminal-pane/pty-connection.ts | 39 ++- .../terminal-alternate-screen-parse.test.ts | 58 ++++ 3 files changed, 396 insertions(+), 3 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/terminal-alternate-screen-parse.test.ts 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 873d4f793ce..53da6899409 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -9192,6 +9192,308 @@ describe('connectPanePty', () => { expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1) }) + it('schedules WebGL atlas recovery for Vim-style foreground alternate-screen redraws', async () => { + const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + ;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'alternate' + const refresh = vi.fn() + let parseCallback: (() => void) | undefined + const terminal = pane.terminal as typeof pane.terminal & { + _core?: { refresh: typeof refresh } + } + terminal._core = { refresh } + terminal.write = vi.fn((_data: string, callback?: () => void) => { + parseCallback = callback + }) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.( + '\x1b[2J\x1b[H{"name":"eepo"}\r\n\x1b[2;1H{"name":"expo"}\x1b[K' + ) + + expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() + parseCallback?.() + expect(refresh).toHaveBeenCalledWith(0, 39, true) + expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1) + } finally { + restoreNavigator() + } + }) + + it('schedules WebGL atlas recovery when a foreground rewrite enters alternate screen', async () => { + const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const refresh = vi.fn() + let parseCallback: (() => void) | undefined + const terminal = pane.terminal as typeof pane.terminal & { + _core?: { refresh: typeof refresh } + } + terminal._core = { refresh } + terminal.write = vi.fn((_data: string, callback?: () => void) => { + parseCallback = callback + }) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\x1b[?1049h\x1b[2J\x1b[HVim package.json') + + expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() + // Why: xterm switches to the alternate buffer while parsing the chunk; + // the write callback observes the post-parse buffer state. + ;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'alternate' + parseCallback?.() + expect(refresh).toHaveBeenCalledWith(0, 39, true) + expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1) + } finally { + restoreNavigator() + } + }) + + it('schedules WebGL atlas recovery when the alternate-screen enter sequence splits across chunks', async () => { + const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const refresh = vi.fn() + let parseCallback: (() => void) | undefined + const terminal = pane.terminal as typeof pane.terminal & { + _core?: { refresh: typeof refresh } + } + terminal._core = { refresh } + terminal.write = vi.fn((_data: string, callback?: () => void) => { + parseCallback = callback + }) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(6) + + // Why: PTY reads split CSI sequences at arbitrary byte boundaries (real + // vim sessions split cursor moves at 1024-byte chunk edges), so the + // enter sequence itself can straddle two onData chunks. + capturedDataCallback.current?.('\x1b[?104') + parseCallback?.() + expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() + + capturedDataCallback.current?.('9h\x1b[2J\x1b[H~\x1b[K') + ;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'alternate' + parseCallback?.() + expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1) + } finally { + restoreNavigator() + } + }) + + it('schedules WebGL atlas recovery when a foreground rewrite leaves alternate screen', async () => { + const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + ;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'alternate' + const refresh = vi.fn() + let parseCallback: (() => void) | undefined + const terminal = pane.terminal as typeof pane.terminal & { + _core?: { refresh: typeof refresh } + } + terminal._core = { refresh } + terminal.write = vi.fn((_data: string, callback?: () => void) => { + parseCallback = callback + }) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(6) + + // Vim's final frame erases the status line and restores the normal + // buffer in one chunk; the atlas must still rebuild for the restored + // prompt even though the post-parse buffer is back to normal. + capturedDataCallback.current?.('\x1b[34;1H\x1b[K\x1b[34;1H\x1b[?1049l\x1b[?25h') + ;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'normal' + parseCallback?.() + expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1) + } finally { + restoreNavigator() + } + }) + + it('schedules WebGL atlas recovery when one chunk enters and exits alternate screen', async () => { + const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + let bufferChangeListener: (() => void) | undefined + ;( + pane.terminal.buffer as { + onBufferChange?: (listener: () => void) => { dispose: () => void } + } + ).onBufferChange = (listener) => { + bufferChangeListener = listener + return { dispose: vi.fn() } + } + const refresh = vi.fn() + let parseCallback: (() => void) | undefined + const terminal = pane.terminal as typeof pane.terminal & { + _core?: { refresh: typeof refresh } + } + terminal._core = { refresh } + terminal.write = vi.fn((_data: string, callback?: () => void) => { + parseCallback = callback + }) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(6) + + // A coalesced backlog flush can parse a whole enter -> draw -> exit TUI + // interaction in one write, netting buffer type back to 'normal'; the + // buffer-switch count is what still marks it as alternate-screen work. + capturedDataCallback.current?.('\x1b[?1049h\x1b[2J\x1b[Hpager frame\x1b[K\x1b[?1049l') + bufferChangeListener?.() + bufferChangeListener?.() + parseCallback?.() + expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1) + } finally { + restoreNavigator() + } + }) + + it('schedules WebGL atlas recovery for real captured Vim redraw chunks split mid-sequence', async () => { + const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + ;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'alternate' + const refresh = vi.fn() + let parseCallback: (() => void) | undefined + const terminal = pane.terminal as typeof pane.terminal & { + _core?: { refresh: typeof refresh } + } + terminal._core = { refresh } + terminal.write = vi.fn((_data: string, callback?: () => void) => { + parseCallback = callback + }) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(6) + + // Captured from a real `vim package.json` session: a 1024-byte PTY read + // boundary cuts the cursor move \x1b[30;5H into "\x1b[30" + ";5H". + capturedDataCallback.current?.('"rules": {\x1b[29;15H\x1b[K\x1b[30') + parseCallback?.() + expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1) + + capturedDataCallback.current?.( + ';5H "js-combine-iterations": "off"\r\n }\x1b[31;6H\x1b[K\x1b[33;1H\x1b[?25h' + ) + parseCallback?.() + expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(2) + } finally { + restoreNavigator() + } + }) + + it('does not schedule WebGL atlas recovery for ordinary foreground shell rewrites', async () => { + const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const refresh = vi.fn() + let parseCallback: (() => void) | undefined + const terminal = pane.terminal as typeof pane.terminal & { + _core?: { refresh: typeof refresh } + } + terminal._core = { refresh } + terminal.write = vi.fn((_data: string, callback?: () => void) => { + parseCallback = callback + }) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\r\x1b[Korca % npm test') + + parseCallback?.() + expect(refresh).toHaveBeenCalledWith(0, 39, true) + expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() + } finally { + restoreNavigator() + } + }) + it('does not schedule WebGL atlas recovery for plain synchronized foreground frames', async () => { const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') try { diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 49ce4062a02..f614aea121b 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -2550,6 +2550,14 @@ export function connectPanePty( forwardPtyResize(cols, rows) }) + // Why: a rewrite chunk can enter AND exit the alternate screen in one parse + // (fast-quitting TUI), netting buffer.active.type back to 'normal'; counting + // switches keeps those redraws visible to the atlas-recovery check. + let alternateScreenBufferSwitches = 0 + const onBufferChangeDisposable = pane.terminal.buffer.onBufferChange?.(() => { + alternateScreenBufferSwitches += 1 + }) + // Why: renderer resize forwarding is fire-and-forget. A visible pane can // finish with xterm at the right grid while the PTY silently kept an older // grid, so Codex keeps composing against stale columns. Fit first so xterm's @@ -3777,6 +3785,25 @@ export function connectPanePty( return decision.prefersRenderRefresh } + // Why: Vim-style TUI redraws are plain-ASCII in-place rewrites whose erased + // cells can keep stale WebGL glyphs until the shared atlas rebuilds. Whether + // a rewrite touched the alternate screen is only authoritative once xterm + // parses the chunk (enter/exit sequences can split across PTY chunks), so + // capture the pre-parse state and decide the rest at parse completion. + function alternateScreenRewriteAtlasRecoveryOnParsed(): () => void { + const wasAlternateScreenBuffer = pane.terminal.buffer.active.type === 'alternate' + const switchesBeforeParse = alternateScreenBufferSwitches + return () => { + if ( + wasAlternateScreenBuffer || + alternateScreenBufferSwitches !== switchesBeforeParse || + pane.terminal.buffer.active.type === 'alternate' + ) { + scheduleTerminalWebglAtlasRecovery() + } + } + } + function shouldForceForegroundRenderRefresh(data: string): { refresh: boolean inPlaceRewrite: boolean @@ -3864,6 +3891,13 @@ export function connectPanePty( !foregroundOutput && hiddenOutputNeedsAtlasRecoveryAfterParse(data) const recoverWebglAtlasAfterParse = renderRefreshDecision.recoverWebglAtlasAfterParse || recoverHiddenWebglAtlasAfterParse + // Why: atlas recovery must repaint from the parsed xterm buffer, not a + // pre-write snapshot that a late TUI redraw can immediately stale. + const onParsedAtlasRecovery = recoverWebglAtlasAfterParse + ? scheduleTerminalWebglAtlasRecovery + : renderRefreshDecision.inPlaceRewrite + ? alternateScreenRewriteAtlasRecoveryOnParsed() + : undefined const foregroundRenderRefreshNeeded = renderRefreshDecision.refresh // Why: see nativeWindowsRewriteNeedsFollowupRenderRefresh — Claude Code's // in-place prompt redraws on Windows ConPTY can paint one frame late, so a @@ -3910,9 +3944,7 @@ export function connectPanePty( foregroundRenderRefreshNeeded), followupForegroundRefresh: nativeWindowsCursorRestore || nativeWindowsInPlaceRewriteFollowup, - // Why: atlas recovery must repaint from the parsed xterm buffer, not - // a pre-write snapshot that a late TUI redraw can immediately stale. - onParsed: recoverWebglAtlasAfterParse ? scheduleTerminalWebglAtlasRecovery : undefined, + onParsed: onParsedAtlasRecovery, stripTransientCursorShows: shouldProtectNativeWindowsSynchronizedOutput && foreground, coalesceForeground: synchronizedForegroundOutput && synchronizedOutputEnded, holdForeground: synchronizedForegroundOutput && nextSynchronizedForegroundOutputActive @@ -5639,6 +5671,7 @@ export function connectPanePty( onDataDisposable.dispose() terminalCapabilityRepliesDisposable.dispose() onResizeDisposable.dispose() + onBufferChangeDisposable?.dispose() pane.container.removeEventListener(PANE_PTY_RESIZE_HOLD_FLUSH_EVENT, onHeldPtyResizeFlush) geometryReportObserver?.disconnect() if (pendingGeometryReportRaf !== null) { diff --git a/src/renderer/src/components/terminal-pane/terminal-alternate-screen-parse.test.ts b/src/renderer/src/components/terminal-pane/terminal-alternate-screen-parse.test.ts new file mode 100644 index 00000000000..7ce6e3854bd --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-alternate-screen-parse.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { Terminal } from '@xterm/headless' + +function writeChunk(term: Terminal, data: string): Promise { + return new Promise((resolve) => term.write(data, resolve)) +} + +// Pins the xterm contract the alternate-screen atlas recovery relies on: by the +// time a chunk's write callback runs, buffer.active.type reflects any +// alternate-screen enter/exit parsed from that chunk — even when the sequence +// splits across PTY chunk boundaries. +describe('alternate-screen buffer state at write-callback time', () => { + it('reflects an enter sequence split across two chunks', async () => { + const term = new Terminal({ cols: 120, rows: 34, allowProposedApi: true }) + await writeChunk(term, '\x1b[?104') + expect(term.buffer.active.type).toBe('normal') + await writeChunk(term, '9h\x1b[2J\x1b[H~\x1b[K') + expect(term.buffer.active.type).toBe('alternate') + term.dispose() + }) + + it('fires onBufferChange for each switch when one chunk enters and exits', async () => { + const term = new Terminal({ cols: 120, rows: 34, allowProposedApi: true }) + let switches = 0 + const disposable = term.buffer.onBufferChange(() => { + switches += 1 + }) + await writeChunk(term, '\x1b[?1049h\x1b[2J\x1b[Hpager frame\x1b[K\x1b[?1049l') + expect(term.buffer.active.type).toBe('normal') + expect(switches).toBe(2) + disposable.dispose() + term.dispose() + }) + + it('tracks enter, split redraw, and exit from a real captured vim session', async () => { + const term = new Terminal({ cols: 120, rows: 34, allowProposedApi: true }) + // Captured from `vim package.json` (macOS, TERM=xterm-256color): startup chunk. + await writeChunk( + term, + '\x1b[?1049h\x1b[>4;2m\x1b[?1h\x1b=\x1b[?2004h\x1b[?1004h\x1b[1;34r\x1b[?12h\x1b[?12l\x1b[22;2t\x1b[22;1t' + ) + expect(term.buffer.active.type).toBe('alternate') + // Mid-session redraw where a 1024-byte PTY read split \x1b[30;5H in two. + await writeChunk(term, '"rules": {\x1b[29;15H\x1b[K\x1b[30') + await writeChunk( + term, + ';5H "js-combine-iterations": "off"\r\n }\x1b[31;6H\x1b[K\x1b[33;1H\x1b[?25h' + ) + expect(term.buffer.active.type).toBe('alternate') + // Vim quit: erase the status line and restore the normal buffer in one chunk. + await writeChunk( + term, + '\x1b[23;2t\x1b[23;1t\x1b[34;1H\x1b[K\x1b[34;1H\x1b[?1004l\x1b[?2004l\x1b[?1l\x1b>\x1b[?1049l\x1b[?25h\x1b[>4;m' + ) + expect(term.buffer.active.type).toBe('normal') + term.dispose() + }) +}) From 185b768609bf9b9729a6f13b62ad2a44fc965a77 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 2 Jul 2026 21:59:41 -0700 Subject: [PATCH 09/48] Add rich Markdown spellcheck setting (#7103) --- .../LinearIssueMarkdownDescriptionEditor.tsx | 11 ++- .../components/editor/RichMarkdownEditor.tsx | 4 + .../rich-markdown-editor-config.test.ts | 90 +++++++++++++++++++ .../editor/rich-markdown-editor-config.ts | 5 +- .../editor/rich-markdown-spellcheck.ts | 12 +++ .../github/GitHubMarkdownComposer.tsx | 11 ++- .../settings/GeneralEditorSettingsSection.tsx | 3 + .../components/settings/GeneralPane.test.ts | 7 ++ .../RichMarkdownSpellcheckSetting.tsx | 44 +++++++++ .../settings/general-editor-search.ts | 23 +++++ src/renderer/src/i18n/locales/en.json | 8 +- src/renderer/src/i18n/locales/es.json | 8 +- src/renderer/src/i18n/locales/ja.json | 8 +- src/renderer/src/i18n/locales/ko.json | 8 +- src/renderer/src/i18n/locales/zh.json | 8 +- src/shared/constants.test.ts | 4 + src/shared/constants.ts | 1 + src/shared/types.ts | 2 + 18 files changed, 244 insertions(+), 13 deletions(-) create mode 100644 src/renderer/src/components/editor/rich-markdown-editor-config.test.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-spellcheck.ts create mode 100644 src/renderer/src/components/settings/RichMarkdownSpellcheckSetting.tsx diff --git a/src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx b/src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx index b63dc248665..5e45c6e71da 100644 --- a/src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx +++ b/src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx @@ -11,6 +11,11 @@ import { LinearIssueMarkdownToolbar } from '@/components/LinearIssueMarkdownTool import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import { + getRichMarkdownSpellcheckAttribute, + useRichMarkdownSpellcheckAttribute +} from '@/components/editor/rich-markdown-spellcheck' type LinearIssueMarkdownDescriptionEditorProps = { value: string @@ -46,6 +51,9 @@ export function LinearIssueMarkdownDescriptionEditor({ const language = i18n.resolvedLanguage ?? i18n.language const lastEditorMarkdownRef = useRef(value) const editorRef = useRef(null) + const richMarkdownSpellcheckEnabled = useAppStore( + (s) => s.settings?.richMarkdownSpellcheckEnabled ?? true + ) const linearIssueMarkdownExtensions = useMemo(() => { // Why: Tiptap freezes extension options when the editor is created; the // language value is the recreation key for translated extension options. @@ -63,7 +71,7 @@ export function LinearIssueMarkdownDescriptionEditor({ editorProps: { attributes: { class: 'rich-markdown-editor', - spellcheck: 'true', + spellcheck: getRichMarkdownSpellcheckAttribute(richMarkdownSpellcheckEnabled), 'aria-label': 'Issue description' }, handleKeyDown: (_view, event) => { @@ -93,6 +101,7 @@ export function LinearIssueMarkdownDescriptionEditor({ }, [language] ) + useRichMarkdownSpellcheckAttribute(editor, richMarkdownSpellcheckEnabled) useEffect(() => { editorRef.current = editor diff --git a/src/renderer/src/components/editor/RichMarkdownEditor.tsx b/src/renderer/src/components/editor/RichMarkdownEditor.tsx index c4b90043207..13461edd9ca 100644 --- a/src/renderer/src/components/editor/RichMarkdownEditor.tsx +++ b/src/renderer/src/components/editor/RichMarkdownEditor.tsx @@ -21,6 +21,7 @@ import { isRichMarkdownContextCommandTarget, runRichMarkdownContextCommand } from './rich-markdown-context-command-routing' +import { useRichMarkdownSpellcheckAttribute } from './rich-markdown-spellcheck' type RichMarkdownEditorProps = { fileId: string @@ -72,6 +73,7 @@ export default function RichMarkdownEditor({ }: RichMarkdownEditorProps): React.JSX.Element { const rootRef = useRef(null) const settings = useAppStore((s) => s.settings) + const richMarkdownSpellcheckEnabled = settings?.richMarkdownSpellcheckEnabled ?? true const editorFontZoomLevel = useAppStore((s) => s.editorFontZoomLevel) const activateMarkdownLink = useAppStore((s) => s.activateMarkdownLink) const addDiffComment = useAppStore((s) => s.addDiffComment) @@ -209,6 +211,7 @@ export default function RichMarkdownEditor({ worktreeRoot, runtimeEnvironmentId, isMac, + richMarkdownSpellcheckEnabled, settings, activateMarkdownLink, rootRef, @@ -246,6 +249,7 @@ export default function RichMarkdownEditor({ setSlashMenu: menu.setSlashMenu, setDocLinkMenu: menu.setDocLinkMenu }) + useRichMarkdownSpellcheckAttribute(editor, richMarkdownSpellcheckEnabled) // Why: use useLayoutEffect (synchronous cleanup) so the pending serialization // flush runs before useEditor's cleanup destroys the editor instance on tab diff --git a/src/renderer/src/components/editor/rich-markdown-editor-config.test.ts b/src/renderer/src/components/editor/rich-markdown-editor-config.test.ts new file mode 100644 index 00000000000..20d969ca8f0 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-editor-config.test.ts @@ -0,0 +1,90 @@ +import type { Dispatch, MutableRefObject, SetStateAction } from 'react' +import type { Editor } from '@tiptap/react' +import type { DocLinkMenuState } from './rich-markdown-commands' +import { describe, expect, it, vi } from 'vitest' +import type { LinkBubbleState } from './RichMarkdownLinkBubble' +import { + createRichMarkdownEditorConfig, + type EditorConfigParams +} from './rich-markdown-editor-config' +import type { SlashMenuState } from './rich-markdown-slash-commands' + +function ref(current: T): MutableRefObject { + return { current } +} + +function stateSetter(): Dispatch> { + return vi.fn() as Dispatch> +} + +function getSpellcheckAttribute(config: ReturnType): string { + const attributes = config.editorProps?.attributes + return typeof attributes === 'function' + ? attributes({} as never).spellcheck + : (attributes?.spellcheck ?? '') +} + +function createConfigParams(overrides: Partial = {}): EditorConfigParams { + return { + content: '', + filePath: '/repo/README.md', + worktreeId: 'worktree-1', + worktreeRoot: '/repo', + runtimeEnvironmentId: null, + isMac: false, + richMarkdownSpellcheckEnabled: true, + settings: { activeRuntimeEnvironmentId: null }, + activateMarkdownLink: vi.fn(), + rootRef: ref(null), + editorRef: ref(null), + lastCommittedMarkdownRef: ref(''), + onContentChangeRef: ref(vi.fn()), + onDirtyStateHintRef: ref(vi.fn()), + onSaveRef: ref(vi.fn()), + onOpenDocLinkRef: ref(undefined), + isEditingLinkRef: ref(false), + slashMenuRef: ref(null), + filteredSlashCommandsRef: ref([]), + selectedCommandIndexRef: ref(0), + docLinkMenuRef: ref(null), + filteredDocLinkRowsRef: ref([]), + selectedDocLinkIndexRef: ref(0), + handleLocalImagePickRef: ref(vi.fn()), + handleEmojiPickRef: ref(vi.fn()), + typedEmptyOrderedListMarkerRef: ref(false), + cancelAutoFocusRef: ref(null), + serializeTimerRef: ref(null), + isInitializingRef: ref(false), + isApplyingProgrammaticUpdateRef: ref(false), + markdownCommentsRef: ref([]), + markdownSourceLineOffsetRef: ref(0), + flushPendingSerialization: vi.fn(), + openSearchRef: ref(vi.fn()), + syncAnnotationTarget: vi.fn(), + clearAnnotationTarget: vi.fn(), + scrollRichMarkdownReviewNoteCardIntoView: vi.fn(), + setIsEditingLink: stateSetter(), + setLinkBubble: stateSetter(), + setSelectedCommandIndex: stateSetter(), + setSelectedDocLinkIndex: stateSetter(), + setSlashMenu: stateSetter(), + setDocLinkMenu: stateSetter(), + ...overrides + } +} + +describe('createRichMarkdownEditorConfig', () => { + it('disables browser spellcheck when the rich Markdown setting is off', () => { + const config = createRichMarkdownEditorConfig( + createConfigParams({ richMarkdownSpellcheckEnabled: false }) + ) + + expect(getSpellcheckAttribute(config)).toBe('false') + }) + + it('keeps browser spellcheck enabled by default', () => { + const config = createRichMarkdownEditorConfig(createConfigParams()) + + expect(getSpellcheckAttribute(config)).toBe('true') + }) +}) diff --git a/src/renderer/src/components/editor/rich-markdown-editor-config.ts b/src/renderer/src/components/editor/rich-markdown-editor-config.ts index e6fedd8f897..de2e520d631 100644 --- a/src/renderer/src/components/editor/rich-markdown-editor-config.ts +++ b/src/renderer/src/components/editor/rich-markdown-editor-config.ts @@ -26,6 +26,7 @@ import { createRichMarkdownImageResolverContext, setRichMarkdownImageResolverContext } from './rich-markdown-image-context' +import { getRichMarkdownSpellcheckAttribute } from './rich-markdown-spellcheck' import type { MutableRefObject, Dispatch, SetStateAction } from 'react' import type { DiffComment } from '../../../../shared/types' @@ -36,6 +37,7 @@ export type EditorConfigParams = { worktreeRoot: string | null runtimeEnvironmentId?: string | null isMac: boolean + richMarkdownSpellcheckEnabled: boolean settings: RichMarkdownRuntimeSettings activateMarkdownLink: ActivateMarkdownLink rootRef: MutableRefObject @@ -82,6 +84,7 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE worktreeRoot, runtimeEnvironmentId, isMac, + richMarkdownSpellcheckEnabled, settings, activateMarkdownLink, rootRef, @@ -127,7 +130,7 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE editorProps: { attributes: { class: 'rich-markdown-editor', - spellcheck: 'true' + spellcheck: getRichMarkdownSpellcheckAttribute(richMarkdownSpellcheckEnabled) }, handleDOMEvents: { cut: handleRichMarkdownCut diff --git a/src/renderer/src/components/editor/rich-markdown-spellcheck.ts b/src/renderer/src/components/editor/rich-markdown-spellcheck.ts new file mode 100644 index 00000000000..0b82bdf60ee --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-spellcheck.ts @@ -0,0 +1,12 @@ +import { useEffect } from 'react' +import type { Editor } from '@tiptap/react' + +export function getRichMarkdownSpellcheckAttribute(enabled: boolean): 'true' | 'false' { + return enabled ? 'true' : 'false' +} + +export function useRichMarkdownSpellcheckAttribute(editor: Editor | null, enabled: boolean): void { + useEffect(() => { + editor?.view.dom.setAttribute('spellcheck', getRichMarkdownSpellcheckAttribute(enabled)) + }, [editor, enabled]) +} diff --git a/src/renderer/src/components/github/GitHubMarkdownComposer.tsx b/src/renderer/src/components/github/GitHubMarkdownComposer.tsx index ba840a82421..9a4019a3f8b 100644 --- a/src/renderer/src/components/github/GitHubMarkdownComposer.tsx +++ b/src/renderer/src/components/github/GitHubMarkdownComposer.tsx @@ -25,6 +25,11 @@ import { hasBoundedGitHubMarkdownImageUrlText } from '@/components/github/github import { useImageInput } from '@/components/github/use-image-input' import type { GitHubOwnerRepo } from '../../../../shared/types' import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import { + getRichMarkdownSpellcheckAttribute, + useRichMarkdownSpellcheckAttribute +} from '@/components/editor/rich-markdown-spellcheck' type GitHubMarkdownComposerProps = { value: string @@ -59,6 +64,9 @@ export function GitHubMarkdownComposer({ const onSubmitShortcutRef = useRef(onSubmitShortcut) const disabledRef = useRef(disabled) const isEditingLinkRef = useRef(false) + const richMarkdownSpellcheckEnabled = useAppStore( + (s) => s.settings?.richMarkdownSpellcheckEnabled ?? true + ) const [activeTab, setActiveTab] = useState('write') const [linkBubble, setLinkBubble] = useState(null) const [isEditingLink, setIsEditingLink] = useState(false) @@ -114,7 +122,7 @@ export function GitHubMarkdownComposer({ editorProps: { attributes: { class: cn('rich-markdown-editor github-markdown-composer-editor', minHeightClassName), - spellcheck: 'true' + spellcheck: getRichMarkdownSpellcheckAttribute(richMarkdownSpellcheckEnabled) }, handleKeyDown: (_view, event) => { if (isScreenSubmitShortcut(event)) { @@ -179,6 +187,7 @@ export function GitHubMarkdownComposer({ setLinkBubble(null) } }) + useRichMarkdownSpellcheckAttribute(editor, richMarkdownSpellcheckEnabled) useEffect(() => { if (!editor) { diff --git a/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx b/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx index 2d8ca10c2f7..a4a33f1102a 100644 --- a/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx +++ b/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx @@ -16,6 +16,7 @@ import { SettingsSwitchRow } from './SettingsFormControls' import { translate } from '@/i18n/i18n' +import { RichMarkdownSpellcheckSetting } from './RichMarkdownSpellcheckSetting' export type AutoSaveDelayDraftState = { sourceDelayMs: number @@ -378,6 +379,8 @@ export function GeneralEditorSettingsSection({ /> + + { expect(matchesSettingsSearch('wsl', entries)).toBe(true) }) + it('includes rich Markdown spellcheck keywords', () => { + const entries = getGeneralPaneSearchEntries() + + expect(matchesSettingsSearch('spellcheck', entries)).toBe(true) + expect(matchesSettingsSearch('red underline', entries)).toBe(true) + }) + it('omits the default project runtime setting when Windows runtimes are unsupported', () => { const entries = getGeneralPaneSearchEntries({ includeProjectRuntime: false }) diff --git a/src/renderer/src/components/settings/RichMarkdownSpellcheckSetting.tsx b/src/renderer/src/components/settings/RichMarkdownSpellcheckSetting.tsx new file mode 100644 index 00000000000..c59a6302386 --- /dev/null +++ b/src/renderer/src/components/settings/RichMarkdownSpellcheckSetting.tsx @@ -0,0 +1,44 @@ +import type React from 'react' +import type { GlobalSettings } from '../../../../shared/types' +import { translate } from '@/i18n/i18n' +import { SearchableSetting } from './SearchableSetting' +import { SettingsSwitchRow } from './SettingsFormControls' + +type RichMarkdownSpellcheckSettingProps = { + settings: Pick + updateSettings: (updates: Partial) => void +} + +export function RichMarkdownSpellcheckSetting({ + settings, + updateSettings +}: RichMarkdownSpellcheckSettingProps): React.JSX.Element { + const enabled = settings.richMarkdownSpellcheckEnabled ?? true + + return ( + + updateSettings({ richMarkdownSpellcheckEnabled: !enabled })} + /> + + ) +} diff --git a/src/renderer/src/components/settings/general-editor-search.ts b/src/renderer/src/components/settings/general-editor-search.ts index 0a13d7c5dd8..8f93eea9544 100644 --- a/src/renderer/src/components/settings/general-editor-search.ts +++ b/src/renderer/src/components/settings/general-editor-search.ts @@ -79,6 +79,29 @@ export const getGeneralEditorSearchEntries = createLocalizedCatalog(() => [ ...translateSearchKeyword('auto.components.settings.general.search.a0014961ae', 'scroll') ] }, + { + title: translate( + 'auto.components.settings.general.search.d2d2d929c0', + 'Rich Markdown Spellcheck' + ), + description: translate( + 'auto.components.settings.general.search.4497e2e2bb', + 'Show browser spelling underlines and suggestions while editing rich Markdown.' + ), + keywords: [ + ...translateSearchKeyword('auto.components.settings.general.search.f96cdaf37d', 'spellcheck'), + ...translateSearchKeyword( + 'auto.components.settings.general.search.a51d23f4a1', + 'spell check' + ), + ...translateSearchKeyword('auto.components.settings.general.search.641358460a', 'spelling'), + ...translateSearchKeyword('auto.components.settings.general.search.d05f629d2c', 'markdown'), + ...translateSearchKeyword( + 'auto.components.settings.general.search.d755962089', + 'red underline' + ) + ] + }, { title: translate('auto.components.settings.general.search.128bc09325', 'Markdown Review Notes'), description: translate( diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 88287a0b519..b9113f064cd 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -5163,7 +5163,9 @@ "8f1afdfbd8": "Diff Word Wrap", "4aa4d9fb73": "Wrap long lines in diff editors instead of requiring horizontal scrolling.", "bf16ef0af2": "Off", - "3f6892f307": "On" + "3f6892f307": "On", + "b82f86d7d2": "Rich Markdown Spellcheck", + "5195f0b9ef": "Show browser spelling underlines and suggestions while editing rich Markdown." }, "AdvancedNetworkSettingsSection": { "3e431564b5": "localhost, 127.0.0.1, *.internal", @@ -7453,7 +7455,9 @@ "161a86a9da": "Confirm before closing pinned tabs", "8e593f04fc": "Show a confirmation dialog before a pinned tab is closed.", "defaultProjectRuntime": "Default Project Runtime", - "defaultProjectRuntimeDescription": "Choose the runtime inherited by local Windows projects." + "defaultProjectRuntimeDescription": "Choose the runtime inherited by local Windows projects.", + "d2d2d929c0": "Rich Markdown Spellcheck", + "4497e2e2bb": "Show browser spelling underlines and suggestions while editing rich Markdown." } }, "git": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index dccd120a443..ea71f964f4b 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -5163,7 +5163,9 @@ "8f1afdfbd8": "Ajuste de palabras diferencial", "4aa4d9fb73": "Ajuste líneas largas en editores de diferencias en lugar de requerir desplazamiento horizontal.", "bf16ef0af2": "Apagado", - "3f6892f307": "En" + "3f6892f307": "En", + "b82f86d7d2": "Rich Markdown Spellcheck", + "5195f0b9ef": "Show browser spelling underlines and suggestions while editing rich Markdown." }, "AdvancedNetworkSettingsSection": { "3e431564b5": "host local, 127.0.0.1, *.interno", @@ -7416,7 +7418,9 @@ "161a86a9da": "Confirmar antes de cerrar pestañas fijadas", "8e593f04fc": "Muestra un diálogo de confirmación antes de cerrar una pestaña fijada.", "defaultProjectRuntime": "Default Project Runtime", - "defaultProjectRuntimeDescription": "Choose the runtime inherited by local Windows projects." + "defaultProjectRuntimeDescription": "Choose the runtime inherited by local Windows projects.", + "d2d2d929c0": "Rich Markdown Spellcheck", + "4497e2e2bb": "Show browser spelling underlines and suggestions while editing rich Markdown." } }, "git": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index bdd01d85a4b..177d52f50b2 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -5148,7 +5148,9 @@ "8f1afdfbd8": "差分ワードラップ", "4aa4d9fb73": "水平スクロールを必要とせずに、差分エディターで長い行を折り返します。", "bf16ef0af2": "オフ", - "3f6892f307": "オン" + "3f6892f307": "オン", + "b82f86d7d2": "Rich Markdown Spellcheck", + "5195f0b9ef": "Show browser spelling underlines and suggestions while editing rich Markdown." }, "AdvancedNetworkSettingsSection": { "3e431564b5": "ローカルホスト、127.0.0.1、*.internal", @@ -7438,7 +7440,9 @@ "161a86a9da": "ピン留めしたタブを閉じる前に確認", "8e593f04fc": "ピン留めしたタブを閉じる前に確認ダイアログを表示します。", "defaultProjectRuntime": "Default Project Runtime", - "defaultProjectRuntimeDescription": "Choose the runtime inherited by local Windows projects." + "defaultProjectRuntimeDescription": "Choose the runtime inherited by local Windows projects.", + "d2d2d929c0": "Rich Markdown Spellcheck", + "4497e2e2bb": "Show browser spelling underlines and suggestions while editing rich Markdown." } }, "git": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index a934f15ad8b..ab7493ba20b 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -5148,7 +5148,9 @@ "8f1afdfbd8": "차이점 단어 줄 바꿈", "4aa4d9fb73": "가로 스크롤을 요구하는 대신 diff 편집기에서 긴 줄을 줄 바꿈합니다.", "bf16ef0af2": "끄다", - "3f6892f307": "~에" + "3f6892f307": "~에", + "b82f86d7d2": "Rich Markdown Spellcheck", + "5195f0b9ef": "Show browser spelling underlines and suggestions while editing rich Markdown." }, "AdvancedNetworkSettingsSection": { "3e431564b5": "로컬호스트, 127.0.0.1, *.internal", @@ -7401,7 +7403,9 @@ "161a86a9da": "고정된 탭을 닫기 전에 확인", "8e593f04fc": "고정된 탭을 닫기 전에 확인 대화상자를 표시합니다.", "defaultProjectRuntime": "기본 프로젝트 런타임", - "defaultProjectRuntimeDescription": "로컬 Windows 프로젝트가 상속할 런타임을 선택합니다." + "defaultProjectRuntimeDescription": "로컬 Windows 프로젝트가 상속할 런타임을 선택합니다.", + "d2d2d929c0": "Rich Markdown Spellcheck", + "4497e2e2bb": "Show browser spelling underlines and suggestions while editing rich Markdown." } }, "git": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 6114335ad89..78f552eef4a 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -5148,7 +5148,9 @@ "8f1afdfbd8": "差异自动换行", "4aa4d9fb73": "在差异编辑器中换行显示长行,而不是要求水平滚动。", "bf16ef0af2": "关闭", - "3f6892f307": "开启" + "3f6892f307": "开启", + "b82f86d7d2": "Rich Markdown Spellcheck", + "5195f0b9ef": "Show browser spelling underlines and suggestions while editing rich Markdown." }, "AdvancedNetworkSettingsSection": { "3e431564b5": "本地主机,127.0.0.1,*.internal", @@ -7401,7 +7403,9 @@ "161a86a9da": "关闭固定标签页前确认", "8e593f04fc": "关闭固定标签页前显示确认对话框。", "defaultProjectRuntime": "默认项目运行时", - "defaultProjectRuntimeDescription": "选择本地 Windows 项目继承的运行时。" + "defaultProjectRuntimeDescription": "选择本地 Windows 项目继承的运行时。", + "d2d2d929c0": "Rich Markdown Spellcheck", + "4497e2e2bb": "Show browser spelling underlines and suggestions while editing rich Markdown." } }, "git": { diff --git a/src/shared/constants.test.ts b/src/shared/constants.test.ts index fdead8aab38..26db028766d 100644 --- a/src/shared/constants.test.ts +++ b/src/shared/constants.test.ts @@ -51,6 +51,10 @@ describe('getDefaultSettings', () => { expect(getDefaultSettings('/tmp').confirmClosePinnedTab).toBe(true) }) + it('keeps rich Markdown spellcheck enabled by default', () => { + expect(getDefaultSettings('/tmp').richMarkdownSpellcheckEnabled).toBe(true) + }) + it('enables Source Control AI by default without pinning a separate agent', () => { expect(getDefaultSettings('/tmp').commitMessageAi).toMatchObject({ enabled: true, diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 67ac5e65450..4eebbaa9383 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -203,6 +203,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings { editorAutoSave: false, editorAutoSaveDelayMs: DEFAULT_EDITOR_AUTO_SAVE_DELAY_MS, editorMinimapEnabled: false, + richMarkdownSpellcheckEnabled: true, markdownReviewToolsEnabled: true, primarySelectionMiddleClickPaste: getDefaultPrimarySelectionMiddleClickPaste(), primarySelectionMiddleClickPasteDefaultedForLinux: diff --git a/src/shared/types.ts b/src/shared/types.ts index e403caba22d..b55b96270e4 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2472,6 +2472,8 @@ export type GlobalSettings = { editorAutoSave: boolean editorAutoSaveDelayMs: number editorMinimapEnabled: boolean + /** Persisted opt-out for browser spellcheck noise in rich Markdown editing surfaces. */ + richMarkdownSpellcheckEnabled?: boolean /** Whether local markdown review note controls and the review panel are shown. */ markdownReviewToolsEnabled: boolean /** Why: mirrors terminal selection-paste muscle memory without mutating the From 4caea00f166ff7debe762ef43179f6b097e4eeec Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:04:05 -0700 Subject: [PATCH 10/48] Skip the synchronous cold-restore replay for live daemon sessions in doSpawn (#7105) Co-authored-by: Orca --- src/main/daemon/daemon-pty-adapter.test.ts | 154 +++++++++++++++++++++ src/main/daemon/daemon-pty-adapter.ts | 35 ++++- src/main/daemon/history-reader.ts | 20 ++- 3 files changed, 203 insertions(+), 6 deletions(-) diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index e32415aa9a3..af7756e45e7 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -6,6 +6,7 @@ import { mkdtempSync, mkdirSync, rmSync, existsSync, readFileSync, writeFileSync import { DaemonPtyAdapter } from './daemon-pty-adapter' import { DaemonServer } from './daemon-server' import { getHistorySessionDirName } from './history-paths' +import type { HistoryReader } from './history-reader' import type { SubprocessHandle } from './session' import type * as DaemonHealthModule from './daemon-health' import { getDaemonSocketPath } from './daemon-spawner' @@ -1239,6 +1240,159 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { expect(internals.lastFullCheckpointAt.has(sessionId)).toBe(false) }) + it('skips the cold-restore replay when the daemon session is still alive', async () => { + const sessionId = 'warm-reattach-skip-replay' + const first = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + await first.spawn({ cols: 80, rows: 24, cwd: '/home/user', sessionId }) + // Why disconnectOnly: the production app-quit path leaves meta.endedAt + // null so the session stays crash-recoverable — the state every app + // relaunch with a live daemon sees. + await first.disconnectOnly() + + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + const reader = (historyAdapter as unknown as { historyReader: HistoryReader }).historyReader + const detectSpy = vi.spyOn(reader, 'detectColdRestore') + const result = await historyAdapter.spawn({ cols: 80, rows: 24, sessionId }) + + expect(result.isReattach).toBe(true) + expect(result.coldRestore).toBeUndefined() + expect(detectSpy).not.toHaveBeenCalled() + // The unmanaged-reattach re-anchor must survive the skipped detect. + const internals = historyAdapter as unknown as { + sessionsNeedingFullCheckpoint: Set + lastFullCheckpointAt: Map + } + expect(internals.sessionsNeedingFullCheckpoint.has(sessionId)).toBe(true) + expect(internals.lastFullCheckpointAt.has(sessionId)).toBe(false) + }) + + it('does not probe session aliveness when there is no restorable history', async () => { + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + const client = ( + historyAdapter as unknown as { + client: { request: (type: string, payload?: unknown) => Promise } + } + ).client + const requestSpy = vi.spyOn(client, 'request') + + await historyAdapter.spawn({ cols: 80, rows: 24, sessionId: 'fresh-no-history' }) + + expect(requestSpy.mock.calls.map((call) => call[0])).not.toContain('getSize') + }) + + it('recovers cold restore when the probed session dies before createOrAttach', async () => { + const sessionId = 'probe-race-cold-restore' + const sessionDir = join(historyDir, getHistorySessionDirName(sessionId)) + mkdirSync(sessionDir, { recursive: true }) + writeFileSync( + join(sessionDir, 'meta.json'), + JSON.stringify({ + cwd: '/projects/raced', + cols: 100, + rows: 30, + startedAt: '2026-04-15T10:00:00Z', + endedAt: null, + exitCode: null + }) + ) + writeFileSync(join(sessionDir, 'scrollback.bin'), 'raced output\r\n') + + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + const client = ( + historyAdapter as unknown as { + client: { request: (type: string, payload?: unknown) => Promise } + } + ).client + const originalRequest = client.request.bind(client) + // Why: simulates the probe→createOrAttach race — the probe sees the + // session alive, but it is gone by the time createOrAttach runs. The + // meta rewrite mimics the dying session's exit event beating the + // createOrAttach reply and writing endedAt via closeSession; the + // fallback detect must still restore instead of falling through to + // openSession (which would delete the checkpoint). + vi.spyOn(client, 'request').mockImplementation(async (type: string, payload?: unknown) => { + if (type === 'getSize') { + return { size: { cols: 100, rows: 30 } } + } + const response = await originalRequest(type, payload) + if (type === 'createOrAttach') { + writeFileSync( + join(sessionDir, 'meta.json'), + JSON.stringify({ + cwd: '/projects/raced', + cols: 100, + rows: 30, + startedAt: '2026-04-15T10:00:00Z', + endedAt: '2026-04-15T10:05:00Z', + exitCode: 0 + }) + ) + } + return response + }) + + const result = await historyAdapter.spawn({ cols: 80, rows: 24, sessionId }) + + expect(result.coldRestore).toBeDefined() + expect(result.coldRestore!.scrollback).toContain('raced output') + // Documented race delta: the fresh shell spawns with the renderer's + // requested params, not the recovered ones. + expect(lastSpawnOpts).toMatchObject({ sessionId, cols: 80, rows: 24 }) + // The recovery data must survive — openSession would have deleted it. + expect(existsSync(join(sessionDir, 'scrollback.bin'))).toBe(true) + const internals = historyAdapter as unknown as { + sessionsNeedingFullCheckpoint: Set + lastFullCheckpointAt: Map + } + expect(internals.sessionsNeedingFullCheckpoint.has(sessionId)).toBe(true) + expect(internals.lastFullCheckpointAt.has(sessionId)).toBe(false) + }) + + it('falls back to the full cold-restore detect when the aliveness probe fails', async () => { + const sessionId = 'probe-error-cold-restore' + const sessionDir = join(historyDir, getHistorySessionDirName(sessionId)) + mkdirSync(sessionDir, { recursive: true }) + writeFileSync( + join(sessionDir, 'meta.json'), + JSON.stringify({ + cwd: '/projects/probeless', + cols: 132, + rows: 43, + startedAt: '2026-04-15T10:00:00Z', + endedAt: null, + exitCode: null + }) + ) + writeFileSync(join(sessionDir, 'scrollback.bin'), 'probeless output\r\n') + + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + const client = ( + historyAdapter as unknown as { + client: { request: (type: string, payload?: unknown) => Promise } + } + ).client + const originalRequest = client.request.bind(client) + // Why: an old daemon rejects the unknown getSize method; the spawn must + // behave exactly like the unprobed path. + vi.spyOn(client, 'request').mockImplementation((type: string, payload?: unknown) => { + if (type === 'getSize') { + return Promise.reject(new Error('Unknown request type')) + } + return originalRequest(type, payload) + }) + + const result = await historyAdapter.spawn({ cols: 80, rows: 24, sessionId }) + + expect(result.coldRestore).toBeDefined() + expect(result.coldRestore!.scrollback).toContain('probeless output') + expect(lastSpawnOpts).toMatchObject({ + sessionId, + cwd: '/projects/probeless', + cols: 132, + rows: 43 + }) + }) + it('returns same cold restore on StrictMode double-mount (sticky cache)', async () => { const sessionId = 'sticky-cache-test' const sessionDir = join(historyDir, getHistorySessionDirName(sessionId)) diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index f4d717f1999..a649abebcc9 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -6,7 +6,7 @@ import { existsSync } from 'node:fs' import { DaemonClient } from './client' import { getMacDaemonSystemResolverHealth } from './daemon-health' import { HistoryManager } from './history-manager' -import { HistoryReader } from './history-reader' +import { HistoryReader, type ColdRestoreInfo } from './history-reader' import { mintPtySessionId, parsePtySessionId } from './pty-session-id' import { supportsPtyStartupBarrier } from './shell-ready' import { CODEX_SHELL_READY_TIMEOUT_MS } from './session' @@ -141,16 +141,30 @@ export class DaemonPtyAdapter implements IPtyProvider { await this.replaceUnhealthyMacResolverDaemonBeforeNewPty() } + await this.ensureConnected() + // Why: detect crash-recovery history before spawning a replacement PTY so // the revived shell inherits the recovered cwd and dimensions instead of // whatever the current renderer happened to request on mount. - const restoreInfo = this.historyReader?.detectColdRestore(sessionId) ?? null + // Why probe aliveness first: detectColdRestore synchronously replays the + // full checkpoint + log (up to ~5MB) through a scratch emulator on the + // main process, but a live daemon session ignores spawn params and its + // own snapshot supersedes disk — the replay result would be discarded. + // getSize is a read-only probe; on error/unsupported it degrades to the + // full detect. + let restoreInfo: ColdRestoreInfo | null = null + let restoreSkippedForLiveSession = false + if (this.historyReader?.hasRestorableHistory(sessionId)) { + if ((await this.getAppliedSize(sessionId)) !== null) { + restoreSkippedForLiveSession = true + } else { + restoreInfo = this.historyReader.detectColdRestore(sessionId) + } + } const effectiveCwd = restoreInfo?.cwd ?? opts.cwd const effectiveCols = restoreInfo?.cols ?? opts.cols const effectiveRows = restoreInfo?.rows ?? opts.rows - await this.ensureConnected() - const shellReadySupported = opts.command ? supportsPtyStartupBarrier(opts.env ?? {}) : false const isCodexStartupCommand = recognizeAgentProcessFromCommandLine(opts.command)?.agent === 'codex' @@ -208,6 +222,19 @@ export class DaemonPtyAdapter implements IPtyProvider { } } + // Why: the probe→createOrAttach gap is racy — the session can exit (or + // enter termination) in between, so the daemon spawned a fresh shell. + // Detect now so scrollback restore matches the unprobed path; only the + // new shell's cwd/dims came from the renderer request in this rare case. + // Why ignoreCleanEnd: the raced session's exit event (stream socket) can + // beat the createOrAttach reply and write endedAt via closeSession; that + // must not null the restore here, or the openSession branch below would + // delete the checkpoint instead of restoring it. + if (result.isNew && restoreSkippedForLiveSession) { + restoreInfo = + this.historyReader?.detectColdRestore(sessionId, { ignoreCleanEnd: true }) ?? null + } + const wasAlreadyManaged = this.activeSessionIds.has(sessionId) this.activeSessionIds.add(sessionId) diff --git a/src/main/daemon/history-reader.ts b/src/main/daemon/history-reader.ts index 2914ed21aa7..f6661b50044 100644 --- a/src/main/daemon/history-reader.ts +++ b/src/main/daemon/history-reader.ts @@ -28,12 +28,28 @@ export class HistoryReader { this.basePath = basePath } - detectColdRestore(sessionId: string): ColdRestoreInfo | null { + // Why: spawn needs a cheap "could this cold-restore?" predicate before + // deciding to pay detectColdRestore's full checkpoint+log replay. Reads only + // the small meta.json, using the same unclean-shutdown test detectColdRestore + // starts with. + hasRestorableHistory(sessionId: string): boolean { + const meta = this.readMeta(sessionId) + return meta !== null && meta.endedAt === null + } + + detectColdRestore( + sessionId: string, + opts?: { ignoreCleanEnd?: boolean } + ): ColdRestoreInfo | null { const meta = this.readMeta(sessionId) if (!meta) { return null } - if (meta.endedAt !== null) { + // Why ignoreCleanEnd: in the spawn probe race, the dying session's exit + // event can write endedAt between the aliveness probe and the post-spawn + // fallback detect. The caller established restore eligibility before the + // probe, so the just-written clean end must not downgrade the restore. + if (meta.endedAt !== null && !opts?.ignoreCleanEnd) { return null } From b523c5311f16e807bbc2c969dcba0fa56df70231 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:18:12 -0700 Subject: [PATCH 11/48] fix: show Claude Fable usage in account switcher Show Fable usage in inactive Claude account switcher previews by supplementing OAuth usage with scoped Claude CLI /usage data, with macOS scoped Keychain safety checks and compact preview layout coverage. Validation: - pnpm exec vitest run --config config/vitest.config.ts src/main/rate-limits/claude-fetcher.test.ts src/main/rate-limits/service.test.ts src/renderer/src/components/status-bar/inline-usage-bars.test.tsx src/renderer/src/components/status-bar/tooltip.test.ts - pnpm run typecheck - pnpm exec oxlint src/main/rate-limits/claude-fetcher.ts src/main/rate-limits/claude-fetcher.test.ts src/main/rate-limits/service.ts src/main/rate-limits/service.test.ts src/renderer/src/components/status-bar/StatusBar.tsx src/renderer/src/components/status-bar/inline-usage-bars.test.tsx - git diff --check HEAD~1..HEAD --- src/main/rate-limits/claude-fetcher.test.ts | 248 +++++++++++++++++- src/main/rate-limits/claude-fetcher.ts | 163 +++++++++++- src/main/rate-limits/service.test.ts | 13 + src/main/rate-limits/service.ts | 4 +- .../src/components/status-bar/StatusBar.tsx | 89 +++---- .../status-bar/inline-usage-bars.test.tsx | 53 ++++ 6 files changed, 508 insertions(+), 62 deletions(-) create mode 100644 src/renderer/src/components/status-bar/inline-usage-bars.test.tsx diff --git a/src/main/rate-limits/claude-fetcher.test.ts b/src/main/rate-limits/claude-fetcher.test.ts index 24ee44f7cec..6571177a928 100644 --- a/src/main/rate-limits/claude-fetcher.test.ts +++ b/src/main/rate-limits/claude-fetcher.test.ts @@ -1,14 +1,17 @@ /* eslint-disable max-lines -- Why: Claude rate-limit fallback tests share account/keychain/PTY mocks that would be noisier split apart. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fetchClaudeRateLimits, fetchManagedAccountUsage } from './claude-fetcher' import { fetchViaPty } from './claude-pty' import { + deleteActiveClaudeKeychainCredentialsStrict, readActiveClaudeKeychainCredentials, readActiveClaudeKeychainCredentialsStrict, - readManagedClaudeKeychainCredentials + readManagedClaudeKeychainCredentials, + writeActiveClaudeKeychainCredentials, + writeManagedClaudeKeychainCredentials } from '../claude-accounts/keychain' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' @@ -46,9 +49,12 @@ vi.mock('./claude-pty', () => ({ })) vi.mock('../claude-accounts/keychain', () => ({ + deleteActiveClaudeKeychainCredentialsStrict: vi.fn(), readActiveClaudeKeychainCredentials: vi.fn(), readActiveClaudeKeychainCredentialsStrict: vi.fn(), - readManagedClaudeKeychainCredentials: vi.fn() + readManagedClaudeKeychainCredentials: vi.fn(), + writeActiveClaudeKeychainCredentials: vi.fn(), + writeManagedClaudeKeychainCredentials: vi.fn() })) const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') @@ -71,6 +77,9 @@ describe('fetchClaudeRateLimits', () => { vi.mocked(readActiveClaudeKeychainCredentials).mockResolvedValue(null) vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValue(null) vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValue(null) + vi.mocked(writeActiveClaudeKeychainCredentials).mockResolvedValue() + vi.mocked(deleteActiveClaudeKeychainCredentialsStrict).mockResolvedValue() + vi.mocked(writeManagedClaudeKeychainCredentials).mockResolvedValue() appGetPathMock.mockReturnValue('/tmp/orca-claude-fetcher-test') resolveProxyMock.mockResolvedValue('DIRECT') netFetchMock.mockResolvedValue( @@ -948,6 +957,239 @@ describe('fetchClaudeRateLimits', () => { expect(readFileMock).not.toHaveBeenCalled() }) + it('supplements inactive managed account OAuth usage with Fable from its usage panel', async () => { + setPlatform('linux') + tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-')) + appGetPathMock.mockReturnValue(tempDir) + const ownedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') + mkdirSync(ownedAuthPath, { recursive: true }) + writeFileSync(join(ownedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') + const canonicalAuthPath = realpathSync(ownedAuthPath) + writeFileSync( + join(ownedAuthPath, '.credentials.json'), + JSON.stringify({ + claudeAiOauth: { + accessToken: 'inactive-token', + expiresAt: Date.now() + 60_000 + } + }), + 'utf-8' + ) + vi.mocked(fetchViaPty).mockResolvedValueOnce({ + provider: 'claude', + session: null, + weekly: null, + fableWeekly: { + usedPercent: 42, + windowMinutes: 10080, + resetsAt: null, + resetDescription: '2d' + }, + updatedAt: 1, + error: null, + status: 'ok' + }) + + await expect( + fetchManagedAccountUsage( + { id: 'account-1', managedAuthPath: ownedAuthPath }, + { allowUsagePanelSupplement: true } + ) + ).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + session: { usedPercent: 12 }, + weekly: { usedPercent: 34 }, + fableWeekly: { usedPercent: 42, resetDescription: '2d' } + }) + expect(fetchViaPty).toHaveBeenCalledWith({ + authPreparation: expect.objectContaining({ + configDir: canonicalAuthPath, + envPatch: { CLAUDE_CONFIG_DIR: canonicalAuthPath }, + provenance: 'managed:account-1:inactive-preview', + stripAuthEnv: true + }) + }) + }) + + it('stages macOS inactive account credentials in a scoped Keychain for Fable preview', async () => { + setPlatform('darwin') + tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-')) + appGetPathMock.mockReturnValue(tempDir) + const ownedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') + const credentialsJson = JSON.stringify({ + claudeAiOauth: { + accessToken: 'managed-keychain-token', + expiresAt: Date.now() + 60_000 + } + }) + mkdirSync(ownedAuthPath, { recursive: true }) + writeFileSync(join(ownedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') + const canonicalAuthPath = realpathSync(ownedAuthPath) + vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValueOnce(credentialsJson) + vi.mocked(fetchViaPty).mockResolvedValueOnce({ + provider: 'claude', + session: { + usedPercent: 12, + windowMinutes: 300, + resetsAt: null, + resetDescription: null + }, + weekly: { + usedPercent: 34, + windowMinutes: 10080, + resetsAt: null, + resetDescription: null + }, + fableWeekly: { + usedPercent: 58, + windowMinutes: 10080, + resetsAt: null, + resetDescription: '3d' + }, + updatedAt: 1, + error: null, + status: 'ok' + }) + + const result = await fetchManagedAccountUsage( + { id: 'account-1', managedAuthPath: ownedAuthPath }, + { allowUsagePanelSupplement: true } + ) + + expect(result.fableWeekly).toMatchObject({ usedPercent: 58, resetDescription: '3d' }) + expect(writeActiveClaudeKeychainCredentials).toHaveBeenCalledWith( + credentialsJson, + canonicalAuthPath + ) + expect(deleteActiveClaudeKeychainCredentialsStrict).toHaveBeenCalledWith(canonicalAuthPath) + }) + + it('stages refreshed macOS inactive account credentials before Fable preview', async () => { + setPlatform('darwin') + tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-')) + appGetPathMock.mockReturnValue(tempDir) + const ownedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') + const staleCredentialsJson = JSON.stringify({ + claudeAiOauth: { + accessToken: 'stale-access', + refreshToken: 'stale-refresh', + expiresAt: Date.now() - 60_000 + } + }) + mkdirSync(ownedAuthPath, { recursive: true }) + writeFileSync(join(ownedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') + vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValueOnce(staleCredentialsJson) + netFetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + access_token: 'fresh-access', + expires_in: 3600, + refresh_token: 'fresh-refresh' + }), + { status: 200 } + ) + ) + netFetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ five_hour: { utilization: 12 }, seven_day: { utilization: 34 } }), + { + status: 200 + } + ) + ) + vi.mocked(fetchViaPty).mockResolvedValueOnce({ + provider: 'claude', + session: { + usedPercent: 12, + windowMinutes: 300, + resetsAt: null, + resetDescription: null + }, + weekly: { + usedPercent: 34, + windowMinutes: 10080, + resetsAt: null, + resetDescription: null + }, + fableWeekly: { + usedPercent: 58, + windowMinutes: 10080, + resetsAt: null, + resetDescription: '3d' + }, + updatedAt: 1, + error: null, + status: 'ok' + }) + + const result = await fetchManagedAccountUsage( + { id: 'account-1', managedAuthPath: ownedAuthPath }, + { allowUsagePanelSupplement: true } + ) + + const stagedCredentialsJson = vi.mocked(writeActiveClaudeKeychainCredentials).mock.calls[0]?.[0] + expect(result.fableWeekly).toMatchObject({ usedPercent: 58, resetDescription: '3d' }) + expect(JSON.parse(stagedCredentialsJson ?? '{}')).toMatchObject({ + claudeAiOauth: { + accessToken: 'fresh-access', + refreshToken: 'fresh-refresh' + } + }) + expect(writeManagedClaudeKeychainCredentials).toHaveBeenCalledWith( + 'account-1', + stagedCredentialsJson + ) + }) + + it('does not merge macOS inactive Fable preview when usage windows belong to another account', async () => { + setPlatform('darwin') + tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-')) + appGetPathMock.mockReturnValue(tempDir) + const ownedAuthPath = join(tempDir, 'claude-accounts', 'account-1', 'auth') + mkdirSync(ownedAuthPath, { recursive: true }) + writeFileSync(join(ownedAuthPath, '.orca-managed-claude-auth'), 'account-1\n', 'utf-8') + vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { + accessToken: 'managed-keychain-token', + expiresAt: Date.now() + 60_000 + } + }) + ) + vi.mocked(fetchViaPty).mockResolvedValueOnce({ + provider: 'claude', + session: { + usedPercent: 91, + windowMinutes: 300, + resetsAt: null, + resetDescription: null + }, + weekly: { + usedPercent: 3, + windowMinutes: 10080, + resetsAt: null, + resetDescription: null + }, + fableWeekly: { + usedPercent: 58, + windowMinutes: 10080, + resetsAt: null, + resetDescription: '3d' + }, + updatedAt: 1, + error: null, + status: 'ok' + }) + + const result = await fetchManagedAccountUsage( + { id: 'account-1', managedAuthPath: ownedAuthPath }, + { allowUsagePanelSupplement: true } + ) + + expect(result.fableWeekly).toBeNull() + }) + it('refreshes and persists an expiring inactive account before fetching usage', async () => { setPlatform('linux') tempDir = mkdtempSync(join(tmpdir(), 'orca-claude-fetcher-')) diff --git a/src/main/rate-limits/claude-fetcher.ts b/src/main/rate-limits/claude-fetcher.ts index 5f283e58ec8..b546d21b83e 100644 --- a/src/main/rate-limits/claude-fetcher.ts +++ b/src/main/rate-limits/claude-fetcher.ts @@ -17,9 +17,11 @@ import { parseWslUncPath } from '../../shared/wsl-paths' import { fetchViaPty } from './claude-pty' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' import { + deleteActiveClaudeKeychainCredentialsStrict, readActiveClaudeKeychainCredentials, readActiveClaudeKeychainCredentialsStrict, - readManagedClaudeKeychainCredentials + readManagedClaudeKeychainCredentials, + writeActiveClaudeKeychainCredentials } from '../claude-accounts/keychain' import { readClaudeManagedAuthFile, @@ -885,7 +887,7 @@ export type InactiveClaudeAccountInfo = { } type ManagedCredentialsLocation = - | { kind: 'keychain'; accountId: string } + | { kind: 'keychain'; accountId: string; managedAuthPath: string } | { kind: 'file'; managedAuthPath: string } // Why: resolves where an inactive account's credentials live without @@ -907,7 +909,7 @@ function resolveManagedCredentialsLocation( // macOS stores host managed credentials in the Keychain; everything else // (and WSL, handled above) stores them as a file under the managed dir. if (process.platform === 'darwin') { - return { kind: 'keychain', accountId: account.id } + return { kind: 'keychain', accountId: account.id, managedAuthPath } } return { kind: 'file', managedAuthPath } } @@ -966,12 +968,127 @@ function resolveOwnedWslClaudeManagedAuthPath(account: InactiveClaudeAccountInfo } } -export async function fetchManagedAccountUsage( +function getManagedUsagePanelAuthPreparation( + account: InactiveClaudeAccountInfo, + location: ManagedCredentialsLocation +): ClaudeRuntimeAuthPreparation | null { + if (process.platform === 'win32') { + return null + } + if (account.managedAuthRuntime === 'wsl') { + if (!account.wslLinuxAuthPath || !account.wslDistro) { + return null + } + return { + configDir: location.managedAuthPath, + runtime: 'wsl', + wslDistro: account.wslDistro, + wslLinuxConfigDir: account.wslLinuxAuthPath, + envPatch: { CLAUDE_CONFIG_DIR: account.wslLinuxAuthPath }, + stripAuthEnv: true, + provenance: `managed:${account.id}:inactive-preview` + } + } + return { + configDir: location.managedAuthPath, + runtime: 'host', + wslDistro: null, + wslLinuxConfigDir: null, + envPatch: { CLAUDE_CONFIG_DIR: location.managedAuthPath }, + stripAuthEnv: true, + provenance: `managed:${account.id}:inactive-preview` + } +} + +function windowsAgree(left: RateLimitWindow | null, right: RateLimitWindow | null): boolean { + return Boolean(left && right && Math.abs(left.usedPercent - right.usedPercent) <= 1) +} + +function canTrustManagedUsagePanelSupplement( + oauthLimits: ProviderRateLimits, + cliLimits: ProviderRateLimits, + options: { requireMatchingOAuthWindow: boolean } +): boolean { + if (!options.requireMatchingOAuthWindow) { + return true + } + const sharedWindowMatches = [ + oauthLimits.session && cliLimits.session + ? windowsAgree(oauthLimits.session, cliLimits.session) + : null, + oauthLimits.weekly && cliLimits.weekly + ? windowsAgree(oauthLimits.weekly, cliLimits.weekly) + : null + ].filter((match): match is boolean => match !== null) + // Why: macOS inactive previews temporarily stage managed credentials in a + // scoped Keychain item. If an older Claude build ignores scoped Keychains, + // matching OAuth windows prevent active-account Fable data from leaking in. + return sharedWindowMatches.length > 0 && sharedWindowMatches.every(Boolean) +} + +async function withManagedPreviewKeychainCredentials( + location: ManagedCredentialsLocation, + credentialsJson: string, + fn: () => Promise +): Promise { + if (location.kind !== 'keychain') { + return fn() + } + await writeActiveClaudeKeychainCredentials(credentialsJson, location.managedAuthPath) + try { + return await fn() + } finally { + await deleteActiveClaudeKeychainCredentialsStrict(location.managedAuthPath).catch(() => {}) + } +} + +async function readStagedManagedPreviewCredentials( + location: ManagedCredentialsLocation +): Promise { + if (location.kind !== 'keychain') { + return null + } + try { + return await readActiveClaudeKeychainCredentialsStrict(location.managedAuthPath) + } catch { + return null + } +} + +async function fetchManagedUsagePanelSupplement(input: { account: InactiveClaudeAccountInfo + location: ManagedCredentialsLocation + credentialsJson: string + oauthLimits: ProviderRateLimits +}): Promise { + const authPreparation = getManagedUsagePanelAuthPreparation(input.account, input.location) + if (!authPreparation) { + return null + } + return withManagedPreviewKeychainCredentials(input.location, input.credentialsJson, async () => { + const cliLimits = await fetchViaPty({ authPreparation }) + if ( + !canTrustManagedUsagePanelSupplement(input.oauthLimits, cliLimits, { + requireMatchingOAuthWindow: input.location.kind === 'keychain' + }) + ) { + return null + } + const refreshedCredentials = await readStagedManagedPreviewCredentials(input.location) + if (refreshedCredentials && refreshedCredentials !== input.credentialsJson) { + await writeManagedCredentialsJson(input.location, refreshedCredentials) + } + return cliLimits + }) +} + +export async function fetchManagedAccountUsage( + account: InactiveClaudeAccountInfo, + options: { allowUsagePanelSupplement?: boolean } = {} ): Promise { const location = resolveManagedCredentialsLocation(account) - const credentialsJson = location ? await readManagedCredentialsJson(location) : null - if (!credentialsJson) { + let credentialsJson = location ? await readManagedCredentialsJson(location) : null + if (!location || !credentialsJson) { return { provider: 'claude', session: null, @@ -988,7 +1105,7 @@ export async function fetchManagedAccountUsage( // single-use refresh tokens fresh so a later switch-in never materializes a // stale token. Persistence failure is non-fatal: we still try the fetch. let token = parseOAuthCredentialsJson(credentialsJson, 'credentials-file').token - if (location && isOauthTokenExpiring(credentialsJson)) { + if (isOauthTokenExpiring(credentialsJson)) { const refreshed = await refreshClaudeOauthCredentials(credentialsJson) if (refreshed) { try { @@ -997,6 +1114,7 @@ export async function fetchManagedAccountUsage( // Keep going with the refreshed token in memory even if the write // failed; worst case the next poll refreshes again. } + credentialsJson = refreshed token = parseOAuthCredentialsJson(refreshed, 'credentials-file').token } } @@ -1013,7 +1131,32 @@ export async function fetchManagedAccountUsage( } // Why: PTY fallback is intentionally omitted for inactive accounts. The PTY - // path materializes credentials via ClaudeRuntimeAuthService, which would - // interfere with the active account's auth state. - return fetchViaOAuth(token) + // path is used only as a supplement after OAuth succeeds, and it points + // directly at the managed account's isolated config so selection is unchanged. + const oauthLimits = await fetchViaOAuth(token) + if ( + !canSupplementOAuthUsageFromCli({ + oauthLimits, + authPreparation: undefined, + allowUsagePanelSupplement: options.allowUsagePanelSupplement === true + }) + ) { + return oauthLimits + } + try { + const cliLimits = await fetchManagedUsagePanelSupplement({ + account, + location, + credentialsJson, + oauthLimits + }) + return mergeClaudeUsageWindows(oauthLimits, cliLimits) + } catch (err) { + warnClaudeUsageFetchFailure( + undefined, + parseOAuthCredentialsJson(credentialsJson, 'credentials-file'), + err + ) + return oauthLimits + } } diff --git a/src/main/rate-limits/service.test.ts b/src/main/rate-limits/service.test.ts index c5c389bd0e3..5af9abca04f 100644 --- a/src/main/rate-limits/service.test.ts +++ b/src/main/rate-limits/service.test.ts @@ -621,6 +621,19 @@ describe('RateLimitService', () => { ]) }) + it('allows usage-panel Fable supplements for inactive Claude account previews', async () => { + const service = new RateLimitService() + const account = { id: 'account-1', managedAuthPath: '/tmp/account-1/auth' } + service.setInactiveClaudeAccountsResolver(() => [account]) + vi.mocked(fetchManagedAccountUsage).mockResolvedValueOnce(okProvider('claude', 33, Date.now())) + + await service.fetchInactiveClaudeAccountsOnOpen() + + expect(fetchManagedAccountUsage).toHaveBeenCalledWith(account, { + allowUsagePanelSupplement: true + }) + }) + it('does not start overlapping inactive Codex preview fetches', async () => { const service = new RateLimitService() const accountFetch = deferred() diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index af7431bcad6..5225485ee27 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -382,7 +382,9 @@ export class RateLimitService { continue } try { - const fresh = await fetchManagedAccountUsage(account) + const fresh = await fetchManagedAccountUsage(account, { + allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement() + }) if ( fetchGeneration !== this.inactiveClaudeAccountsGeneration || !this.isCurrentInactiveClaudeAccount(account.id) diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index eb43c2c5625..d08ffca7a06 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -901,70 +901,63 @@ function MiniBar({ leftPct }: { leftPct: number }): React.JSX.Element { // Inline usage bars (compact bars for inactive accounts in the switcher) // --------------------------------------------------------------------------- -function InlineUsageBars({ +export function InlineUsageBars({ limits, isFetching }: { limits: ProviderRateLimits isFetching: boolean }): React.JSX.Element { - const sessionLeft = limits.session - ? Math.max(0, Math.round(100 - limits.session.usedPercent)) - : null - const weeklyLeft = limits.weekly ? Math.max(0, Math.round(100 - limits.weekly.usedPercent)) : null - const fableLeft = limits.fableWeekly - ? Math.max(0, Math.round(100 - limits.fableWeekly.usedPercent)) - : null + const usageWindows = [ + limits.session + ? { + key: 'session', + left: Math.max(0, Math.round(100 - limits.session.usedPercent)), + label: translate('auto.components.status.bar.StatusBar.d79c3362c4', '% 5h') + } + : null, + limits.weekly + ? { + key: 'weekly', + left: Math.max(0, Math.round(100 - limits.weekly.usedPercent)), + label: translate('auto.components.status.bar.StatusBar.5c938d39ac', '% wk') + } + : null, + limits.fableWeekly + ? { + key: 'fableWeekly', + left: Math.max(0, Math.round(100 - limits.fableWeekly.usedPercent)), + label: translate('auto.components.status.bar.StatusBar.54e8d6bb2d', '% Fable') + } + : null + ].filter((window): window is { key: string; left: number; label: string } => window !== null) return ( -
- {sessionLeft !== null && ( -
-
+
+ {usageWindows.map((window) => ( +
+
- - {sessionLeft} - {translate('auto.components.status.bar.StatusBar.d79c3362c4', '% 5h')} + + {window.left} + {window.label}
- )} - {weeklyLeft !== null && ( -
-
-
-
- - {weeklyLeft} - {translate('auto.components.status.bar.StatusBar.5c938d39ac', '% wk')} - -
- )} - {fableLeft !== null && ( -
-
-
-
- - {fableLeft} - {translate('auto.components.status.bar.StatusBar.54e8d6bb2d', '% Fable')} - -
- )} - {limits.status === 'error' && !limits.session && !limits.weekly && !limits.fableWeekly && ( + ))} + {usageWindows.length === 0 && limits.status === 'error' ? ( {translate('auto.components.status.bar.StatusBar.f19a63e7cd', 'Sign in to see usage')} - )} + ) : null}
) } 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 new file mode 100644 index 00000000000..0160978beb7 --- /dev/null +++ b/src/renderer/src/components/status-bar/inline-usage-bars.test.tsx @@ -0,0 +1,53 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { 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 +})) + +vi.mock('@/lib/agent-catalog', () => ({ + AgentIcon: () => null +})) + +function claudeLimits(): ProviderRateLimits { + return { + provider: 'claude', + session: { + usedPercent: 32, + windowMinutes: 300, + resetsAt: null, + resetDescription: null + }, + weekly: { + usedPercent: 16, + windowMinutes: 10080, + resetsAt: null, + resetDescription: null + }, + fableWeekly: { + usedPercent: 42, + windowMinutes: 10080, + resetsAt: null, + resetDescription: null + }, + updatedAt: Date.now(), + error: null, + status: 'ok' + } +} + +describe('InlineUsageBars', () => { + it('renders Claude Fable usage in inactive account preview rows', async () => { + const { InlineUsageBars } = await import('./StatusBar') + + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('68% 5h') + expect(markup).toContain('84% wk') + expect(markup).toContain('58% Fable') + }) +}) From 0fd2d67c699a6a221196110e184c4da3ec81864d Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:23:18 -0700 Subject: [PATCH 12/48] Allow manual sorting of Project Groups (#7191) --- .../src/components/sidebar/WorktreeList.tsx | 104 +++++- .../sidebar/project-group-header-dom.test.ts | 25 ++ .../project-group-header-drag-commit.test.ts | 111 +++++++ .../project-group-header-drag-commit.ts | 50 +++ .../project-group-header-drag-contract.ts | 77 +++++ .../project-group-header-drag-start.ts | 64 ++++ .../sidebar/project-group-header-drag.ts | 304 ++++++++++++++++++ .../sidebar/project-group-header-drop.test.ts | 169 ++++++++++ .../sidebar/project-group-header-drop.ts | 210 ++++++++++++ .../sidebar/worktree-list-groups.test.ts | 61 ++++ 10 files changed, 1172 insertions(+), 3 deletions(-) create mode 100644 src/renderer/src/components/sidebar/project-group-header-dom.test.ts create mode 100644 src/renderer/src/components/sidebar/project-group-header-drag-commit.test.ts create mode 100644 src/renderer/src/components/sidebar/project-group-header-drag-commit.ts create mode 100644 src/renderer/src/components/sidebar/project-group-header-drag-contract.ts create mode 100644 src/renderer/src/components/sidebar/project-group-header-drag-start.ts create mode 100644 src/renderer/src/components/sidebar/project-group-header-drag.ts create mode 100644 src/renderer/src/components/sidebar/project-group-header-drop.test.ts create mode 100644 src/renderer/src/components/sidebar/project-group-header-drop.ts diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 2296df9064b..2e405dcfb2d 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -135,6 +135,8 @@ import { } from '@/lib/scroll-to-current-workspace-status' import { isRepoHeaderActionTarget, useRepoHeaderDrag } from './project-header-drag' import { getSidebarOrderedRepoHeaderIdsByBucket } from './project-header-drop' +import { useProjectGroupHeaderDrag } from './project-group-header-drag' +import { getSidebarOrderedProjectGroupHeaderIdsByBucket } from './project-group-header-drop' import { buildManualOrderUpdatesForGroupDrop, buildManualOrderUpdatesForVisibleGroups, @@ -1377,7 +1379,9 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp const suppressWorktreeClickUntilRef = useRef(0) const hasProjectGroups = projectGroups.length > 0 const canReorderRepoHeaders = groupBy === 'repo' && projectOrderBy === 'manual' + const canReorderProjectGroupHeaders = groupBy === 'repo' && hasProjectGroups const moveProjectToGroup = useAppStore((s) => s.moveProjectToGroup) + const updateProjectGroup = useAppStore((s) => s.updateProjectGroup) const lastVisibleRefreshKeyRef = useRef('') const reportVisibleGitHubPRRefreshCandidates = useAppStore( (s) => s.reportVisibleGitHubPRRefreshCandidates @@ -1399,6 +1403,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp ), [projectGroups] ) + const projectGroupByIdForHeaderDrag = useMemo( + () => new Map(projectGroups.map((group) => [group.id, group])), + [projectGroups] + ) useEffect( () => @@ -1567,6 +1575,14 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp ), [rows] ) + const sidebarProjectGroupHeaderIdsByBucket = useMemo( + () => + getSidebarOrderedProjectGroupHeaderIdsByBucket( + rows.filter((row): row is Row => row.type !== 'host-header'), + projectGroupByIdForHeaderDrag + ), + [projectGroupByIdForHeaderDrag, rows] + ) const repoHeaderIndexByRepoId = useMemo(() => { const map = new Map() for (const repoIds of sidebarRepoHeaderIdsByBucket.values()) { @@ -1585,12 +1601,43 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp } return map }, [sidebarRepoHeaderIdsByBucket]) + const projectGroupHeaderIndexByGroupId = useMemo(() => { + const map = new Map() + for (const groupIds of sidebarProjectGroupHeaderIdsByBucket.values()) { + groupIds.forEach((groupId, index) => { + map.set(groupId, index) + }) + } + return map + }, [sidebarProjectGroupHeaderIdsByBucket]) + const projectGroupHeaderBucketByGroupId = useMemo(() => { + const map = new Map() + for (const [bucketKey, groupIds] of sidebarProjectGroupHeaderIdsByBucket) { + for (const groupId of groupIds) { + map.set(groupId, bucketKey) + } + } + return map + }, [sidebarProjectGroupHeaderIdsByBucket]) const commitProjectGroupOrder = useCallback( (repoId: string, projectGroupId: string | null, order: number) => { void moveProjectToGroup(repoId, projectGroupId, order) }, [moveProjectToGroup] ) + const commitProjectGroupHeaderOrder = useCallback( + (groupId: string, tabOrder: number) => { + if (!Number.isFinite(tabOrder)) { + return + } + const suppressUntil = + window.performance.now() + USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS + suppressMeasurementAdjustmentUntilRef.current = suppressUntil + directScrollInputUntilRef.current = suppressUntil + void updateProjectGroup(groupId, { tabOrder }) + }, + [updateProjectGroup] + ) // Drag is only meaningful when repo headers are using manual order. The // controller is still constructed for hook order stability when inert. const repoDrag = useRepoHeaderDrag({ @@ -1602,6 +1649,12 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp onCommitProjectGroupOrder: commitProjectGroupOrder, getScrollContainer: () => scrollRef.current }) + const projectGroupDrag = useProjectGroupHeaderDrag({ + sidebarProjectGroupHeaderIdsByBucket, + projectGroupById: projectGroupByIdForHeaderDrag, + onCommitProjectGroupTabOrder: commitProjectGroupHeaderOrder, + getScrollContainer: () => scrollRef.current + }) const [primaryActiveWorktreeRow, setPrimaryActiveWorktreeRow] = useState<{ worktreeId: string rowKey: string @@ -3909,6 +3962,15 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp style={{ top: `${repoDrag.state.dropIndicatorY}px` }} /> ) : null} + {canReorderProjectGroupHeaders && + projectGroupDrag.state.draggingGroupId !== null && + projectGroupDrag.state.dropIndicatorY !== null ? ( +
+ ) : null} {hostDrag.state.draggingHostId !== null && hostDrag.state.dropIndicatorY !== null ? (
1 ) + const isDraggableProjectGroupHeader = Boolean( + canReorderProjectGroupHeaders && + projectGroupIdForHeader && + projectGroupHeaderBucketKey && + (sidebarProjectGroupHeaderIdsByBucket.get(projectGroupHeaderBucketKey)?.length ?? + 0) > 1 + ) const isDraggingThis = canReorderRepoHeaders && repoDrag.state.draggingRepoId !== null && repoDrag.state.draggingRepoId === projectIdForHeader + const isDraggingThisProjectGroup = + canReorderProjectGroupHeaders && + projectGroupDrag.state.draggingGroupId !== null && + projectGroupDrag.state.draggingGroupId === projectGroupIdForHeader const headerWorkspaceStatus = groupBy === 'workspace-status' ? getWorkspaceStatusFromGroupKey(row.key, workspaceStatuses) @@ -4100,6 +4185,9 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp data-repo-header-id={projectIdForHeader} data-repo-header-index={repoHeaderIndex} data-repo-header-bucket={repoHeaderBucketKey} + data-project-group-header-id={projectGroupIdForHeader} + data-project-group-header-index={projectGroupHeaderIndex} + data-project-group-header-bucket={projectGroupHeaderBucketKey} data-workspace-status-drop-target={headerWorkspaceStatus ? '' : undefined} data-workspace-status={headerWorkspaceStatus ?? undefined} data-workspace-pin-drop-target={isPinnedHeader ? '' : undefined} @@ -4108,7 +4196,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp 'cursor-pointer', highlightedRevealRowKey === row.key && 'rounded-md bg-worktree-sidebar-accent ring-1 ring-worktree-sidebar-ring/50', - isDraggingThis && + (isDraggingThis || isDraggingThisProjectGroup) && 'bg-accent/80 ring-1 ring-ring/40 shadow-md rounded-md scale-[1.01]', headerWorkspaceStatus && dragOverStatus === headerWorkspaceStatus && @@ -4157,15 +4245,25 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp {row.icon ? (
repoDrag.onHandlePointerDown(event, projectIdForHeader) - : undefined + : isDraggableProjectGroupHeader && projectGroupIdForHeader + ? (event) => + projectGroupDrag.onHandlePointerDown( + event, + projectGroupIdForHeader + ) + : undefined } > {row.repo ? ( diff --git a/src/renderer/src/components/sidebar/project-group-header-dom.test.ts b/src/renderer/src/components/sidebar/project-group-header-dom.test.ts new file mode 100644 index 00000000000..266c85c4893 --- /dev/null +++ b/src/renderer/src/components/sidebar/project-group-header-dom.test.ts @@ -0,0 +1,25 @@ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +function readWorktreeListSource(): string { + return readFileSync(fileURLToPath(new URL('./WorktreeList.tsx', import.meta.url)), 'utf8') +} + +describe('Project Group header drag DOM source', () => { + it('renders concrete Project Group header drag attributes separately from repo headers', () => { + const source = readWorktreeListSource() + + expect(source).toContain('data-project-group-header-id={projectGroupIdForHeader}') + expect(source).toContain('data-project-group-header-index={projectGroupHeaderIndex}') + expect(source).toContain('data-project-group-header-bucket={projectGroupHeaderBucketKey}') + expect(source).toContain('data-project-group-header-drag-handle=') + }) + + it('commits Project Group manual sorting through updateProjectGroup tabOrder', () => { + const source = readWorktreeListSource() + + expect(source).toContain('const updateProjectGroup = useAppStore((s) => s.updateProjectGroup)') + expect(source).toContain('void updateProjectGroup(groupId, { tabOrder })') + }) +}) diff --git a/src/renderer/src/components/sidebar/project-group-header-drag-commit.test.ts b/src/renderer/src/components/sidebar/project-group-header-drag-commit.test.ts new file mode 100644 index 00000000000..ef873d9bf02 --- /dev/null +++ b/src/renderer/src/components/sidebar/project-group-header-drag-commit.test.ts @@ -0,0 +1,111 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi } from 'vitest' + +import { commitProjectGroupHeaderDragDrop } from './project-group-header-drag-commit' +import type { ProjectGroupHeaderDragSession } from './project-group-header-drag-contract' +import type { ProjectGroup } from '../../../../shared/types' + +function group(id: string, overrides: Partial = {}): ProjectGroup { + return { + id, + name: id, + parentPath: null, + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +function makeSession( + groupId: string, + sidebarProjectGroupHeaderIds: readonly string[] +): ProjectGroupHeaderDragSession { + return { + groupId, + bucketKey: 'root', + sidebarProjectGroupHeaderIds, + pointerId: 1, + headerRects: [], + handleEl: document.createElement('div'), + startX: 0, + startY: 0, + latestPointerY: 0, + promoted: true + } +} + +describe('commitProjectGroupHeaderDragDrop', () => { + it('commits a finite tabOrder for the dragged Project Group', () => { + const onCommitProjectGroupTabOrder = vi.fn() + const groups = [ + group('a', { tabOrder: 0 }), + group('b', { tabOrder: 10 }), + group('c', { tabOrder: 20 }) + ] + const projectGroupById = new Map(groups.map((entry) => [entry.id, entry])) + + commitProjectGroupHeaderDragDrop({ + session: makeSession('c', ['a', 'b', 'c']), + sidebarDropIndex: 0, + projectGroupById, + onCommitProjectGroupTabOrder + }) + + expect(onCommitProjectGroupTabOrder).toHaveBeenCalledWith('c', -1) + }) + + it('computes order only from the captured sibling bucket', () => { + const onCommitProjectGroupTabOrder = vi.fn() + const root = group('root') + const siblingA = group('sibling-a', { parentGroupId: root.id, tabOrder: 0 }) + const siblingB = group('sibling-b', { parentGroupId: root.id, tabOrder: 10 }) + const otherParentGroup = group('other-parent-group', { tabOrder: -100 }) + const projectGroupById = new Map( + [root, siblingA, siblingB, otherParentGroup].map((entry) => [entry.id, entry]) + ) + + commitProjectGroupHeaderDragDrop({ + session: makeSession('sibling-b', ['sibling-a', 'sibling-b']), + sidebarDropIndex: 0, + projectGroupById, + onCommitProjectGroupTabOrder + }) + + expect(onCommitProjectGroupTabOrder).toHaveBeenCalledWith('sibling-b', -1) + }) + + it('does not commit when the drop keeps the group in the same slot', () => { + const onCommitProjectGroupTabOrder = vi.fn() + const groups = [group('a'), group('b'), group('c')] + const projectGroupById = new Map(groups.map((entry) => [entry.id, entry])) + + commitProjectGroupHeaderDragDrop({ + session: makeSession('b', ['a', 'b', 'c']), + sidebarDropIndex: 2, + projectGroupById, + onCommitProjectGroupTabOrder + }) + + expect(onCommitProjectGroupTabOrder).not.toHaveBeenCalled() + }) + + it('does not commit when a stale session no longer contains the dragged group', () => { + const onCommitProjectGroupTabOrder = vi.fn() + const groups = [group('a'), group('b'), group('c')] + const projectGroupById = new Map(groups.map((entry) => [entry.id, entry])) + + commitProjectGroupHeaderDragDrop({ + session: makeSession('c', ['a', 'b']), + sidebarDropIndex: 0, + projectGroupById, + onCommitProjectGroupTabOrder + }) + + expect(onCommitProjectGroupTabOrder).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/sidebar/project-group-header-drag-commit.ts b/src/renderer/src/components/sidebar/project-group-header-drag-commit.ts new file mode 100644 index 00000000000..3ef85959577 --- /dev/null +++ b/src/renderer/src/components/sidebar/project-group-header-drag-commit.ts @@ -0,0 +1,50 @@ +import { + getProjectGroupTabOrderForSidebarDrop, + mapSidebarProjectGroupDropIndexToSiblingInsertIndex +} from './project-group-header-drop' +import type { ProjectGroupHeaderDragSession } from './project-group-header-drag-contract' +import type { ProjectGroup } from '../../../../shared/types' + +export function commitProjectGroupHeaderDragDrop(args: { + session: ProjectGroupHeaderDragSession + sidebarDropIndex: number + projectGroupById: ReadonlyMap + onCommitProjectGroupTabOrder: (groupId: string, tabOrder: number) => void +}): void { + const draggedGroup = args.projectGroupById.get(args.session.groupId) + if (!draggedGroup) { + return + } + + const sidebarProjectGroupHeaderIds = args.session.sidebarProjectGroupHeaderIds + const sourceIndex = sidebarProjectGroupHeaderIds.indexOf(args.session.groupId) + if (sourceIndex === -1) { + return + } + if (args.sidebarDropIndex === sourceIndex) { + return + } + + const siblings = sidebarProjectGroupHeaderIds + .filter((groupId) => groupId !== args.session.groupId) + .map((groupId) => args.projectGroupById.get(groupId)) + .filter((group): group is ProjectGroup => group !== undefined) + const siblingDropIndex = mapSidebarProjectGroupDropIndexToSiblingInsertIndex({ + sidebarDropIndex: args.sidebarDropIndex, + sourceIndex, + siblingCount: siblings.length + }) + const sourceIndexInSiblings = Math.min(sourceIndex, siblings.length) + if (siblingDropIndex === sourceIndexInSiblings) { + return + } + + const tabOrder = getProjectGroupTabOrderForSidebarDrop({ + siblings, + dropIndex: siblingDropIndex + }) + if (!Number.isFinite(tabOrder)) { + return + } + args.onCommitProjectGroupTabOrder(draggedGroup.id, tabOrder) +} diff --git a/src/renderer/src/components/sidebar/project-group-header-drag-contract.ts b/src/renderer/src/components/sidebar/project-group-header-drag-contract.ts new file mode 100644 index 00000000000..7127ce6bd8c --- /dev/null +++ b/src/renderer/src/components/sidebar/project-group-header-drag-contract.ts @@ -0,0 +1,77 @@ +import type { PointerEvent } from 'react' + +import type { + ProjectGroupHeaderDragBucketKey, + ProjectGroupHeaderDragRect +} from './project-group-header-drop' +import type { ProjectGroup } from '../../../../shared/types' + +export type ProjectGroupDragState = { + draggingGroupId: string | null + dropIndex: number | null + dropIndicatorY: number | null +} + +export const INITIAL_PROJECT_GROUP_DRAG_STATE: ProjectGroupDragState = { + draggingGroupId: null, + dropIndex: null, + dropIndicatorY: null +} + +export type UseProjectGroupHeaderDragArgs = { + sidebarProjectGroupHeaderIdsByBucket: ReadonlyMap< + ProjectGroupHeaderDragBucketKey, + readonly string[] + > + projectGroupById: ReadonlyMap + onCommitProjectGroupTabOrder: (groupId: string, tabOrder: number) => void + getScrollContainer: () => HTMLElement | null +} + +export type ProjectGroupHeaderDragController = { + state: ProjectGroupDragState + onHandlePointerDown: (event: PointerEvent, groupId: string) => void +} + +export type ProjectGroupHeaderDragSession = { + groupId: string + bucketKey: ProjectGroupHeaderDragBucketKey + sidebarProjectGroupHeaderIds: readonly string[] + pointerId: number + headerRects: ProjectGroupHeaderDragRect[] + handleEl: HTMLElement + startX: number + startY: number + latestPointerY: number + promoted: boolean +} + +export const PROJECT_GROUP_HEADER_DRAG_THRESHOLD_PX = 4 + +const PROJECT_GROUP_HEADER_DRAG_HANDLE_SELECTOR = '[data-project-group-header-drag-handle]' + +const PROJECT_GROUP_HEADER_ACTION_SELECTOR = + '[data-repo-header-action], [data-repo-header-collapse-affordance], button, a, input, textarea, select, [contenteditable=""], [contenteditable="true"]' + +export function isProjectGroupHeaderDragHandleTarget( + target: EventTarget | null, + currentTarget: HTMLElement +): boolean { + if (!(target instanceof HTMLElement)) { + return false + } + const dragHandle = target.closest(PROJECT_GROUP_HEADER_DRAG_HANDLE_SELECTOR) + return dragHandle !== null && currentTarget.contains(dragHandle) +} + +export function isProjectGroupHeaderActionTarget( + target: EventTarget | null, + currentTarget: HTMLElement +): boolean { + if (!(target instanceof HTMLElement) || target === currentTarget) { + return false + } + return ( + currentTarget.contains(target) && target.closest(PROJECT_GROUP_HEADER_ACTION_SELECTOR) !== null + ) +} diff --git a/src/renderer/src/components/sidebar/project-group-header-drag-start.ts b/src/renderer/src/components/sidebar/project-group-header-drag-start.ts new file mode 100644 index 00000000000..89eb5bd9dd0 --- /dev/null +++ b/src/renderer/src/components/sidebar/project-group-header-drag-start.ts @@ -0,0 +1,64 @@ +import type { PointerEvent } from 'react' + +import { + getProjectGroupHeaderDragBucketKey, + measureProjectGroupHeaderDragRects, + type ProjectGroupHeaderDragBucketKey +} from './project-group-header-drop' +import { + isProjectGroupHeaderActionTarget, + isProjectGroupHeaderDragHandleTarget, + type ProjectGroupHeaderDragSession +} from './project-group-header-drag-contract' +import type { ProjectGroup } from '../../../../shared/types' + +export function createProjectGroupHeaderDragSession(args: { + event: PointerEvent + groupId: string + projectGroupById: ReadonlyMap + sidebarProjectGroupHeaderIdsByBucket: ReadonlyMap< + ProjectGroupHeaderDragBucketKey, + readonly string[] + > + getScrollContainer: () => HTMLElement | null +}): ProjectGroupHeaderDragSession | null { + if (args.event.button !== 0) { + return null + } + if (!isProjectGroupHeaderDragHandleTarget(args.event.target, args.event.currentTarget)) { + return null + } + if (isProjectGroupHeaderActionTarget(args.event.target, args.event.currentTarget)) { + return null + } + const group = args.projectGroupById.get(args.groupId) + if (!group) { + return null + } + const bucketKey = getProjectGroupHeaderDragBucketKey(group, args.projectGroupById) + const sidebarProjectGroupHeaderIds = + args.sidebarProjectGroupHeaderIdsByBucket.get(bucketKey) ?? [] + // Why: a lone group in a parent bucket cannot move without reparenting. + if (sidebarProjectGroupHeaderIds.length <= 1) { + return null + } + const container = args.getScrollContainer() + if (!container) { + return null + } + const handleEl = args.event.currentTarget + // Why: defer pointer capture until the threshold so ordinary group header + // clicks still toggle collapse through the row handler. + return { + groupId: args.groupId, + bucketKey, + sidebarProjectGroupHeaderIds, + pointerId: args.event.pointerId, + headerRects: measureProjectGroupHeaderDragRects(container, bucketKey), + handleEl, + startX: args.event.clientX, + startY: args.event.clientY, + latestPointerY: args.event.clientY, + promoted: false + } +} diff --git a/src/renderer/src/components/sidebar/project-group-header-drag.ts b/src/renderer/src/components/sidebar/project-group-header-drag.ts new file mode 100644 index 00000000000..6896dff069f --- /dev/null +++ b/src/renderer/src/components/sidebar/project-group-header-drag.ts @@ -0,0 +1,304 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +import { + computeProjectGroupHeaderDropPreview, + measureProjectGroupHeaderDragRects +} from './project-group-header-drop' +import { commitProjectGroupHeaderDragDrop } from './project-group-header-drag-commit' +import { + INITIAL_PROJECT_GROUP_DRAG_STATE, + PROJECT_GROUP_HEADER_DRAG_THRESHOLD_PX, + type ProjectGroupDragState, + type ProjectGroupHeaderDragController, + type ProjectGroupHeaderDragSession, + type UseProjectGroupHeaderDragArgs +} from './project-group-header-drag-contract' +import { createProjectGroupHeaderDragSession } from './project-group-header-drag-start' +import { getWorktreeSidebarDragAutoscroll } from './worktree-sidebar-drag-autoscroll' + +// Why pointer events instead of HTML5 DnD: Project Group rows are virtualized +// and may unmount while scrolling; cached row-model indices keep drops stable. + +export function useProjectGroupHeaderDrag({ + sidebarProjectGroupHeaderIdsByBucket, + projectGroupById, + onCommitProjectGroupTabOrder, + getScrollContainer +}: UseProjectGroupHeaderDragArgs): ProjectGroupHeaderDragController { + const [state, setState] = useState(INITIAL_PROJECT_GROUP_DRAG_STATE) + const [sessionArmed, setSessionArmed] = useState(false) + const latestDropIndexRef = useRef(null) + latestDropIndexRef.current = state.dropIndex + const sidebarProjectGroupHeaderIdsByBucketRef = useRef(sidebarProjectGroupHeaderIdsByBucket) + sidebarProjectGroupHeaderIdsByBucketRef.current = sidebarProjectGroupHeaderIdsByBucket + const projectGroupByIdRef = useRef(projectGroupById) + projectGroupByIdRef.current = projectGroupById + const onCommitProjectGroupTabOrderRef = useRef(onCommitProjectGroupTabOrder) + onCommitProjectGroupTabOrderRef.current = onCommitProjectGroupTabOrder + const getContainerRef = useRef(getScrollContainer) + getContainerRef.current = getScrollContainer + const autoscrollLastFrameTimeRef = useRef(null) + const autoscrollFrameIdRef = useRef(null) + + const dragSessionRef = useRef(null) + const clickSwallowTimeoutRef = useRef | null>(null) + + const refreshHeaderRects = useCallback(() => { + const container = getContainerRef.current() + const session = dragSessionRef.current + if (!container || !session) { + return [] + } + const rects = measureProjectGroupHeaderDragRects(container, session.bucketKey) + session.headerRects = rects + return rects + }, []) + + const computeDrop = useCallback( + (pointerY: number): { dropIndex: number; dropIndicatorY: number } | null => { + const session = dragSessionRef.current + const container = getContainerRef.current() + if (!session || !container) { + return null + } + const containerRect = container.getBoundingClientRect() + return computeProjectGroupHeaderDropPreview({ + pointerY, + containerTop: containerRect.top, + scrollTop: container.scrollTop, + rects: session.headerRects, + sidebarProjectGroupHeaderIds: session.sidebarProjectGroupHeaderIds + }) + }, + [] + ) + + const cancelAutoscroll = useCallback(() => { + if (autoscrollFrameIdRef.current !== null) { + window.cancelAnimationFrame(autoscrollFrameIdRef.current) + autoscrollFrameIdRef.current = null + } + autoscrollLastFrameTimeRef.current = null + }, []) + + const endDrag = useCallback( + (commit: boolean) => { + cancelAutoscroll() + const session = dragSessionRef.current + if (!session) { + setState(INITIAL_PROJECT_GROUP_DRAG_STATE) + setSessionArmed(false) + return + } + try { + session.handleEl.releasePointerCapture(session.pointerId) + } catch { + // capture may already be released (pointercancel, element unmounted) + } + if (session.promoted) { + const handleEl = session.handleEl + const swallow = (event: MouseEvent): void => { + const target = event.target as Node | null + if (target && handleEl.contains(target)) { + event.stopPropagation() + event.preventDefault() + } + window.removeEventListener('click', swallow, true) + } + window.addEventListener('click', swallow, true) + clickSwallowTimeoutRef.current = setTimeout(() => { + window.removeEventListener('click', swallow, true) + clickSwallowTimeoutRef.current = null + }, 0) + } + const sidebarDropIndex = + commit && session.promoted && latestDropIndexRef.current !== null + ? latestDropIndexRef.current + : null + dragSessionRef.current = null + setState(INITIAL_PROJECT_GROUP_DRAG_STATE) + setSessionArmed(false) + if (sidebarDropIndex === null) { + return + } + + commitProjectGroupHeaderDragDrop({ + session, + sidebarDropIndex, + projectGroupById: projectGroupByIdRef.current, + onCommitProjectGroupTabOrder: onCommitProjectGroupTabOrderRef.current + }) + }, + [cancelAutoscroll] + ) + + const runAutoscrollFrame = useCallback( + (frameTime: number) => { + autoscrollFrameIdRef.current = null + const session = dragSessionRef.current + const container = getContainerRef.current() + if (!session?.promoted || !container) { + cancelAutoscroll() + return + } + + const previousFrameTime = autoscrollLastFrameTimeRef.current ?? frameTime + autoscrollLastFrameTimeRef.current = frameTime + const autoscroll = getWorktreeSidebarDragAutoscroll({ + point: { clientX: 0, clientY: session.latestPointerY }, + containerRect: container.getBoundingClientRect(), + scrollTop: container.scrollTop, + scrollHeight: container.scrollHeight, + clientHeight: container.clientHeight, + elapsedMs: frameTime - previousFrameTime + }) + if (autoscroll) { + container.scrollTop = autoscroll.scrollTop + refreshHeaderRects() + } + + const drop = computeDrop(session.latestPointerY) + if (drop) { + setState((prev) => + prev.dropIndex === drop.dropIndex && prev.dropIndicatorY === drop.dropIndicatorY + ? prev + : { draggingGroupId: session.groupId, ...drop } + ) + } + + autoscrollFrameIdRef.current = window.requestAnimationFrame(runAutoscrollFrame) + }, + [cancelAutoscroll, computeDrop, refreshHeaderRects] + ) + + const ensureAutoscroll = useCallback(() => { + if (autoscrollFrameIdRef.current !== null) { + return + } + autoscrollLastFrameTimeRef.current = null + autoscrollFrameIdRef.current = window.requestAnimationFrame(runAutoscrollFrame) + }, [runAutoscrollFrame]) + + useEffect(() => { + if (!sessionArmed) { + return + } + const onPointerMove = (event: PointerEvent): void => { + const session = dragSessionRef.current + if (!session || event.pointerId !== session.pointerId) { + return + } + session.latestPointerY = event.clientY + if (!session.promoted) { + const dx = event.clientX - session.startX + const dy = event.clientY - session.startY + if ( + dx * dx + dy * dy < + PROJECT_GROUP_HEADER_DRAG_THRESHOLD_PX * PROJECT_GROUP_HEADER_DRAG_THRESHOLD_PX + ) { + return + } + session.promoted = true + // Why: virtualized headers may detach during drag; global listeners + // still keep the operation alive if pointer capture is unavailable. + if (session.handleEl.isConnected) { + try { + session.handleEl.setPointerCapture(session.pointerId) + } catch { + // Ignore capture failure; global listeners will handle the drag. + } + } + refreshHeaderRects() + setState({ draggingGroupId: session.groupId, dropIndex: null, dropIndicatorY: null }) + } + refreshHeaderRects() + const drop = computeDrop(event.clientY) + if (drop) { + setState((prev) => + prev.dropIndex === drop.dropIndex && prev.dropIndicatorY === drop.dropIndicatorY + ? prev + : { draggingGroupId: session.groupId, ...drop } + ) + } + ensureAutoscroll() + } + const onPointerUp = (event: PointerEvent): void => { + const session = dragSessionRef.current + if (!session || event.pointerId !== session.pointerId) { + return + } + endDrag(true) + } + const onPointerCancel = (event: PointerEvent): void => { + const session = dragSessionRef.current + if (!session || event.pointerId !== session.pointerId) { + return + } + endDrag(false) + } + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key === 'Escape') { + endDrag(false) + } + } + const onBlur = (): void => endDrag(false) + + window.addEventListener('pointermove', onPointerMove) + window.addEventListener('pointerup', onPointerUp) + window.addEventListener('pointercancel', onPointerCancel) + window.addEventListener('keydown', onKeyDown) + window.addEventListener('blur', onBlur) + return () => { + window.removeEventListener('pointermove', onPointerMove) + window.removeEventListener('pointerup', onPointerUp) + window.removeEventListener('pointercancel', onPointerCancel) + window.removeEventListener('keydown', onKeyDown) + window.removeEventListener('blur', onBlur) + cancelAutoscroll() + if (clickSwallowTimeoutRef.current !== null) { + clearTimeout(clickSwallowTimeoutRef.current) + clickSwallowTimeoutRef.current = null + } + } + }, [cancelAutoscroll, computeDrop, endDrag, ensureAutoscroll, refreshHeaderRects, sessionArmed]) + + useEffect(() => { + if (state.draggingGroupId === null) { + return + } + const body = document.body + const prevCursor = body.style.cursor + const prevUserSelect = body.style.userSelect + body.style.cursor = 'grabbing' + body.style.userSelect = 'none' + return () => { + body.style.cursor = prevCursor + body.style.userSelect = prevUserSelect + } + }, [state.draggingGroupId]) + + const onHandlePointerDown = useCallback( + (event: React.PointerEvent, groupId: string) => { + const session = createProjectGroupHeaderDragSession({ + event, + groupId, + projectGroupById: projectGroupByIdRef.current, + sidebarProjectGroupHeaderIdsByBucket: sidebarProjectGroupHeaderIdsByBucketRef.current, + getScrollContainer: getContainerRef.current + }) + if (!session) { + return + } + dragSessionRef.current = session + setSessionArmed(true) + }, + [] + ) + + return { state, onHandlePointerDown } +} + +export { + isProjectGroupHeaderActionTarget, + isProjectGroupHeaderDragHandleTarget +} from './project-group-header-drag-contract' diff --git a/src/renderer/src/components/sidebar/project-group-header-drop.test.ts b/src/renderer/src/components/sidebar/project-group-header-drop.test.ts new file mode 100644 index 00000000000..5abf7648677 --- /dev/null +++ b/src/renderer/src/components/sidebar/project-group-header-drop.test.ts @@ -0,0 +1,169 @@ +// @vitest-environment happy-dom +import { describe, expect, it } from 'vitest' + +import { + computeProjectGroupHeaderDropPreview, + getProjectGroupHeaderDragBucketKey, + getProjectGroupTabOrderForSidebarDrop, + getSidebarOrderedProjectGroupHeaderIdsByBucket, + mapSidebarProjectGroupDropIndexToSiblingInsertIndex +} from './project-group-header-drop' +import type { Row } from './worktree-list-groups' +import type { ProjectGroup, Repo } from '../../../../shared/types' + +function group(id: string, overrides: Partial = {}): ProjectGroup { + return { + id, + name: id, + parentPath: null, + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +describe('getProjectGroupHeaderDragBucketKey', () => { + it('uses root for top-level groups', () => { + expect(getProjectGroupHeaderDragBucketKey(group('root'))).toBe('root') + }) + + it('scopes child groups to their parent bucket', () => { + const root = group('root') + const child = group('child', { parentGroupId: root.id }) + const groupsById = new Map([ + [root.id, root], + [child.id, child] + ]) + + expect(getProjectGroupHeaderDragBucketKey(child, groupsById)).toBe('parent:root') + }) + + it('falls back to root when persisted parent metadata is missing', () => { + const orphan = group('orphan', { parentGroupId: 'missing' }) + + expect(getProjectGroupHeaderDragBucketKey(orphan, new Map([[orphan.id, orphan]]))).toBe('root') + }) +}) + +describe('getSidebarOrderedProjectGroupHeaderIdsByBucket', () => { + it('groups Project Group headers by effective parent bucket', () => { + const rootA = group('root-a') + const rootB = group('root-b') + const childA = group('child-a', { parentGroupId: rootA.id }) + const repo = { id: 'repo-a', projectGroupId: rootA.id } as Repo + const groupsById = new Map([ + [rootA.id, rootA], + [rootB.id, rootB], + [childA.id, childA] + ]) + const rows = [ + { + type: 'header', + key: 'project-group:root-a', + label: 'A', + count: 0, + tone: '', + projectGroup: rootA + }, + { type: 'header', key: 'repo:repo-a', label: 'repo', count: 0, tone: '', repo }, + { + type: 'header', + key: 'project-group:child-a', + label: 'A child', + count: 0, + tone: '', + projectGroup: childA + }, + { + type: 'header', + key: 'project-group:root-b', + label: 'B', + count: 0, + tone: '', + projectGroup: rootB + } + ] as Row[] + + expect(getSidebarOrderedProjectGroupHeaderIdsByBucket(rows, groupsById)).toEqual( + new Map([ + ['root', ['root-a', 'root-b']], + ['parent:root-a', ['child-a']] + ]) + ) + }) +}) + +describe('mapSidebarProjectGroupDropIndexToSiblingInsertIndex', () => { + it('keeps upward drops at the same target index after removing the source', () => { + expect( + mapSidebarProjectGroupDropIndexToSiblingInsertIndex({ + sidebarDropIndex: 0, + sourceIndex: 2, + siblingCount: 2 + }) + ).toBe(0) + }) + + it('shifts downward drops because the source header is removed first', () => { + expect( + mapSidebarProjectGroupDropIndexToSiblingInsertIndex({ + sidebarDropIndex: 3, + sourceIndex: 0, + siblingCount: 2 + }) + ).toBe(2) + }) +}) + +describe('computeProjectGroupHeaderDropPreview', () => { + it('uses row-model header indices instead of mounted subset order', () => { + const preview = computeProjectGroupHeaderDropPreview({ + pointerY: 105, + containerTop: 0, + scrollTop: 0, + sidebarProjectGroupHeaderIds: ['a', 'b', 'c', 'd', 'e'], + rects: [ + { groupId: 'b', bucketKey: 'root', headerIndex: 1, top: 100, bottom: 128 }, + { groupId: 'c', bucketKey: 'root', headerIndex: 2, top: 200, bottom: 228 }, + { groupId: 'd', bucketKey: 'root', headerIndex: 3, top: 300, bottom: 328 } + ] + }) + + expect(preview).toEqual({ dropIndex: 1, dropIndicatorY: 96 }) + }) +}) + +describe('getProjectGroupTabOrderForSidebarDrop', () => { + it('uses a midpoint between sibling tab orders when there is room', () => { + expect( + getProjectGroupTabOrderForSidebarDrop({ + siblings: [group('a', { tabOrder: 0 }), group('b', { tabOrder: 10 })], + dropIndex: 1 + }) + ).toBe(5) + }) + + it('assigns an order before the first sibling', () => { + expect( + getProjectGroupTabOrderForSidebarDrop({ + siblings: [group('a', { tabOrder: 10 }), group('b', { tabOrder: 20 })], + dropIndex: 0 + }) + ).toBe(9) + }) + + it('keeps a deterministic finite anchor when sibling orders collide', () => { + const tabOrder = getProjectGroupTabOrderForSidebarDrop({ + siblings: [group('a', { tabOrder: 10 }), group('b', { tabOrder: 10 })], + dropIndex: 1 + }) + + expect(tabOrder).toBe(11) + expect(Number.isFinite(tabOrder)).toBe(true) + }) +}) diff --git a/src/renderer/src/components/sidebar/project-group-header-drop.ts b/src/renderer/src/components/sidebar/project-group-header-drop.ts new file mode 100644 index 00000000000..95546266779 --- /dev/null +++ b/src/renderer/src/components/sidebar/project-group-header-drop.ts @@ -0,0 +1,210 @@ +import { getWorktreeSidebarBoundaryDrop } from './worktree-sidebar-drag-autoscroll' +import type { Row } from './worktree-list-groups' +import type { ProjectGroup } from '../../../../shared/types' + +export type ProjectGroupHeaderDragBucketKey = string + +export type ProjectGroupHeaderDragRect = { + groupId: string + bucketKey: ProjectGroupHeaderDragBucketKey + // Index among sibling Project Group headers in the row model, not mounted DOM. + headerIndex: number + top: number + bottom: number +} + +export type ProjectGroupHeaderDropPreview = { + dropIndex: number + dropIndicatorY: number +} + +const INDICATOR_GAP_PX = 4 +const ROOT_PROJECT_GROUP_HEADER_BUCKET = 'root' + +type SidebarProjectGroupHeader = ProjectGroup | { id: null } | undefined + +function isConcreteProjectGroup( + projectGroup: SidebarProjectGroupHeader +): projectGroup is ProjectGroup { + return typeof projectGroup?.id === 'string' +} + +export function getProjectGroupHeaderDragBucketKey( + group: Pick, + projectGroupById?: ReadonlyMap +): ProjectGroupHeaderDragBucketKey { + const parentGroupId = group.parentGroupId ?? null + if (!parentGroupId) { + return ROOT_PROJECT_GROUP_HEADER_BUCKET + } + if (projectGroupById && !projectGroupById.has(parentGroupId)) { + return ROOT_PROJECT_GROUP_HEADER_BUCKET + } + return `parent:${parentGroupId}` +} + +export function getSidebarOrderedProjectGroupHeaderIdsByBucket( + rows: readonly Row[], + projectGroupById?: ReadonlyMap +): Map { + const buckets = new Map() + for (const row of rows) { + if (row.type !== 'header' || row.repo || !isConcreteProjectGroup(row.projectGroup)) { + continue + } + const bucketKey = getProjectGroupHeaderDragBucketKey(row.projectGroup, projectGroupById) + const list = buckets.get(bucketKey) ?? [] + list.push(row.projectGroup.id) + buckets.set(bucketKey, list) + } + return buckets +} + +export function getProjectGroupTabOrderForSidebarDrop(args: { + siblings: readonly ProjectGroup[] + dropIndex: number +}): number { + const ordered = args.siblings.slice() + if (ordered.length === 0) { + return 0 + } + const getOrder = (group: ProjectGroup | undefined): number | undefined => + group && Number.isFinite(group.tabOrder) ? group.tabOrder : undefined + const before = getOrder(ordered[args.dropIndex - 1]) + const after = getOrder(ordered[args.dropIndex]) + if (before === undefined && after === undefined) { + return 0 + } + if (before === undefined) { + return after !== undefined ? after - 1 : 0 + } + if (after === undefined) { + return before + 1 + } + if (after > before) { + const midpoint = before + (after - before) / 2 + return Number.isFinite(midpoint) ? midpoint : before / 2 + after / 2 + } + // Why: duplicate legacy ranks leave no slot between siblings; a finite anchor + // lets the next drag establish a stable persisted order. + return before + 1 +} + +export function mapSidebarProjectGroupDropIndexToSiblingInsertIndex(args: { + sidebarDropIndex: number + sourceIndex: number + siblingCount: number +}): number { + // Why: sidebar drop indices include the dragged header, but tabOrder is + // computed against the sibling list after that header is removed. + const adjustedDropIndex = + args.sourceIndex >= 0 && args.sidebarDropIndex > args.sourceIndex + ? args.sidebarDropIndex - 1 + : args.sidebarDropIndex + return Math.max(0, Math.min(args.siblingCount, adjustedDropIndex)) +} + +function getVirtualRowStart(virtualRow: HTMLElement | null): number | null { + if (!virtualRow) { + return null + } + const rawStart = virtualRow.getAttribute('data-worktree-virtual-row-start') + if (rawStart === null) { + return null + } + const start = Number(rawStart) + return Number.isFinite(start) ? start : null +} + +export function measureProjectGroupHeaderDragRects( + container: HTMLElement, + bucketKey?: ProjectGroupHeaderDragBucketKey +): ProjectGroupHeaderDragRect[] { + const containerRect = container.getBoundingClientRect() + const rects: ProjectGroupHeaderDragRect[] = [] + container.querySelectorAll('[data-project-group-header-id]').forEach((element) => { + const groupId = element.getAttribute('data-project-group-header-id') + const elementBucketKey = element.getAttribute('data-project-group-header-bucket') + const rawHeaderIndex = element.getAttribute('data-project-group-header-index') + const headerIndex = rawHeaderIndex === null ? Number.NaN : Number(rawHeaderIndex) + if (!groupId || !elementBucketKey || !Number.isFinite(headerIndex)) { + return + } + if (bucketKey !== undefined && elementBucketKey !== bucketKey) { + return + } + const rect = element.getBoundingClientRect() + const virtualRow = element.closest('[data-worktree-virtual-row]') + const virtualRowStart = getVirtualRowStart(virtualRow) + const top = + virtualRow && virtualRowStart !== null + ? virtualRowStart + rect.top - virtualRow.getBoundingClientRect().top + : rect.top - containerRect.top + container.scrollTop + rects.push({ + groupId, + bucketKey: elementBucketKey, + headerIndex, + top, + bottom: top + rect.height + }) + }) + rects.sort((left, right) => left.top - right.top) + return rects +} + +export function computeProjectGroupHeaderDropPreview(args: { + pointerY: number + containerTop: number + scrollTop: number + rects: readonly ProjectGroupHeaderDragRect[] + sidebarProjectGroupHeaderIds: readonly string[] +}): ProjectGroupHeaderDropPreview | null { + const { rects, sidebarProjectGroupHeaderIds } = args + if (rects.length === 0 || sidebarProjectGroupHeaderIds.length === 0) { + return null + } + + const localY = args.pointerY - args.containerTop + args.scrollTop + const first = rects[0]! + const last = rects.at(-1)! + const boundaryDrop = getWorktreeSidebarBoundaryDrop({ + localY, + firstRect: { + worktreeId: first.groupId, + groupIndex: first.headerIndex, + top: first.top, + bottom: first.bottom + }, + lastRect: { + worktreeId: last.groupId, + groupIndex: last.headerIndex, + top: last.top, + bottom: last.bottom + }, + sourceGroupSize: sidebarProjectGroupHeaderIds.length + }) + if (boundaryDrop.kind === 'outside') { + return null + } + + let dropIndex = last.headerIndex + 1 + let indicatorY = last.bottom + INDICATOR_GAP_PX + if (boundaryDrop.kind === 'drop') { + dropIndex = boundaryDrop.dropIndex + indicatorY = boundaryDrop.indicatorY + } else { + for (const rect of rects) { + const mid = (rect.top + rect.bottom) / 2 + if (localY < mid) { + dropIndex = rect.headerIndex + indicatorY = Math.max(0, rect.top - INDICATOR_GAP_PX) + break + } + } + } + + return { + dropIndex, + dropIndicatorY: Math.max(args.scrollTop, indicatorY) + } +} diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts index 805aaa4c6de..f0ef24b9ef6 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts @@ -2275,6 +2275,67 @@ describe('project groups', () => { ]) }) + it('orders Project Group siblings by tabOrder within each parent bucket', () => { + const rootA: ProjectGroup = { + id: 'group-root-a', + name: 'Platform', + parentPath: '/platform', + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 20, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } + const rootB: ProjectGroup = { + ...rootA, + id: 'group-root-b', + name: 'Infrastructure', + tabOrder: 10 + } + const childLate: ProjectGroup = { + ...rootA, + id: 'group-child-late', + name: 'late', + parentGroupId: rootB.id, + tabOrder: 30 + } + const childEarly: ProjectGroup = { + ...rootA, + id: 'group-child-early', + name: 'early', + parentGroupId: rootB.id, + tabOrder: 5 + } + + const rows = buildRows( + 'repo', + [], + new Map(), + null, + new Set(), + undefined, + undefined, + 'recent', + {}, + undefined, + false, + undefined, + [rootA, rootB, childLate, childEarly] + ) + + expect(rows.filter((row) => row.type === 'header').map((row) => row.key)).toEqual([ + 'project-group:group-root-b', + 'project-group:group-child-early', + 'project-group:group-child-late', + 'project-group:group-root-a' + ]) + expect(rows.filter((row) => row.type === 'header').map((row) => row.projectGroupDepth)).toEqual( + [0, 1, 1, 0] + ) + }) + it('renders nested Project Groups before repos assigned to their leaf group', () => { const rootGroup: ProjectGroup = { id: 'group-root', From d65c938529bd9a893b7fda725743cb72067729e6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:42:49 -0700 Subject: [PATCH 13/48] Keep runtime mirror sized with desktop terminal resizes (#7192) Co-authored-by: Orca --- src/main/ipc/pty.test.ts | 56 ++++++++++ src/main/runtime/orca-runtime.test.ts | 143 ++++++++++++++++++++++++++ src/main/runtime/orca-runtime.ts | 27 +++-- 3 files changed, 218 insertions(+), 8 deletions(-) diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index e3475692814..26c1bda9157 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -3829,6 +3829,62 @@ describe('registerPtyHandlers', () => { const reported = await handlers.get('pty:getSize')!(null, { id }) expect(reported).toEqual({ cols: 100, rows: 30 }) }) + + it('fans out accepted desktop resizes to the runtime after provider resize', async () => { + const resize = vi.fn() + setupProviderWithAppliedSize({ applied: { cols: 120, rows: 30 }, resize }) + const runtime = { + setPtyController: vi.fn(), + createPreAllocatedTerminalHandle: vi.fn(() => null), + registerPty: vi.fn(), + getDriver: vi.fn(() => ({ kind: 'host' })), + isResizeSuppressed: vi.fn(() => false), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn(), + onExternalPtyResize: vi.fn() + } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const spawn = await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, env: {} }) + const id = (spawn as { id: string }).id + + resizeListener()(mainWindowIpcEvent, { id, cols: 120, rows: 30 }) + + expect(resize).toHaveBeenCalledWith(id, 120, 30) + expect(runtime.onExternalPtyResize).toHaveBeenCalledWith(id, 120, 30) + expect(resize.mock.invocationCallOrder[0]).toBeLessThan( + runtime.onExternalPtyResize.mock.invocationCallOrder[0]! + ) + }) + + it('does not fan out rejected desktop resizes to the runtime', async () => { + setupProviderWithAppliedSize({ + applied: { cols: 80, rows: 24 }, + resize: () => { + throw new Error('resize rejected') + } + }) + const runtime = { + setPtyController: vi.fn(), + createPreAllocatedTerminalHandle: vi.fn(() => null), + registerPty: vi.fn(), + getDriver: vi.fn(() => ({ kind: 'host' })), + isResizeSuppressed: vi.fn(() => false), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn(), + onExternalPtyResize: vi.fn() + } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const spawn = await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, env: {} }) + const id = (spawn as { id: string }).id + + resizeListener()(mainWindowIpcEvent, { id, cols: 120, rows: 30 }) + + expect(runtime.onExternalPtyResize).not.toHaveBeenCalled() + }) }) it('injects ORCA_TERMINAL_HANDLE for non-local PTY providers', async () => { diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index b4b2dae941d..372cf304061 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -52,6 +52,7 @@ import { recentTerminalOutputIncludesPath, type RuntimeTerminalAgentStatusEvent } from './orca-runtime' +import { HeadlessEmulator } from '../daemon/headless-emulator' import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types' import { TERMINAL_INPUT_CHUNK_MAX_BYTES, @@ -719,6 +720,64 @@ function syncSinglePty( }) } +function makeDeferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void + const promise = new Promise((next) => { + resolve = next + }) + return { promise, resolve } +} + +function makeStatusFrame(index: number, first: boolean): string { + const pad = '·'.repeat(60) + const rows = [ + `✻ 执行任务中… (esc to interrupt) [${index}] ${pad}`, + ` ⎿ 正在分析代码库结构与依赖关系,请稍候… ${pad}`, + ` ⎿ tokens: ${1000 + index * 137} · elapsed: ${index}s ${pad}` + ] + return `${first ? '' : '\x1b[2A'}\r${rows.map((row) => `\x1b[K${row}`).join('\r\n')}\r` +} + +async function writeHeadless(emulator: HeadlessEmulator, data: string): Promise { + await emulator.write(data) +} + +function visibleNonEmptyLines(emulator: HeadlessEmulator): string[] { + return emulator.getVisibleLines().filter((line) => line.length > 0) +} + +async function parseHeadlessSnapshotLines( + snapshot: { data: string; cols: number; rows: number }, + display: { cols: number; rows: number } +): Promise { + const restored = new HeadlessEmulator({ cols: display.cols, rows: display.rows }) + try { + restored.resize(snapshot.cols, snapshot.rows) + await writeHeadless(restored, `\x1b[2J\x1b[3J\x1b[H${snapshot.data}`) + restored.resize(display.cols, display.rows) + return visibleNonEmptyLines(restored) + } finally { + restored.dispose() + } +} + +async function referenceStatusFrameLines( + spawn: { cols: number; rows: number }, + resized: { cols: number; rows: number } +): Promise { + const truth = new HeadlessEmulator({ cols: spawn.cols, rows: spawn.rows }) + try { + await writeHeadless(truth, 'user@host % claude\r\n') + truth.resize(resized.cols, resized.rows) + for (let index = 0; index < 5; index += 1) { + await writeHeadless(truth, makeStatusFrame(index, index === 0)) + } + return visibleNonEmptyLines(truth) + } finally { + truth.dispose() + } +} + const TEST_WINDOW_ID = 1 const TEST_REPO_ID = 'repo-1' const TEST_REPO_PATH = '/tmp/repo' @@ -5411,6 +5470,90 @@ describe('OrcaRuntimeService', () => { }) }) + it('resizes the headless mirror after an accepted desktop PTY resize', async () => { + const spawn = { cols: 80, rows: 24 } + const resized = { cols: 120, rows: 30 } + let currentSize = spawn + const runtime = createRuntime() + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + getSize: () => currentSize + }) + syncSinglePty(runtime, 'pty-1') + + runtime.onPtyData('pty-1', 'user@host % claude\r\n', 100) + currentSize = resized + runtime.onExternalPtyResize('pty-1', resized.cols, resized.rows) + for (let index = 0; index < 5; index += 1) { + runtime.onPtyData('pty-1', makeStatusFrame(index, index === 0), 200 + index) + } + + const snapshot = await runtime.serializeMainTerminalBuffer('pty-1', { scrollbackRows: 5000 }) + expect(snapshot).toMatchObject({ cols: resized.cols, rows: resized.rows, source: 'headless' }) + await expect(parseHeadlessSnapshotLines(snapshot!, resized)).resolves.toEqual( + await referenceStatusFrameLines(spawn, resized) + ) + }) + + it('orders headless mirror resizes behind queued PTY writes', async () => { + const spawn = { cols: 80, rows: 10 } + const resized = { cols: 120, rows: 10 } + let currentSize = spawn + const runtime = createRuntime() + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + getSize: () => currentSize, + resize: () => { + currentSize = resized + return true + } + }) + syncSinglePty(runtime, 'pty-1') + runtime.onPtyData('pty-1', 'prompt\r\n', 100) + await runtime.serializeMainTerminalBuffer('pty-1') + + type HeadlessStateForTest = { + emulator: HeadlessEmulator + writeChain: Promise + } + const headless = ( + runtime as unknown as { headlessTerminals: Map } + ).headlessTerminals.get('pty-1') + expect(headless).toBeDefined() + const originalWrite = headless!.emulator.write.bind(headless!.emulator) + const queuedWriteStarted = makeDeferred() + const releaseQueuedWrite = makeDeferred() + headless!.emulator.write = async (data: string): Promise => { + queuedWriteStarted.resolve() + await releaseQueuedWrite.promise + await originalWrite(data) + } + + try { + runtime.onPtyData('pty-1', '\x1b[90GOLD', 200) + await queuedWriteStarted.promise + await runtime.updateDesktopViewport('pty-1', resized) + runtime.onPtyData('pty-1', '\r\nNEXT', 300) + releaseQueuedWrite.resolve() + + const snapshot = await runtime.serializeMainTerminalBuffer('pty-1', { scrollbackRows: 100 }) + expect(snapshot).toMatchObject({ cols: resized.cols, rows: resized.rows }) + await expect(parseHeadlessSnapshotLines(snapshot!, resized)).resolves.toEqual([ + 'prompt', + ' O', + 'LD', + 'NEXT' + ]) + } finally { + headless!.emulator.write = originalWrite + releaseQueuedWrite.resolve() + } + }) + it('adopts renderer-seeded titles into headless main terminal snapshots', async () => { const artifactPath = '/tmp/renderer-seeded-artifact.json' const serializeBuffer = vi.fn().mockResolvedValue({ diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 81150a3a36b..afd10bee579 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -5760,7 +5760,21 @@ export class OrcaRuntimeService { } private resizeHeadlessTerminal(ptyId: string, cols: number, rows: number): void { - this.headlessTerminals.get(ptyId)?.emulator.resize(cols, rows) + const state = this.headlessTerminals.get(ptyId) + if (!state) { + return + } + // Why: terminal reflow is a parser operation. It must sit in the same + // per-PTY stream as output bytes or restore snapshots can bake in wraps + // from the wrong terminal width. + state.writeChain = state.writeChain + .then(() => { + state.emulator.resize(cols, rows) + }) + .catch(() => { + // Best-effort mirror tracking; live PTY streaming must continue even + // if xterm rejects a raced resize during teardown. + }) } private createHeadlessEmulator( @@ -7052,7 +7066,6 @@ export class OrcaRuntimeService { if (!resized) { return false } - this.resizeHeadlessTerminal(ptyId, cols, rows) this.onExternalPtyResize(ptyId, cols, rows) return true } @@ -7880,12 +7893,9 @@ export class OrcaRuntimeService { } } - // Why: called from the pty:resize IPC handler whenever the desktop renderer - // resizes a PTY (e.g. via safeFit after window resize, split, or desktop-mode - // restore). Stores the renderer-reported size so handleMobileSubscribe can use - // the actual pane geometry instead of a stale PTY size for previousCols. - // This is a passive geometry report — it does NOT call applyLayout; the - // PTY is already at the reported size. + // Why: called after a desktop renderer path has successfully resized the + // PTY (local IPC or remote desktop viewport). The runtime mirror must take + // the same accepted geometry so hidden-output restore parses at PTY width. onExternalPtyResize(ptyId: string, cols: number, rows: number): void { // The pty:resize IPC handler is supposed to gate via `isResizeSuppressed` // before calling here, but defend against callers that don't. @@ -7905,6 +7915,7 @@ export class OrcaRuntimeService { if (activeOverride && activeOverride.cols === cols && activeOverride.rows === rows) { return } + this.resizeHeadlessTerminal(ptyId, cols, rows) this.refreshRendererGeometry(ptyId, cols, rows) } From bf6a39987cfcf64ea71e4c4fa2cb178f7b958b75 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 05:50:01 +0000 Subject: [PATCH 14/48] release: v1.4.120-rc.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 41ec21ecb50..5e37d338f40 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.120-rc.4", + "version": "1.4.120-rc.5", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From fa94065a81efbd0f0ea24133078b7ffec23c97d5 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 2 Jul 2026 23:06:32 -0700 Subject: [PATCH 15/48] Recover exported terminal handles from live PTYs (#7162) * Recover exported terminal handles from live PTYs * Guard terminal-handle adoption: first-wins, no collisions Discovery adoption of ORCA_TERMINAL_HANDLE is now skipped when the pty already has a handle bound this session (re-keying would strand waiters registered under the old handle) or when the reported handle is already bound to a different pty (provider-reported values are not trusted to be collision-free). Also adds the relay why-comment and PtyProcessSummary type alias from review feedback. Co-authored-by: Orca --------- Co-authored-by: Orca --- src/main/daemon/daemon-pty-adapter.ts | 12 +- src/main/daemon/daemon-pty-router.ts | 9 +- .../daemon/degraded-daemon-pty-provider.ts | 9 +- src/main/daemon/session.ts | 3 + src/main/daemon/terminal-host.ts | 2 + src/main/daemon/types.ts | 1 + src/main/providers/local-pty-provider.ts | 12 +- src/main/providers/ssh-pty-provider.ts | 6 +- src/main/providers/types.ts | 10 +- src/main/runtime/orca-runtime.test.ts | 106 ++++++++++++++++++ src/main/runtime/orca-runtime.ts | 45 +++++++- src/relay/pty-handler.ts | 34 +++++- 12 files changed, 228 insertions(+), 21 deletions(-) diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index a649abebcc9..56a775a8c99 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -19,7 +19,12 @@ import { type SessionInfo, type TakePendingOutputResult } from './types' -import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types' +import type { + IPtyProvider, + PtyProcessInfo, + PtySpawnOptions, + PtySpawnResult +} from '../providers/types' import { isShellProcess } from '../../shared/agent-detection' import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery' @@ -517,7 +522,7 @@ export class DaemonPtyAdapter implements IPtyProvider { return { alive, killed } } - async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> { + async listProcesses(): Promise { await this.ensureConnected() const result = await this.client.request('listSessions', undefined) return result.sessions @@ -525,7 +530,8 @@ export class DaemonPtyAdapter implements IPtyProvider { .map((s) => ({ id: s.sessionId, cwd: s.cwd ?? '', - title: 'shell' + title: 'shell', + ...(s.terminalHandle ? { terminalHandle: s.terminalHandle } : {}) })) } diff --git a/src/main/daemon/daemon-pty-router.ts b/src/main/daemon/daemon-pty-router.ts index 2212635d1fa..1f80f770cd9 100644 --- a/src/main/daemon/daemon-pty-router.ts +++ b/src/main/daemon/daemon-pty-router.ts @@ -1,5 +1,10 @@ import type { DaemonPtyAdapter } from './daemon-pty-adapter' -import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types' +import type { + IPtyProvider, + PtyProcessInfo, + PtySpawnOptions, + PtySpawnResult +} from '../providers/types' export class DaemonPtyRouter implements IPtyProvider { private current: DaemonPtyAdapter @@ -124,7 +129,7 @@ export class DaemonPtyRouter implements IPtyProvider { await this.current.revive(state) } - async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> { + async listProcesses(): Promise { // Why: runtime exact-stop/liveness flows must fail closed if any adapter // cannot provide a trustworthy process list. const results = await Promise.all(this.allAdapters().map((adapter) => adapter.listProcesses())) diff --git a/src/main/daemon/degraded-daemon-pty-provider.ts b/src/main/daemon/degraded-daemon-pty-provider.ts index acf95ffcdfe..a7b8c68fa19 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.ts @@ -1,5 +1,10 @@ import type { DaemonPtyAdapter } from './daemon-pty-adapter' -import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types' +import type { + IPtyProvider, + PtyProcessInfo, + PtySpawnOptions, + PtySpawnResult +} from '../providers/types' type ManagedPtyProvider = IPtyProvider & { disconnectOnly?: () => Promise @@ -135,7 +140,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { await this.fallback.revive(state) } - async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> { + async listProcesses(): Promise { const results = await Promise.all( this.allProviders().map((provider) => provider.listProcesses()) ) diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index 551b8ad276c..b4d3433a5f8 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -56,6 +56,7 @@ export type SessionOptions = { sessionId: string cols: number rows: number + terminalHandle?: string subprocess: SubprocessHandle shellReadySupported: boolean shellReadyTimeoutMs?: number @@ -76,6 +77,7 @@ type AttachedClient = { export class Session { readonly sessionId: string + readonly terminalHandle: string | null private _state: SessionState = 'running' private _shellState: ShellReadyState private _exitCode: number | null = null @@ -97,6 +99,7 @@ export class Session { constructor(opts: SessionOptions) { this.sessionId = opts.sessionId + this.terminalHandle = opts.terminalHandle ?? null this.subprocess = opts.subprocess this.onSessionExit = opts.onExit const size = normalizePtySize(opts.cols, opts.rows) diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index 4731322e526..41e1f96b1bb 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -136,6 +136,7 @@ export class TerminalHost { sessionId: opts.sessionId, cols: size.cols, rows: size.rows, + terminalHandle: opts.env?.ORCA_TERMINAL_HANDLE, subprocess, shellReadySupported: opts.shellReadySupported ?? false, // Why: reap the dead session (dispose emulator + drop from the map) the @@ -301,6 +302,7 @@ export class TerminalHost { state: session.state, shellState: session.shellState, isAlive: true, + ...(session.terminalHandle ? { terminalHandle: session.terminalHandle } : {}), pid: session.pid, cwd: session.getCwd(), cols: size?.cols ?? 0, diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index 868afa81610..a38cc21a425 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -324,6 +324,7 @@ export type SessionInfo = { state: SessionState shellState: ShellReadyState isAlive: boolean + terminalHandle?: string pid: number | null cwd: string | null cols: number diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 01ea3aeea57..7e8588a6b64 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -21,7 +21,7 @@ import { updateHistFileForFallback, logHistoryInjection } from '../terminal-history' -import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from './types' +import type { IPtyProvider, PtyProcessInfo, PtySpawnOptions, PtySpawnResult } from './types' import { ensureNodePtySpawnHelperExecutable, validateWorkingDirectory, @@ -67,6 +67,7 @@ let ptyCounter = 0 const ptyProcesses = new Map() const ptyShellName = new Map() const ptyAgentForegroundContextPaths = new Map() +const ptyTerminalHandle = new Map() // Why: node-pty's onData/onExit register native NAPI ThreadSafeFunction // callbacks. If the PTY is killed without disposing these listeners, the // stale callbacks survive into node::FreeEnvironment() where NAPI attempts @@ -187,6 +188,7 @@ function clearPtyState(id: string): void { ptyProcesses.delete(id) ptyShellName.delete(id) ptyAgentForegroundContextPaths.delete(id) + ptyTerminalHandle.delete(id) ptyLoadGeneration.delete(id) } @@ -680,6 +682,9 @@ export class LocalPtyProvider implements IPtyProvider { const proc = spawnResult.process ptyProcesses.set(id, proc) ptyShellName.set(id, getSpawnedShellName(shellPath)) + if (finalEnv.ORCA_TERMINAL_HANDLE) { + ptyTerminalHandle.set(id, finalEnv.ORCA_TERMINAL_HANDLE) + } ptyAgentForegroundContextPaths.set( id, getAgentForegroundContextPaths({ cwd: args.cwd, worktreeId: args.worktreeId }) @@ -945,11 +950,12 @@ export class LocalPtyProvider implements IPtyProvider { /* re-spawning handles local revival */ } - async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> { + async listProcesses(): Promise { return Array.from(ptyProcesses.entries()).map(([id, proc]) => ({ id, cwd: '', - title: proc.process || ptyShellName.get(id) || 'shell' + title: proc.process || ptyShellName.get(id) || 'shell', + ...(ptyTerminalHandle.get(id) ? { terminalHandle: ptyTerminalHandle.get(id) } : {}) })) } diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts index 7284f2353ba..398cc935142 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -1,5 +1,5 @@ import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from './types' +import type { IPtyProvider, PtyProcessInfo, PtySpawnOptions, PtySpawnResult } from './types' import { toAppSshPtyId, toRelaySshPtyId } from './ssh-pty-id' import { seedPowerlevel10kWizardEnv } from '../pty/powerlevel10k-wizard-env' @@ -254,9 +254,9 @@ export class SshPtyProvider implements IPtyProvider { await this.mux.request('pty.revive', { state }) } - async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> { + async listProcesses(): Promise { const result = await this.mux.request('pty.listProcesses') - return (result as { id: string; cwd: string; title: string }[]).map((session) => ({ + return (result as PtyProcessInfo[]).map((session) => ({ ...session, id: this.toAppPtyId(session.id) })) diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index a7516ec4073..ec9061c5130 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -99,6 +99,14 @@ export type PtySpawnResult = { } } +export type PtyProcessInfo = { + id: string + cwd: string + title: string + /** Trusted ORCA_TERMINAL_HANDLE exported into this PTY, when known. */ + terminalHandle?: string +} + export type IPtyProvider = { spawn(opts: PtySpawnOptions): Promise attach(id: string): Promise @@ -128,7 +136,7 @@ export type IPtyProvider = { getForegroundProcess(id: string): Promise serialize(ids: string[]): Promise revive(state: string): Promise - listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> + listProcesses(): Promise getDefaultShell(): Promise getProfiles(): Promise<{ name: string; path: string }[]> onData(callback: (payload: { id: string; data: string }) => void): () => void diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 372cf304061..ee8d6edd608 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -10497,6 +10497,112 @@ describe('OrcaRuntimeService', () => { expect(read.tail).toEqual(['ready']) }) + it('recovers exported ORCA_TERMINAL_HANDLE from discovered live PTY sessions', async () => { + const runtime = new OrcaRuntimeService(store) + const writes: string[] = [] + runtime.setPtyController({ + write: (_ptyId, data) => { + writes.push(data) + return true + }, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [ + { + id: 'pty-1', + cwd: TEST_WORKTREE_PATH, + title: 'claude', + terminalHandle: 'term_exported' + } + ] + }) + + const listed = await runtime.listTerminals() + expect(listed.terminals[0]?.handle).toBe('term_exported') + + runtime.onPtyData('pty-1', 'after restart\n', 100) + await expect(runtime.readTerminal('term_exported')).resolves.toMatchObject({ + handle: 'term_exported', + tail: ['after restart'] + }) + await expect( + runtime.sendTerminal('term_exported', { text: 'still writable' }) + ).resolves.toMatchObject({ + handle: 'term_exported', + accepted: true + }) + expect(writes).toEqual(['still writable']) + }) + + it('does not adopt a discovered terminal handle already bound to another live PTY', async () => { + const runtime = new OrcaRuntimeService(store) + const writesByPty = new Map() + runtime.setPtyController({ + write: (ptyId, data) => { + writesByPty.set(ptyId, [...(writesByPty.get(ptyId) ?? []), data]) + return true + }, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [ + { + id: 'pty-victim', + cwd: TEST_WORKTREE_PATH, + title: 'claude', + terminalHandle: 'term_victim' + }, + { + id: 'pty-imposter', + cwd: TEST_WORKTREE_PATH, + title: 'claude', + terminalHandle: 'term_victim' + } + ] + }) + + const listed = await runtime.listTerminals() + const handles = listed.terminals.map((terminal) => terminal.handle) + expect(handles).toContain('term_victim') + expect(new Set(handles).size).toBe(handles.length) + + await expect( + runtime.sendTerminal('term_victim', { text: 'for victim' }) + ).resolves.toMatchObject({ accepted: true }) + expect(writesByPty.get('pty-victim')).toEqual(['for victim']) + expect(writesByPty.has('pty-imposter')).toBe(false) + }) + + it('keeps an already-bound terminal handle when discovery reports a different exported one', async () => { + const runtime = new OrcaRuntimeService(store) + const writes: string[] = [] + runtime.setPtyController({ + write: (_ptyId, data) => { + writes.push(data) + return true + }, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [ + { + id: 'pty-1', + cwd: TEST_WORKTREE_PATH, + title: 'claude', + terminalHandle: 'term_from_env' + } + ] + }) + runtime.registerPreAllocatedHandleForPty('pty-1', 'term_already_bound') + + const listed = await runtime.listTerminals() + expect(listed.terminals[0]?.handle).toBe('term_already_bound') + await expect( + runtime.sendTerminal('term_already_bound', { text: 'still routed' }) + ).resolves.toMatchObject({ accepted: true }) + expect(writes).toEqual(['still routed']) + // the reported-but-not-adopted handle must not resolve to the live pty + await expect(runtime.readTerminal('term_from_env')).rejects.toThrow() + }) + it('binds advertised URLs for renderer-restored PTYs that skip registerPty', () => { const runtime = new OrcaRuntimeService(store) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index afd10bee579..38414da2155 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -661,7 +661,7 @@ import { closeLocalWatcherForWorktreePath } from '../ipc/filesystem-watcher' import { HeadlessEmulator, type HeadlessEmulatorOptions } from '../daemon/headless-emulator' import { killAllProcessesForWorktree } from './worktree-teardown' import { MOBILE_SUBSCRIBE_SCROLLBACK_ROWS } from './scrollback-limits' -import type { IFilesystemProvider, IPtyProvider } from '../providers/types' +import type { IFilesystemProvider, IPtyProvider, PtyProcessInfo } from '../providers/types' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { assertFolderWorkspacePathUsable, @@ -1037,7 +1037,7 @@ type RuntimePtyController = { hasChildProcesses?(ptyId: string): Promise clearBuffer?(ptyId: string): Promise resize?(ptyId: string, cols: number, rows: number): boolean - listProcesses?(): Promise<{ id: string; cwd: string; title: string }[]> + listProcesses?(): Promise serializeBuffer?( ptyId: string, opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } @@ -5041,6 +5041,46 @@ export class OrcaRuntimeService { } } + private adoptControllerTerminalHandle(ptyId: string, handle: string | undefined): void { + const trimmed = handle?.trim() + if (!trimmed || !trimmed.startsWith('term_')) { + return + } + if (this.isTerminalHandleAdoptionBlocked(ptyId, trimmed)) { + return + } + // Why: after an app/runtime restart, the live PTY child still has its + // original ORCA_TERMINAL_HANDLE, but the runtime's in-memory map is gone. + this.registerPreAllocatedHandleForPty(ptyId, trimmed) + } + + // Why: adoption is best-effort restart recovery and must be first-wins. + // Re-keying a pty that already has a handle this session would strand + // waiters registered under the old handle, and provider-reported values + // are not trusted to be collision-free — a handle bound to a different + // pty must never be stolen by a later report. + private isTerminalHandleAdoptionBlocked(ptyId: string, handle: string): boolean { + if (this.handleByPtyId.get(ptyId) ?? this.findHandleForPtyRecord(ptyId)) { + return true + } + for (const leaf of this.getLeavesForPty(ptyId)) { + const issued = this.handleByLeafKey.get(this.getLeafKey(leaf.tabId, leaf.leafId)) + if (issued && issued !== handle) { + return true + } + } + const existingRecord = this.handles.get(handle) + if (existingRecord && existingRecord.ptyId !== ptyId) { + return true + } + for (const [otherPtyId, otherHandle] of this.handleByPtyId) { + if (otherHandle === handle && otherPtyId !== ptyId) { + return true + } + } + return false + } + onPtySpawned(ptyId: string): void { const pty = this.getOrCreatePtyWorktreeRecord(ptyId) if (pty) { @@ -17789,6 +17829,7 @@ export class OrcaRuntimeService { const sessions = sessionsResult.value const livePtyIds = new Set(sessions.map((session) => session.id)) for (const session of sessions) { + this.adoptControllerTerminalHandle(session.id, session.terminalHandle) const worktreeId = inferWorktreeIdFromPtyId(session.id) ?? findResolvedWorktreeIdForPath(resolvedWorktrees, session.cwd) diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 5f4fff86610..126f761333b 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -62,6 +62,7 @@ type ManagedPty = { paneKey?: string tabId?: string worktreeId?: string + terminalHandle?: string startupCommand?: ManagedStartupCommand } @@ -156,6 +157,13 @@ function resolvePtyShellOverride(shellOverride: string): string { return resolveWindowsGitBashShellPath(shellOverride) ?? shellOverride } +type PtyProcessSummary = { + id: string + cwd: string + title: string + terminalHandle?: string +} + type SerializedPtyEntry = { id: string pid: number @@ -165,6 +173,7 @@ type SerializedPtyEntry = { paneKey?: string tabId?: string worktreeId?: string + terminalHandle?: string } export type PtyExitListener = (event: { id: string; paneKey?: string }) => void @@ -539,6 +548,10 @@ export class PtyHandler { // for overlay resolution; runtime-owned PTYs opt into relay delivery // because no renderer TerminalPane exists to type the command. const paneKey = typeof env?.ORCA_PANE_KEY === 'string' ? env.ORCA_PANE_KEY : undefined + // Why: kept so a restarted runtime can re-adopt this live PTY under its + // originally-exported handle (reported via listProcesses, survives revive). + const terminalHandle = + typeof env?.ORCA_TERMINAL_HANDLE === 'string' ? env.ORCA_TERMINAL_HANDLE : undefined const command = typeof params.command === 'string' ? params.command : undefined const terminalWindowsWslDistro = typeof params.terminalWindowsWslDistro === 'string' ? params.terminalWindowsWslDistro : null @@ -589,6 +602,7 @@ export class PtyHandler { paneKey, tabId, worktreeId, + ...(terminalHandle ? { terminalHandle } : {}), ...(shouldProviderDeliverCommand ? { startupCommand: { @@ -819,12 +833,17 @@ export class PtyHandler { return await getForegroundProcessName(managed.pty.pid, managed.pty.process || null) } - private async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> { - const results: { id: string; cwd: string; title: string }[] = [] + private async listProcesses(): Promise { + const results: PtyProcessSummary[] = [] for (const [id, managed] of this.ptys) { const title = (await getForegroundProcessName(managed.pty.pid, managed.pty.process || null)) || 'shell' - results.push({ id, cwd: managed.initialCwd, title }) + results.push({ + id, + cwd: managed.initialCwd, + title, + ...(managed.terminalHandle ? { terminalHandle: managed.terminalHandle } : {}) + }) } return results } @@ -846,7 +865,8 @@ export class PtyHandler { cwd: managed.initialCwd, paneKey: managed.paneKey, tabId: managed.tabId, - worktreeId: managed.worktreeId + worktreeId: managed.worktreeId, + ...(managed.terminalHandle ? { terminalHandle: managed.terminalHandle } : {}) }) } return JSON.stringify(entries) @@ -884,6 +904,9 @@ export class PtyHandler { if (entry.worktreeId) { revivedEnv.ORCA_WORKTREE_ID = entry.worktreeId } + if (entry.terminalHandle) { + revivedEnv.ORCA_TERMINAL_HANDLE = entry.terminalHandle + } const shell = resolveDefaultShell() // Why: `command` is intentionally absent from this revive path because // SerializedPtyEntry (see line 99) does not persist it — ManagedPty @@ -914,7 +937,8 @@ export class PtyHandler { buffered: '', paneKey: entry.paneKey, tabId: entry.tabId, - worktreeId: entry.worktreeId + worktreeId: entry.worktreeId, + ...(entry.terminalHandle ? { terminalHandle: entry.terminalHandle } : {}) }) // Why: nextId starts at 1 and is only incremented by spawn(). Revived From f0278c116e9d3abe66dce26364b4e1122f613b76 Mon Sep 17 00:00:00 2001 From: Siddiqui Qamar <137684575+siddqamar@users.noreply.github.com> Date: Fri, 3 Jul 2026 03:22:30 -0300 Subject: [PATCH 16/48] fix(mobile): preserve terminal input mode on reentry (#7129) Fixes #6972.\n\nPreserves mobile terminal buffered/live input mode across Android terminal re-entry and session refreshes. Includes follow-up hardening for pre-hydration preference edits and failed storage reads. --- .../app/h/[hostId]/session/[worktreeId].tsx | 80 +------ ...erminal-live-input-mode-preference.test.ts | 138 ++++++++++++ ...use-terminal-live-input-mode-preference.ts | 212 ++++++++++++++++++ mobile/src/storage/preferences.test.ts | 59 +++++ mobile/src/storage/preferences.ts | 47 ++++ .../src/terminal/terminal-live-input.test.ts | 31 +++ mobile/src/terminal/terminal-live-input.ts | 42 ++++ 7 files changed, 540 insertions(+), 69 deletions(-) create mode 100644 mobile/src/session/use-terminal-live-input-mode-preference.test.ts create mode 100644 mobile/src/session/use-terminal-live-input-mode-preference.ts diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 3feb30faa3d..a934334b8f2 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -96,9 +96,7 @@ import { createTerminalLiveAccessoryInput } from '../../../../src/terminal/termi import { getTerminalLiveAccessoryRawSendTarget } from '../../../../src/terminal/terminal-live-accessory-raw-send-target' import { clearTerminalLiveInputFocusTimer, - defaultTerminalLiveInputHandles, isTerminalLiveInputWithinByteLimit, - pruneTerminalLiveInputHandles, scheduleTerminalLiveInputFocus } from '../../../../src/terminal/terminal-live-input' import type { TerminalLiveInputSender } from '../../../../src/terminal/terminal-live-input-sender' @@ -165,6 +163,7 @@ import { } from '../../../../src/session/mobile-new-tab-agent-options' import { useMobileImageAttachment } from '../../../../src/session/use-mobile-image-attachment' import { useMobileTerminalPaste } from '../../../../src/session/use-mobile-terminal-paste' +import { useTerminalLiveInputModePreference } from '../../../../src/session/use-terminal-live-input-mode-preference' import { MobileTerminalLiveInputStatus } from '../../../../src/session/MobileTerminalLiveInputStatus' import { MobileTerminalInputActions } from '../../../../src/session/MobileTerminalInputActions' import { classifyMobileArtifact } from '../../../../src/session/mobile-artifact-kind' @@ -896,11 +895,14 @@ export default function SessionScreen() { const [terminalLinkOpenMode, setTerminalLinkOpenMode] = useState('orca-browser') const [liveInputCapture, setLiveInputCapture] = useState('') - const [liveInputTerminalHandles, setLiveInputTerminalHandles] = useState>( - () => new Set() - ) - const liveInputTerminalHandlesRef = useRef>(new Set()) - const defaultedLiveInputTerminalHandlesRef = useRef>(new Set()) + const { + clearTerminalLiveInputDefault, + defaultTerminalHandlesToLiveInput, + liveInputTerminalHandles, + liveInputTerminalHandlesRef, + pruneTerminalHandlesFromLiveInput, + toggleTerminalLiveInput + } = useTerminalLiveInputModePreference({ hostId, worktreeId }) const [activeHandle, setActiveHandle] = useState(null) const [activeSessionTabId, setActiveSessionTabId] = useState(null) const activeSessionTabIdRef = useRef(null) @@ -1104,7 +1106,6 @@ export default function SessionScreen() { sessionTabsRef.current = sessionTabs activeSessionTabIdRef.current = activeSessionTabId markdownDocsRef.current = markdownDocs - liveInputTerminalHandlesRef.current = liveInputTerminalHandles const reconciledCreateWarningState = reconcileMobileSessionCreateWarningState( createWarningState, initialCreateWarning @@ -1170,52 +1171,6 @@ export default function SessionScreen() { [clearToastHideTimer] ) - // Why: direct input is now the mobile default, but only once per discovered - // handle so a user's buffered-mode toggle survives tab/list refreshes. - const defaultTerminalHandlesToLiveInput = useCallback((handles: readonly string[]) => { - const result = defaultTerminalLiveInputHandles( - liveInputTerminalHandlesRef.current, - defaultedLiveInputTerminalHandlesRef.current, - handles - ) - if (!result.changed) { - return - } - const nextEnabledHandles = new Set(result.enabledHandles) - const nextDefaultedHandles = new Set(result.defaultedHandles) - liveInputTerminalHandlesRef.current = nextEnabledHandles - defaultedLiveInputTerminalHandlesRef.current = nextDefaultedHandles - setLiveInputTerminalHandles(nextEnabledHandles) - }, []) - - const pruneTerminalHandlesFromLiveInput = useCallback((liveHandles: ReadonlySet) => { - const result = pruneTerminalLiveInputHandles( - liveInputTerminalHandlesRef.current, - defaultedLiveInputTerminalHandlesRef.current, - liveHandles - ) - if (!result.changed) { - return - } - const nextEnabledHandles = new Set(result.enabledHandles) - const nextDefaultedHandles = new Set(result.defaultedHandles) - liveInputTerminalHandlesRef.current = nextEnabledHandles - defaultedLiveInputTerminalHandlesRef.current = nextDefaultedHandles - setLiveInputTerminalHandles(nextEnabledHandles) - }, []) - - const clearTerminalLiveInputDefault = useCallback( - (handle: string) => { - const liveHandles = new Set([ - ...liveInputTerminalHandlesRef.current, - ...defaultedLiveInputTerminalHandlesRef.current - ]) - liveHandles.delete(handle) - pruneTerminalHandlesFromLiveInput(liveHandles) - }, - [pruneTerminalHandlesFromLiveInput] - ) - const dictation = useMobileDictation({ client, enabled: canSend, @@ -2685,9 +2640,6 @@ export default function SessionScreen() { setSessionTabs([]) setActiveSessionTabId(null) clearPendingLiveInputCommit() - liveInputTerminalHandlesRef.current = new Set() - defaultedLiveInputTerminalHandlesRef.current = new Set() - setLiveInputTerminalHandles(new Set()) setMarkdownDocs(new Map()) setFileDocs(new Map()) clearDelayedActionTimers() @@ -3362,17 +3314,7 @@ export default function SessionScreen() { if (!activeHandle) { return } - const nextEnabled = !liveInputTerminalHandles.has(activeHandle) - setLiveInputTerminalHandles((prev) => { - const next = new Set(prev) - if (nextEnabled) { - next.add(activeHandle) - } else { - next.delete(activeHandle) - } - liveInputTerminalHandlesRef.current = next - return next - }) + const nextEnabled = toggleTerminalLiveInput(activeHandle) clearPendingLiveInputCommit() if (nextEnabled) { scheduleTerminalLiveInputFocus(liveInputFocusTimerRef, () => liveInputRef.current?.focus()) @@ -3380,7 +3322,7 @@ export default function SessionScreen() { clearTerminalLiveInputFocusTimer(liveInputFocusTimerRef) liveInputRef.current?.blur() } - }, [activeHandle, clearPendingLiveInputCommit, liveInputTerminalHandles]) + }, [activeHandle, clearPendingLiveInputCommit, toggleTerminalLiveInput]) const allowTerminalGestureInput = useCallback( (handle: string, sequenceCount: number): boolean => { diff --git a/mobile/src/session/use-terminal-live-input-mode-preference.test.ts b/mobile/src/session/use-terminal-live-input-mode-preference.test.ts new file mode 100644 index 00000000000..d95fab23698 --- /dev/null +++ b/mobile/src/session/use-terminal-live-input-mode-preference.test.ts @@ -0,0 +1,138 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + readDisabledTerminalLiveInputHandlesPreference, + saveDisabledTerminalLiveInputHandles, + type DisabledTerminalLiveInputHandlesPreference +} from '../storage/preferences' +import { useTerminalLiveInputModePreference } from './use-terminal-live-input-mode-preference' + +vi.mock('../storage/preferences', () => ({ + readDisabledTerminalLiveInputHandlesPreference: vi.fn(), + saveDisabledTerminalLiveInputHandles: vi.fn() +})) + +type TerminalLiveInputModePreferenceHarness = { + readonly current: ReturnType + readonly unmount: () => void +} + +type Deferred = { + readonly promise: Promise + readonly resolve: (value: T) => void +} + +function createDeferred(): Deferred { + let resolve: ((value: T) => void) | null = null + const promise = new Promise((innerResolve) => { + resolve = innerResolve + }) + if (!resolve) { + throw new Error('deferred resolver was not initialized') + } + return { promise, resolve } +} + +function suppressReactTestRendererDeprecationWarning(): () => void { + const originalConsoleError = console.error + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + const firstArg = args[0] + if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) { + return + } + originalConsoleError(...args) + }) + return () => consoleErrorSpy.mockRestore() +} + +function createTerminalLiveInputModePreferenceHarness(): TerminalLiveInputModePreferenceHarness { + let current: ReturnType | null = null + let renderer: ReactTestRenderer | null = null + + function Harness(): null { + current = useTerminalLiveInputModePreference({ + hostId: 'host-1', + worktreeId: 'worktree-1' + }) + return null + } + + const restoreConsoleError = suppressReactTestRendererDeprecationWarning() + try { + act(() => { + renderer = create(createElement(Harness)) + }) + } finally { + restoreConsoleError() + } + if (!current || !renderer) { + throw new Error('terminal live input mode preference hook did not render') + } + + return { + get current() { + if (!current) { + throw new Error('terminal live input mode preference hook is not mounted') + } + return current + }, + unmount: () => { + act(() => renderer?.unmount()) + } + } +} + +describe('terminal live input mode preference hook', () => { + beforeEach(() => { + vi.mocked(readDisabledTerminalLiveInputHandlesPreference).mockReset() + vi.mocked(saveDisabledTerminalLiveInputHandles).mockReset() + vi.mocked(saveDisabledTerminalLiveInputHandles).mockResolvedValue() + }) + + it('merges pre-hydration edits with loaded disabled handles', async () => { + const load = createDeferred() + vi.mocked(readDisabledTerminalLiveInputHandlesPreference).mockReturnValue(load.promise) + const harness = createTerminalLiveInputModePreferenceHarness() + + act(() => { + harness.current.defaultTerminalHandlesToLiveInput(['pty-1', 'pty-2']) + }) + act(() => { + expect(harness.current.toggleTerminalLiveInput('pty-1')).toBe(true) + }) + + await act(async () => { + load.resolve({ handles: new Set(['pty-2']), loaded: true }) + await load.promise + }) + + expect([...harness.current.liveInputTerminalHandles]).toEqual(['pty-1']) + expect(saveDisabledTerminalLiveInputHandles).toHaveBeenCalledTimes(1) + expect(saveDisabledTerminalLiveInputHandles).toHaveBeenCalledWith( + 'host-1', + 'worktree-1', + new Set(['pty-2']) + ) + harness.unmount() + }) + + it('does not persist fallback-empty storage reads during clean hydration', async () => { + const load = createDeferred() + vi.mocked(readDisabledTerminalLiveInputHandlesPreference).mockReturnValue(load.promise) + const harness = createTerminalLiveInputModePreferenceHarness() + + act(() => { + harness.current.defaultTerminalHandlesToLiveInput(['pty-1']) + }) + + await act(async () => { + load.resolve({ handles: new Set(), loaded: false }) + await load.promise + }) + + expect([...harness.current.liveInputTerminalHandles]).toEqual(['pty-1']) + expect(saveDisabledTerminalLiveInputHandles).not.toHaveBeenCalled() + harness.unmount() + }) +}) diff --git a/mobile/src/session/use-terminal-live-input-mode-preference.ts b/mobile/src/session/use-terminal-live-input-mode-preference.ts new file mode 100644 index 00000000000..dd59449b816 --- /dev/null +++ b/mobile/src/session/use-terminal-live-input-mode-preference.ts @@ -0,0 +1,212 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { + readDisabledTerminalLiveInputHandlesPreference, + saveDisabledTerminalLiveInputHandles +} from '../storage/preferences' +import { + applyDisabledTerminalLiveInputHandles, + defaultTerminalLiveInputHandles, + filterTerminalLiveInputDefaultCandidates, + pruneTerminalLiveInputHandles +} from '../terminal/terminal-live-input' + +type UseTerminalLiveInputModePreferenceOptions = { + readonly hostId: string + readonly worktreeId: string +} + +export function useTerminalLiveInputModePreference({ + hostId, + worktreeId +}: UseTerminalLiveInputModePreferenceOptions) { + const [liveInputTerminalHandles, setLiveInputTerminalHandles] = useState>( + () => new Set() + ) + const liveInputTerminalHandlesRef = useRef>(new Set()) + const defaultedLiveInputTerminalHandlesRef = useRef>(new Set()) + const disabledLiveInputTerminalHandlesRef = useRef>(new Set()) + const disabledLiveInputHydratedRef = useRef(false) + const pendingDisabledLiveInputHydrationEditsRef = useRef>(new Map()) + const pendingLiveInputDefaultHandlesRef = useRef>(new Set()) + + const defaultTerminalHandlesToLiveInput = useCallback((handles: readonly string[]) => { + // Why: terminal discovery (tab snapshots, list poll, create) can arrive + // before the async persisted-disabled load on worktree re-entry. + if (!disabledLiveInputHydratedRef.current) { + for (const handle of handles) { + pendingLiveInputDefaultHandlesRef.current.add(handle) + } + return + } + const defaultableHandles = filterTerminalLiveInputDefaultCandidates( + handles, + disabledLiveInputTerminalHandlesRef.current + ) + const result = defaultTerminalLiveInputHandles( + liveInputTerminalHandlesRef.current, + defaultedLiveInputTerminalHandlesRef.current, + defaultableHandles + ) + if (!result.changed) { + return + } + const nextEnabledHandles = new Set(result.enabledHandles) + const nextDefaultedHandles = new Set(result.defaultedHandles) + liveInputTerminalHandlesRef.current = nextEnabledHandles + defaultedLiveInputTerminalHandlesRef.current = nextDefaultedHandles + setLiveInputTerminalHandles(nextEnabledHandles) + }, []) + + const persistDisabledLiveInputHandles = useCallback(() => { + void saveDisabledTerminalLiveInputHandles( + hostId, + worktreeId, + disabledLiveInputTerminalHandlesRef.current + ).catch(() => {}) + }, [hostId, worktreeId]) + + const pruneTerminalHandlesFromLiveInput = useCallback( + (liveHandles: ReadonlySet) => { + const result = pruneTerminalLiveInputHandles( + liveInputTerminalHandlesRef.current, + defaultedLiveInputTerminalHandlesRef.current, + liveHandles + ) + let prunedDisabledHandles = false + for (const handle of disabledLiveInputTerminalHandlesRef.current) { + if (liveHandles.has(handle)) { + continue + } + disabledLiveInputTerminalHandlesRef.current.delete(handle) + if (!disabledLiveInputHydratedRef.current) { + pendingDisabledLiveInputHydrationEditsRef.current.set(handle, false) + } + prunedDisabledHandles = true + } + if (prunedDisabledHandles && disabledLiveInputHydratedRef.current) { + persistDisabledLiveInputHandles() + } + if (!result.changed) { + return + } + const nextEnabledHandles = new Set(result.enabledHandles) + const nextDefaultedHandles = new Set(result.defaultedHandles) + liveInputTerminalHandlesRef.current = nextEnabledHandles + defaultedLiveInputTerminalHandlesRef.current = nextDefaultedHandles + setLiveInputTerminalHandles(nextEnabledHandles) + }, + [persistDisabledLiveInputHandles] + ) + + const clearTerminalLiveInputDefault = useCallback( + (handle: string) => { + const liveHandles = new Set([ + ...liveInputTerminalHandlesRef.current, + ...defaultedLiveInputTerminalHandlesRef.current + ]) + liveHandles.delete(handle) + if (!disabledLiveInputHydratedRef.current) { + pendingDisabledLiveInputHydrationEditsRef.current.set(handle, false) + } + if (disabledLiveInputTerminalHandlesRef.current.delete(handle)) { + if (disabledLiveInputHydratedRef.current) { + persistDisabledLiveInputHandles() + } + } + pruneTerminalHandlesFromLiveInput(liveHandles) + }, + [persistDisabledLiveInputHandles, pruneTerminalHandlesFromLiveInput] + ) + + const toggleTerminalLiveInput = useCallback( + (handle: string): boolean => { + const nextEnabled = !liveInputTerminalHandlesRef.current.has(handle) + if (nextEnabled) { + disabledLiveInputTerminalHandlesRef.current.delete(handle) + } else { + disabledLiveInputTerminalHandlesRef.current.add(handle) + } + // Why: pre-hydration edits must patch the loaded set per handle; replacing + // the loaded set would erase other persisted opt-outs for this worktree. + if (!disabledLiveInputHydratedRef.current) { + pendingDisabledLiveInputHydrationEditsRef.current.set(handle, !nextEnabled) + } + // Why: only persist after hydration; an earlier write would use the + // reset-empty ref and overwrite other handles' opt-outs for the worktree. + if (disabledLiveInputHydratedRef.current) { + persistDisabledLiveInputHandles() + } + setLiveInputTerminalHandles((prev) => { + const next = new Set(prev) + if (nextEnabled) { + next.add(handle) + } else { + next.delete(handle) + } + liveInputTerminalHandlesRef.current = next + return next + }) + return nextEnabled + }, + [persistDisabledLiveInputHandles] + ) + + useEffect(() => { + liveInputTerminalHandlesRef.current = new Set() + defaultedLiveInputTerminalHandlesRef.current = new Set() + disabledLiveInputTerminalHandlesRef.current = new Set() + disabledLiveInputHydratedRef.current = false + pendingDisabledLiveInputHydrationEditsRef.current = new Map() + pendingLiveInputDefaultHandlesRef.current = new Set() + setLiveInputTerminalHandles(new Set()) + + let disposed = false + // Why: load the persisted opt-outs first so defaulting logic (which can + // fire immediately from subscriptions) respects prior user choices. + void readDisabledTerminalLiveInputHandlesPreference(hostId, worktreeId).then((preference) => { + if (disposed) { + return + } + const pendingEdits = pendingDisabledLiveInputHydrationEditsRef.current + const hydratedDisabledHandles = new Set(preference.handles) + for (const [handle, disabled] of pendingEdits) { + if (disabled) { + hydratedDisabledHandles.add(handle) + } else { + hydratedDisabledHandles.delete(handle) + } + } + disabledLiveInputTerminalHandlesRef.current = hydratedDisabledHandles + disabledLiveInputHydratedRef.current = true + pendingDisabledLiveInputHydrationEditsRef.current = new Map() + if (preference.loaded && pendingEdits.size > 0) { + persistDisabledLiveInputHandles() + } + const result = applyDisabledTerminalLiveInputHandles( + liveInputTerminalHandlesRef.current, + defaultedLiveInputTerminalHandlesRef.current, + hydratedDisabledHandles + ) + const nextEnabledHandles = new Set(result.enabledHandles) + const nextDefaultedHandles = new Set(result.defaultedHandles) + liveInputTerminalHandlesRef.current = nextEnabledHandles + defaultedLiveInputTerminalHandlesRef.current = nextDefaultedHandles + setLiveInputTerminalHandles(nextEnabledHandles) + const pendingDefaultHandles = [...pendingLiveInputDefaultHandlesRef.current] + pendingLiveInputDefaultHandlesRef.current.clear() + defaultTerminalHandlesToLiveInput(pendingDefaultHandles) + }) + return () => { + disposed = true + } + }, [defaultTerminalHandlesToLiveInput, hostId, persistDisabledLiveInputHandles, worktreeId]) + + return { + clearTerminalLiveInputDefault, + defaultTerminalHandlesToLiveInput, + liveInputTerminalHandles, + liveInputTerminalHandlesRef, + pruneTerminalHandlesFromLiveInput, + toggleTerminalLiveInput + } +} diff --git a/mobile/src/storage/preferences.test.ts b/mobile/src/storage/preferences.test.ts index c0f8596900a..1e5aee95de1 100644 --- a/mobile/src/storage/preferences.test.ts +++ b/mobile/src/storage/preferences.test.ts @@ -8,9 +8,12 @@ import { HOST_SIDEBAR_MIN_WIDTH, clampHostDockWidth, clampHostSidebarWidth, + loadDisabledTerminalLiveInputHandles, loadHostSidebarWidth, loadTerminalAutocompleteEnabled, loadTerminalLinkOpenMode, + readDisabledTerminalLiveInputHandlesPreference, + saveDisabledTerminalLiveInputHandles, saveHostSidebarWidth, saveTerminalAutocompleteEnabled, saveTerminalLinkOpenMode @@ -63,6 +66,62 @@ describe('terminal autocomplete preference', () => { }) }) +describe('terminal live input disabled handles preference', () => { + beforeEach(() => { + vi.mocked(AsyncStorage.getItem).mockReset() + vi.mocked(AsyncStorage.setItem).mockReset() + }) + + it('defaults to no disabled handles when unset', async () => { + vi.mocked(AsyncStorage.getItem).mockResolvedValue(null) + + await expect(loadDisabledTerminalLiveInputHandles('host-1', 'worktree-1')).resolves.toEqual( + new Set() + ) + await expect( + readDisabledTerminalLiveInputHandlesPreference('host-1', 'worktree-1') + ).resolves.toEqual({ handles: new Set(), loaded: true }) + }) + + it('loads only string terminal handles from storage', async () => { + vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(['pty-1', 42, 'pty-2'])) + + await expect(loadDisabledTerminalLiveInputHandles('host-1', 'worktree-1')).resolves.toEqual( + new Set(['pty-1', 'pty-2']) + ) + }) + + it('falls back to no disabled handles for invalid or unreadable storage', async () => { + vi.mocked(AsyncStorage.getItem).mockResolvedValue('not-json') + + await expect(loadDisabledTerminalLiveInputHandles('host-1', 'worktree-1')).resolves.toEqual( + new Set() + ) + + vi.mocked(AsyncStorage.getItem).mockRejectedValue(new Error('storage unavailable')) + + await expect(loadDisabledTerminalLiveInputHandles('host-1', 'worktree-1')).resolves.toEqual( + new Set() + ) + await expect( + readDisabledTerminalLiveInputHandlesPreference('host-1', 'worktree-1') + ).resolves.toEqual({ handles: new Set(), loaded: false }) + }) + + it('persists disabled handles per host and worktree', async () => { + await saveDisabledTerminalLiveInputHandles( + 'host/one', + 'folder:C:\\repo', + new Set(['pty-2', 'pty-1']) + ) + + expect(AsyncStorage.setItem).toHaveBeenCalledWith( + 'orca:terminalLiveInputDisabled:host%2Fone:folder%3AC%3A%5Crepo', + JSON.stringify(['pty-2', 'pty-1']) + ) + }) +}) + describe('host sidebar width preference', () => { beforeEach(() => { vi.mocked(AsyncStorage.getItem).mockReset() diff --git a/mobile/src/storage/preferences.ts b/mobile/src/storage/preferences.ts index 5151397a7f0..d5b0db3a6e7 100644 --- a/mobile/src/storage/preferences.ts +++ b/mobile/src/storage/preferences.ts @@ -73,6 +73,53 @@ export async function saveTerminalAutocompleteEnabled(enabled: boolean): Promise await AsyncStorage.setItem(AUTOCOMPLETE_KEY, String(enabled)) } +const TERMINAL_LIVE_INPUT_DISABLED_PREFIX = 'orca:terminalLiveInputDisabled:' + +export type DisabledTerminalLiveInputHandlesPreference = { + readonly handles: Set + readonly loaded: boolean +} + +function terminalLiveInputDisabledKey(hostId: string, worktreeId: string): string { + return `${TERMINAL_LIVE_INPUT_DISABLED_PREFIX}${encodeURIComponent(hostId)}:${encodeURIComponent( + worktreeId + )}` +} + +export async function readDisabledTerminalLiveInputHandlesPreference( + hostId: string, + worktreeId: string +): Promise { + try { + const raw = await AsyncStorage.getItem(terminalLiveInputDisabledKey(hostId, worktreeId)) + if (!raw) { + return { handles: new Set(), loaded: true } + } + return { handles: new Set(stringArray(JSON.parse(raw))), loaded: true } + } catch { + return { handles: new Set(), loaded: false } + } +} + +export async function loadDisabledTerminalLiveInputHandles( + hostId: string, + worktreeId: string +): Promise> { + const preference = await readDisabledTerminalLiveInputHandlesPreference(hostId, worktreeId) + return preference.handles +} + +export async function saveDisabledTerminalLiveInputHandles( + hostId: string, + worktreeId: string, + handles: ReadonlySet +): Promise { + await AsyncStorage.setItem( + terminalLiveInputDisabledKey(hostId, worktreeId), + JSON.stringify([...handles]) + ) +} + const SIDEBAR_WIDTH_KEY = 'orca:hostSidebarWidth' // Bounds for the draggable host worktree-list sidebar on tablet/foldable diff --git a/mobile/src/terminal/terminal-live-input.test.ts b/mobile/src/terminal/terminal-live-input.test.ts index db3e96236bc..95b4960f928 100644 --- a/mobile/src/terminal/terminal-live-input.test.ts +++ b/mobile/src/terminal/terminal-live-input.test.ts @@ -1,8 +1,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { TERMINAL_LIVE_INPUT_MAX_BYTES, + applyDisabledTerminalLiveInputHandles, clearTerminalLiveInputFocusTimer, defaultTerminalLiveInputHandles, + filterTerminalLiveInputDefaultCandidates, getTerminalLiveSpecialKeyBytes, isTerminalLiveInputWithinByteLimit, pruneTerminalLiveInputHandles, @@ -101,6 +103,35 @@ describe('terminal live input', () => { expect(result.defaultedHandles).toBe(defaulted) }) + it('does not default persisted buffered-mode handles back to live input on reentry', () => { + const defaultableHandles = filterTerminalLiveInputDefaultCandidates( + ['pty-1', 'pty-2'], + new Set(['pty-1']) + ) + + const result = defaultTerminalLiveInputHandles( + new Set(), + new Set(['pty-1']), + defaultableHandles + ) + + expect(defaultableHandles).toEqual(['pty-2']) + expect([...result.enabledHandles]).toEqual(['pty-2']) + expect([...result.defaultedHandles]).toEqual(['pty-1', 'pty-2']) + }) + + it('reconciles persisted buffered-mode handles with currently enabled live input', () => { + const result = applyDisabledTerminalLiveInputHandles( + new Set(['pty-1', 'pty-2']), + new Set(['pty-2']), + new Set(['pty-1']) + ) + + expect(result.changed).toBe(true) + expect([...result.enabledHandles]).toEqual(['pty-2']) + expect([...result.defaultedHandles]).toEqual(['pty-2', 'pty-1']) + }) + it('prunes terminal handles that disappear from session snapshots', () => { const result = pruneTerminalLiveInputHandles( new Set(['pty-1', 'pty-stale']), diff --git a/mobile/src/terminal/terminal-live-input.ts b/mobile/src/terminal/terminal-live-input.ts index dd5f8e6a709..96f279a4855 100644 --- a/mobile/src/terminal/terminal-live-input.ts +++ b/mobile/src/terminal/terminal-live-input.ts @@ -118,6 +118,48 @@ export function defaultTerminalLiveInputHandles( } } +export function filterTerminalLiveInputDefaultCandidates( + terminalHandles: readonly string[], + disabledHandles: ReadonlySet +): string[] { + return terminalHandles.filter((handle) => !disabledHandles.has(handle)) +} + +export function applyDisabledTerminalLiveInputHandles( + enabledHandles: ReadonlySet, + defaultedHandles: ReadonlySet, + disabledHandles: ReadonlySet +): TerminalLiveInputDefaultResult { + let nextEnabledHandles: Set | null = null + let nextDefaultedHandles: Set | null = null + + for (const handle of enabledHandles) { + if (!disabledHandles.has(handle)) { + continue + } + nextEnabledHandles ??= new Set(enabledHandles) + nextEnabledHandles.delete(handle) + } + + for (const handle of disabledHandles) { + if (defaultedHandles.has(handle)) { + continue + } + nextDefaultedHandles ??= new Set(defaultedHandles) + nextDefaultedHandles.add(handle) + } + + if (!nextEnabledHandles && !nextDefaultedHandles) { + return { enabledHandles, defaultedHandles, changed: false } + } + + return { + enabledHandles: nextEnabledHandles ?? enabledHandles, + defaultedHandles: nextDefaultedHandles ?? defaultedHandles, + changed: true + } +} + export function pruneTerminalLiveInputHandles( enabledHandles: ReadonlySet, defaultedHandles: ReadonlySet, From 09fc57e11387e982db1b8a549b399f4db580351d Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Fri, 3 Jul 2026 02:24:24 -0400 Subject: [PATCH 17/48] fix(browser): keep CDP reload from closing Orca browser tabs Fix the per-tab CDP proxy reload path so raw Page.reload keeps the Orca browser tab alive. Lifecycle priming is bounded and root reloads use the same safer webContents reload behavior as Orca's own browser reload path. --- src/main/browser/cdp-ws-proxy.test.ts | 184 +++++++++++++++++++++++++- src/main/browser/cdp-ws-proxy.ts | 117 ++++++++++++++-- 2 files changed, 287 insertions(+), 14 deletions(-) diff --git a/src/main/browser/cdp-ws-proxy.test.ts b/src/main/browser/cdp-ws-proxy.test.ts index 9f345a2e6ee..11fbcd772cf 100644 --- a/src/main/browser/cdp-ws-proxy.test.ts +++ b/src/main/browser/cdp-ws-proxy.test.ts @@ -21,7 +21,9 @@ function createMockWebContents() { detach: vi.fn(() => { debuggerAttached = false }), - sendCommand: vi.fn(async () => ({})), + sendCommand: vi.fn( + async (_method?: string, _params?: Record, _sessionId?: string) => ({}) + ), on: vi.fn((event: string, handler: DebuggerListener) => { const arr = listeners.get(event) ?? [] arr.push(handler) @@ -41,6 +43,8 @@ function createMockWebContents() { debugger: debuggerObj, isDestroyed: () => destroyed, focus: vi.fn(), + reload: vi.fn(), + reloadIgnoringCache: vi.fn(), getTitle: vi.fn(() => 'Example'), getURL: vi.fn(() => 'https://example.com') }, @@ -89,6 +93,22 @@ describe('CdpWsProxy', () => { }) } + type SendCommandCall = [string, Record?, string?] + + function getSendCommandCalls(): SendCommandCall[] { + const calls = mock.webContents.debugger.sendCommand.mock.calls as unknown as [ + string, + Record?, + string? + ][] + return calls + } + + function getSendCommandMethods(): string[] { + const calls = getSendCommandCalls() + return calls.map((call) => call[0]) + } + it('starts on a random port and returns ws:// URL', () => { expect(endpoint).toMatch(/^ws:\/\/127\.0\.0\.1:\d+$/) expect(proxy.getPort()).toBeGreaterThan(0) @@ -311,6 +331,168 @@ describe('CdpWsProxy', () => { client.close() }) + it('primes lifecycle events for Page.navigate', async () => { + const client = await connect() + + const response = await sendAndReceive(client, { + id: 11, + method: 'Page.navigate', + params: { url: 'https://example.com/next' } + }) + + expect(response.id).toBe(11) + expect(response.result).toEqual({}) + expect(getSendCommandMethods()).toEqual([ + 'Page.enable', + 'Page.addScriptToEvaluateOnNewDocument', + 'Network.enable', + 'Page.enable', + 'Page.setLifecycleEventsEnabled', + 'Page.navigate' + ]) + client.close() + }) + + it('primes lifecycle events for Page.reload and preserves response id', async () => { + const client = await connect() + + const response = await sendAndReceive(client, { + id: 12, + method: 'Page.reload' + }) + + expect(response.id).toBe(12) + expect(response.result).toEqual({}) + expect(getSendCommandMethods()).toEqual([ + 'Page.enable', + 'Page.addScriptToEvaluateOnNewDocument', + 'Network.enable', + 'Page.enable', + 'Page.setLifecycleEventsEnabled' + ]) + expect(mock.webContents.reload).toHaveBeenCalledTimes(1) + expect(getSendCommandMethods()).not.toContain('Page.reload') + client.close() + }) + + it('preserves explicit Page.navigate session during lifecycle priming', async () => { + const client = await connect() + + await sendAndReceive(client, { + id: 14, + method: 'Page.navigate', + params: { url: 'https://example.com/frame' }, + sessionId: 'iframe-session-123' + }) + + expect(getSendCommandCalls().slice(2)).toEqual([ + ['Network.enable', {}, 'iframe-session-123'], + ['Page.enable', {}, 'iframe-session-123'], + ['Page.setLifecycleEventsEnabled', { enabled: true }, 'iframe-session-123'], + ['Page.navigate', { url: 'https://example.com/frame' }, 'iframe-session-123'] + ]) + client.close() + }) + + it('forwards explicit Page.reload session after lifecycle priming', async () => { + const client = await connect() + + await sendAndReceive(client, { + id: 15, + method: 'Page.reload', + params: { ignoreCache: true }, + sessionId: 'iframe-session-123' + }) + + expect(getSendCommandCalls().slice(2)).toEqual([ + ['Network.enable', {}, 'iframe-session-123'], + ['Page.enable', {}, 'iframe-session-123'], + ['Page.setLifecycleEventsEnabled', { enabled: true }, 'iframe-session-123'], + ['Page.reload', { ignoreCache: true }, 'iframe-session-123'] + ]) + expect(mock.webContents.reloadIgnoringCache).not.toHaveBeenCalled() + expect(mock.webContents.reload).not.toHaveBeenCalled() + client.close() + }) + + it('rejects root Page.reload params that direct webContents reload cannot honor', async () => { + const client = await connect() + + const response = await sendAndReceive(client, { + id: 16, + method: 'Page.reload', + params: { loaderId: 'stale-loader' } + }) + + expect(response).toEqual({ + id: 16, + error: { + code: -32000, + message: 'Page.reload parameter "loaderId" is not supported for Orca tab reloads' + } + }) + expect(mock.webContents.reload).not.toHaveBeenCalled() + expect(mock.webContents.reloadIgnoringCache).not.toHaveBeenCalled() + expect(getSendCommandMethods()).not.toContain('Network.enable') + client.close() + }) + + it('still reloads when lifecycle priming stalls', async () => { + const client = await connect() + mock.webContents.debugger.sendCommand.mockImplementation((method?: string) => { + if (method === 'Network.enable') { + return new Promise(() => {}) + } + return Promise.resolve({}) + }) + + const responsePromise = sendAndReceive(client, { + id: 17, + method: 'Page.reload' + }) + + await expect(responsePromise).resolves.toEqual({ id: 17, result: {} }) + expect(mock.webContents.reload).toHaveBeenCalledTimes(1) + client.close() + }) + + it('does not reload after the requesting client disconnects during priming', async () => { + const client = await connect() + mock.webContents.debugger.sendCommand.mockImplementation((method?: string) => { + if (method === 'Network.enable') { + return new Promise(() => {}) + } + return Promise.resolve({}) + }) + + client.send(JSON.stringify({ id: 18, method: 'Page.reload' })) + client.close() + + await new Promise((resolve) => setTimeout(resolve, 1_100)) + + expect(mock.webContents.reload).not.toHaveBeenCalled() + expect(mock.webContents.reloadIgnoringCache).not.toHaveBeenCalled() + }) + + it('forwards Runtime.evaluate without lifecycle priming', async () => { + const client = await connect() + + const response = await sendAndReceive(client, { + id: 13, + method: 'Runtime.evaluate', + params: { expression: 'document.readyState' } + }) + + expect(response.id).toBe(13) + expect(response.result).toEqual({}) + expect(getSendCommandMethods()).toEqual([ + 'Page.enable', + 'Page.addScriptToEvaluateOnNewDocument', + 'Runtime.evaluate' + ]) + client.close() + }) + // ── Page.frameNavigated interception ── // ── Cleanup ── diff --git a/src/main/browser/cdp-ws-proxy.ts b/src/main/browser/cdp-ws-proxy.ts index 2531822b982..ec8de5be176 100644 --- a/src/main/browser/cdp-ws-proxy.ts +++ b/src/main/browser/cdp-ws-proxy.ts @@ -6,6 +6,8 @@ import { captureScreenshot } from './cdp-screenshot' import { ANTI_DETECTION_SCRIPT } from './anti-detection' import { acquireElectronDebugger, type ElectronDebuggerLease } from './electron-debugger-lease' +const LIFECYCLE_PRIMING_TIMEOUT_MS = 1_000 + export class CdpWsProxy { private httpServer: Server | null = null private wss: WebSocketServer | null = null @@ -298,13 +300,39 @@ export class CdpWsProxy { } // Why: agent-browser waits for network idle to detect navigation completion. // Electron webview CDP subscriptions silently lapse after cross-process swaps. + // Page.reload needs the same priming: forwarding it unprimed closed the tab (#7031). if (msg.method === 'Page.navigate' && !this.webContents.isDestroyed()) { - void this.navigateWithLifecycleEnsured(client, clientId, msg.params ?? {}) + void this.navigateWithLifecycle(client, clientId, msg.params ?? {}, msg.sessionId) + return + } + // Why: CDP Page.reload can destroy Electron webview targets during process swaps. + // Use the same direct webContents reload path as Orca's own browser.reload. + if (msg.method === 'Page.reload' && !this.webContents.isDestroyed()) { + void this.reloadWithLifecycle(client, clientId, msg.params ?? {}, msg.sessionId) return } this.forwardCommand(client, clientId, msg.method, msg.params ?? {}, msg.sessionId) } + private resolveDebuggerSessionId(msgSessionId?: string): string | undefined { + return msgSessionId && msgSessionId !== this.clientSessionId ? msgSessionId : undefined + } + + private isActiveClient(client: WebSocket): boolean { + return this.client === client && client.readyState === WebSocket.OPEN + } + + private sendDebuggerCommand( + method: string, + params: Record, + sessionId?: string + ): Promise { + const command = sessionId + ? this.webContents.debugger.sendCommand(method, params, sessionId) + : this.webContents.debugger.sendCommand(method, params) + return Promise.resolve(command) + } + private forwardCommand( client: WebSocket, clientId: number, @@ -316,10 +344,9 @@ export class CdpWsProxy { this.sendError(clientId, 'Browser tab is no longer available', client) return } - const sessionId = - msgSessionId && msgSessionId !== this.clientSessionId ? msgSessionId : undefined + const sessionId = this.resolveDebuggerSessionId(msgSessionId) try { - Promise.resolve(this.webContents.debugger.sendCommand(method, params, sessionId)) + this.sendDebuggerCommand(method, params, sessionId) .then((result) => { this.sendResult(clientId, result, client) }) @@ -331,21 +358,85 @@ export class CdpWsProxy { } } - private async navigateWithLifecycleEnsured( + private async navigateWithLifecycle( client: WebSocket, clientId: number, - params: Record + params: Record, + msgSessionId?: string ): Promise { + await this.primePageLifecycle(this.resolveDebuggerSessionId(msgSessionId)) + if (!this.isActiveClient(client)) { + return + } + this.forwardCommand(client, clientId, 'Page.navigate', params, msgSessionId) + } + + private async reloadWithLifecycle( + client: WebSocket, + clientId: number, + params: Record, + msgSessionId?: string + ): Promise { + const sessionId = this.resolveDebuggerSessionId(msgSessionId) + const unsupportedParam = sessionId ? null : this.getUnsupportedRootReloadParam(params) + if (unsupportedParam) { + this.sendError( + clientId, + `Page.reload parameter "${unsupportedParam}" is not supported for Orca tab reloads`, + client + ) + return + } + await this.primePageLifecycle(sessionId) + if (!this.isActiveClient(client)) { + return + } + if (sessionId) { + this.forwardCommand(client, clientId, 'Page.reload', params, msgSessionId) + return + } + if (this.webContents.isDestroyed()) { + this.sendError(clientId, 'Browser tab is no longer available', client) + return + } try { - const dbg = this.webContents.debugger + if (params.ignoreCache === true) { + this.webContents.reloadIgnoringCache() + } else { + this.webContents.reload() + } + this.sendResult(clientId, {}, client) + } catch (err) { + this.sendError(clientId, err instanceof Error ? err.message : String(err), client) + } + } + + private getUnsupportedRootReloadParam(params: Record): string | null { + return Object.keys(params).find((key) => key !== 'ignoreCache') ?? null + } + + private async primePageLifecycle(sessionId?: string): Promise { + let timeout: ReturnType | null = null + const priming = (async (): Promise => { // Why: without Network.enable, agent-browser never sees network idle → goto times out. - await dbg.sendCommand('Network.enable', {}) - await dbg.sendCommand('Page.enable', {}) - await dbg.sendCommand('Page.setLifecycleEventsEnabled', { enabled: true }) - } catch { - /* best-effort */ + await this.sendDebuggerCommand('Network.enable', {}, sessionId) + await this.sendDebuggerCommand('Page.enable', {}, sessionId) + await this.sendDebuggerCommand('Page.setLifecycleEventsEnabled', { enabled: true }, sessionId) + })().catch(() => {}) + + try { + await Promise.race([ + priming, + new Promise((resolve) => { + timeout = setTimeout(resolve, LIFECYCLE_PRIMING_TIMEOUT_MS) + timeout.unref?.() + }) + ]) + } finally { + if (timeout) { + clearTimeout(timeout) + } } - this.forwardCommand(client, clientId, 'Page.navigate', params) } private handleScreenshot( From 2848c5daf47a3b76181179e42817f94582cded70 Mon Sep 17 00:00:00 2001 From: Mark Xian Date: Fri, 3 Jul 2026 14:37:12 +0800 Subject: [PATCH 18/48] Fix rename inputs committing on CJK IME Enter confirmation (#6980) Fixes #6980. --- .../settings/WorkspaceDirectorySetting.tsx | 10 ++-- ...eeTitleInlineRename.begin-editing.test.tsx | 50 ++++++++++++++++ .../sidebar/WorktreeTitleInlineRename.tsx | 6 ++ .../components/tab-bar/EditorFileTab.test.tsx | 57 ++++++++++++++++++- .../src/components/tab-bar/EditorFileTab.tsx | 6 ++ .../SortableTab.rename-shortcut.test.tsx | 49 +++++++++++++++- .../src/components/tab-bar/SortableTab.tsx | 6 ++ .../TerminalPaneHeaderOverlay.test.tsx | 53 +++++++++++++++-- .../TerminalPaneHeaderOverlay.tsx | 6 ++ .../ime-composition-keyboard-event.test.ts | 26 +++++++++ .../src/lib/ime-composition-keyboard-event.ts | 13 +++++ 11 files changed, 268 insertions(+), 14 deletions(-) create mode 100644 src/renderer/src/lib/ime-composition-keyboard-event.test.ts create mode 100644 src/renderer/src/lib/ime-composition-keyboard-event.ts diff --git a/src/renderer/src/components/settings/WorkspaceDirectorySetting.tsx b/src/renderer/src/components/settings/WorkspaceDirectorySetting.tsx index 70fa7e3eb7f..8633b735a96 100644 --- a/src/renderer/src/components/settings/WorkspaceDirectorySetting.tsx +++ b/src/renderer/src/components/settings/WorkspaceDirectorySetting.tsx @@ -14,6 +14,7 @@ import { Label } from '../ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' import { SearchableSetting } from './SearchableSetting' import { useSidebarHostScopeOptions } from '../sidebar/use-sidebar-host-scope-options' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { buildHostScopeChoices, CLIENT_DEFAULT_SCOPE, @@ -197,7 +198,9 @@ export function WorkspaceDirectorySetting({ }} onBlur={handleBlur} onKeyDown={(e) => { - if (isComposingKeyboardEvent(e)) { + // Why: an Enter that only confirms a CJK IME candidate must not + // commit the rename; wait for a non-composition Enter. + if (isImeCompositionKeyDown(e)) { return } if (e.key === 'Enter') { @@ -270,8 +273,3 @@ export function WorkspaceDirectorySetting({ ) } - -function isComposingKeyboardEvent(event: React.KeyboardEvent): boolean { - const nativeEvent = event.nativeEvent - return nativeEvent.isComposing || nativeEvent.keyCode === 229 -} diff --git a/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.begin-editing.test.tsx b/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.begin-editing.test.tsx index 7b017362b1d..8958dd58543 100644 --- a/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.begin-editing.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.begin-editing.test.tsx @@ -68,6 +68,7 @@ async function renderTitleRename(props: { beginEditing: boolean disabled?: boolean onBeginEditingConsumed: () => void + onRename?: (displayName: string) => Promise | void }): Promise { reactHookRuntime.index = 0 const module = await import('./WorktreeTitleInlineRename') @@ -120,6 +121,27 @@ function findElementsByType(node: unknown, typeName: string): ReactElementLike[] return results } +function pressInputKey( + input: ReactElementLike, + key: string, + options?: { isComposing?: boolean; keyCode?: number } +): { + preventDefault: ReturnType + stopPropagation: ReturnType +} { + const event = { + key, + nativeEvent: { + isComposing: options?.isComposing ?? false, + keyCode: options?.keyCode ?? 13 + }, + preventDefault: vi.fn(), + stopPropagation: vi.fn() + } + ;(input.props.onKeyDown as (nextEvent: typeof event) => void)(event) + return event +} + describe('WorktreeTitleInlineRename beginEditing', () => { beforeEach(() => { reactHookRuntime.states = [] @@ -152,4 +174,32 @@ describe('WorktreeTitleInlineRename beginEditing', () => { expect(onBeginEditingConsumed).toHaveBeenCalledTimes(1) expect(findElementsByType(rerender, 'input')).toHaveLength(0) }) + + it('ignores IME composition Enter before committing the edited title', async () => { + const onBeginEditingConsumed = vi.fn() + const onRename = vi.fn() + + await renderTitleRename({ beginEditing: true, onBeginEditingConsumed, onRename }) + let rerender = expandNode( + await renderTitleRename({ beginEditing: false, onBeginEditingConsumed, onRename }) + ) + let input = findElementsByType(rerender, 'input')[0] + ;(input.props.onChange as (event: { target: { value: string } }) => void)({ + target: { value: '日本語 workspace' } + }) + rerender = expandNode( + await renderTitleRename({ beginEditing: false, onBeginEditingConsumed, onRename }) + ) + input = findElementsByType(rerender, 'input')[0] + + const composingEvent = pressInputKey(input, 'Enter', { isComposing: true }) + + expect(composingEvent.preventDefault).not.toHaveBeenCalled() + expect(onRename).not.toHaveBeenCalled() + + pressInputKey(input, 'Enter') + await Promise.resolve() + + expect(onRename).toHaveBeenCalledWith('日本語 workspace') + }) }) diff --git a/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.tsx b/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.tsx index 914c3eb2f9a..d7a98fe2981 100644 --- a/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.tsx +++ b/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.tsx @@ -4,6 +4,7 @@ import { toast } from 'sonner' import { Input } from '@/components/ui/input' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { translate } from '@/i18n/i18n' export type WorktreeTitleRenameCommit = { kind: 'cancel' } | { kind: 'save'; displayName: string } @@ -211,6 +212,11 @@ export function WorktreeTitleInlineRename({ const handleKeyDown = useCallback( (event: React.KeyboardEvent) => { event.stopPropagation() + // Why: an Enter that only confirms a CJK IME candidate must not commit the + // rename; wait for a non-composition Enter. + if (isImeCompositionKeyDown(event)) { + return + } if (event.key === 'Enter') { event.preventDefault() void commitRename() diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx index 0d48f3dfe41..59a1c18bcee 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx @@ -19,6 +19,8 @@ const appStoreMocks = vi.hoisted(() => ({ })) })) +const renameFileOnDiskMock = vi.hoisted(() => vi.fn()) + vi.mock('react', async () => { const actual = await vi.importActual('react') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() return { @@ -147,7 +149,7 @@ vi.mock('@/components/editor/editor-labels', () => ({ })) vi.mock('@/lib/rename-file', () => ({ - renameFileOnDisk: vi.fn() + renameFileOnDisk: renameFileOnDiskMock })) vi.mock('@/lib/file-type-icons', () => ({ @@ -326,6 +328,27 @@ function findSpanByText(node: unknown, label: string): ReactElementLike { return span } +function pressInputKey( + input: ReactElementLike, + key: string, + options?: { isComposing?: boolean; keyCode?: number } +): { + preventDefault: ReturnType + stopPropagation: ReturnType +} { + const event = { + key, + nativeEvent: { + isComposing: options?.isComposing ?? false, + keyCode: options?.keyCode ?? 13 + }, + preventDefault: vi.fn(), + stopPropagation: vi.fn() + } + ;(input.props.onKeyDown as (nextEvent: typeof event) => void)(event) + return event +} + describe('EditorFileTab rename menu', () => { beforeEach(() => { reactHookRuntime.states = [] @@ -371,6 +394,38 @@ describe('EditorFileTab rename menu', () => { expect(select).not.toHaveBeenCalled() }) + it('ignores IME composition Enter before renaming the editor file tab', async () => { + const file = baseFile() + const firstRender = expandNode((await renderEditorFileTab(file)).element) + const renameItem = findMenuItemByText(firstRender, 'Rename') + + ;(renameItem.props.onSelect as () => void)() + + const secondRender = expandNode((await renderEditorFileTab(file)).element) + const input = findElementsByType(secondRender, 'input')[0] + const setInputRef = input.props.ref as (input: HTMLInputElement | null) => void + setInputRef({ + focus: vi.fn(), + select: vi.fn(), + setSelectionRange: vi.fn(), + value: '日本語.md' + } as unknown as HTMLInputElement) + + const composingEvent = pressInputKey(input, 'Enter', { isComposing: true }) + + expect(composingEvent.preventDefault).not.toHaveBeenCalled() + expect(renameFileOnDiskMock).not.toHaveBeenCalled() + + pressInputKey(input, 'Enter') + + expect(renameFileOnDiskMock).toHaveBeenCalledWith({ + oldPath: '/repo/untitled-5.md', + newName: '日本語.md', + worktreeId: 'wt-1', + worktreePath: '/repo' + }) + }) + it('disables Rename for diff tabs that do not map to one writable file', async () => { const file = baseFile({ mode: 'diff', diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.tsx index 694d03b0b59..3fb1a28ca5d 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.tsx @@ -6,6 +6,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { basename, normalizeRelativePath } from '@/lib/path' import { getEditorDisplayLabel } from '@/components/editor/editor-labels' import { renameFileOnDisk } from '@/lib/rename-file' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { detectLanguage } from '@/lib/language-detect' import { getFileTypeIcon } from '@/lib/file-type-icons' import { useRepoById, useWorktreeById } from '@/store/selectors' @@ -281,6 +282,11 @@ export default function EditorFileTab({ onClick={(e) => e.stopPropagation()} onDoubleClick={(e) => e.stopPropagation()} onKeyDown={(e) => { + // Why: an Enter that only confirms a CJK IME candidate must not + // commit the rename; wait for a non-composition Enter. + if (isImeCompositionKeyDown(e)) { + return + } if (e.key === 'Enter') { e.preventDefault() e.stopPropagation() diff --git a/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx b/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx index 10a81ab059c..bb4e757d1c8 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx @@ -208,7 +208,11 @@ function makeTerminalTab() { } } -async function renderSortableTab(): Promise { +async function renderSortableTab({ + onSetCustomTitle = vi.fn() +}: { + onSetCustomTitle?: (tabId: string, title: string | null) => void +} = {}): Promise { reactHookRuntime.index = 0 const module = await import('./SortableTab') return module.default({ @@ -224,7 +228,7 @@ async function renderSortableTab(): Promise { onClose: vi.fn(), onCloseOthers: vi.fn(), onCloseToRight: vi.fn(), - onSetCustomTitle: vi.fn(), + onSetCustomTitle, onSetTabColor: vi.fn(), onTogglePin: vi.fn(), onToggleExpand: vi.fn(), @@ -282,6 +286,25 @@ function findElementsByType(node: unknown, typeName: string): ReactElementLike[] return results } +function pressInputKey( + input: ReactElementLike, + key: string, + options?: { isComposing?: boolean; keyCode?: number } +): { + preventDefault: ReturnType +} { + const event = { + key, + nativeEvent: { + isComposing: options?.isComposing ?? false, + keyCode: options?.keyCode ?? 13 + }, + preventDefault: vi.fn() + } + ;(input.props.onKeyDown as (nextEvent: typeof event) => void)(event) + return event +} + describe('SortableTab rename shortcut signal', () => { beforeEach(() => { reactHookRuntime.states = [] @@ -312,4 +335,26 @@ describe('SortableTab rename shortcut signal', () => { expect(inputs[0].props.value).toBe('Runtime terminal title') expect(inputs[0].props['data-tab-rename-input']).toBe('true') }) + + it('ignores IME composition Enter before committing the custom tab title', async () => { + const onSetCustomTitle = vi.fn() + + await renderSortableTab({ onSetCustomTitle }) + let rerender = expandNode(await renderSortableTab({ onSetCustomTitle })) + let input = findElementsByType(rerender, 'input')[0] + ;(input.props.onChange as (event: { target: { value: string } }) => void)({ + target: { value: '日本語 terminal' } + }) + rerender = expandNode(await renderSortableTab({ onSetCustomTitle })) + input = findElementsByType(rerender, 'input')[0] + + const composingEvent = pressInputKey(input, 'Enter', { isComposing: true }) + + expect(composingEvent.preventDefault).not.toHaveBeenCalled() + expect(onSetCustomTitle).not.toHaveBeenCalled() + + pressInputKey(input, 'Enter') + + expect(onSetCustomTitle).toHaveBeenCalledWith('terminal-tab-1', '日本語 terminal') + }) }) diff --git a/src/renderer/src/components/tab-bar/SortableTab.tsx b/src/renderer/src/components/tab-bar/SortableTab.tsx index e19f1258059..3bef8529a75 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.tsx @@ -5,6 +5,7 @@ import { ShellIcon } from './shell-icons' import { AgentIcon } from '@/lib/agent-catalog' import { stripLeadingAgentTitleDecoration } from '../../../../shared/agent-title-decoration' import { useTabAgent } from '@/lib/use-tab-agent' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { Input } from '@/components/ui/input' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' @@ -333,6 +334,11 @@ export default function SortableTab({ onChange={(event) => setRenameValue(event.target.value)} onBlur={commitRename} onKeyDown={(event) => { + // Why: an Enter that only confirms a CJK IME candidate must not + // commit the rename; wait for a non-composition Enter. + if (isImeCompositionKeyDown(event)) { + return + } if (event.key === 'Enter') { event.preventDefault() commitRename() diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx index 78e80b3231e..80a386b3c8d 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx @@ -45,17 +45,24 @@ function renderOverlay({ paneCount = 2, showAlwaysOnHeaders = true, onClosePane = vi.fn(), - onRemoveTitle = vi.fn() + onRemoveTitle = vi.fn(), + onRenameSubmit = vi.fn(), + renameValue = '', + renamingPaneId = null }: { paneTitles: Record paneCount?: number showAlwaysOnHeaders?: boolean onClosePane?: ReturnType onRemoveTitle?: ReturnType + onRenameSubmit?: ReturnType + renameValue?: string + renamingPaneId?: number | null }): { container: HTMLDivElement onClosePane: ReturnType onRemoveTitle: ReturnType + onRenameSubmit: ReturnType } { const panes = [makePane(1), makePane(2)] const container = document.createElement('div') @@ -76,8 +83,8 @@ function renderOverlay({ 1: { left: 0, top: 0, width: 200 }, 2: { left: 220, top: 0, width: 200 } }} - renamingPaneId={null} - renameValue="" + renamingPaneId={renamingPaneId} + renameValue={renameValue} renameInputRef={createRef()} titleUsesLightSurface={false} paneTitleBackground="transparent" @@ -93,14 +100,31 @@ function renderOverlay({ onRemoveTitle={onRemoveTitle as (paneId: number) => void} onClosePane={onClosePane as (paneId: number) => void} onRenameValueChange={vi.fn()} - onRenameSubmit={vi.fn()} + onRenameSubmit={onRenameSubmit as () => void} onRenameCancel={vi.fn()} onRenameBlur={vi.fn()} /> ) }) mounted.push({ container, root }) - return { container, onClosePane, onRemoveTitle } + return { container, onClosePane, onRemoveTitle, onRenameSubmit } +} + +function pressInputKey( + input: HTMLInputElement, + key: string, + options?: { isComposing?: boolean; keyCode?: number } +): void { + act(() => { + const event = new KeyboardEvent('keydown', { key, bubbles: true }) + if (options?.isComposing !== undefined) { + Object.defineProperty(event, 'isComposing', { value: options.isComposing }) + } + if (options?.keyCode !== undefined) { + Object.defineProperty(event, 'keyCode', { value: options.keyCode }) + } + input.dispatchEvent(event) + }) } afterEach(() => { @@ -142,4 +166,23 @@ describe('TerminalPaneHeaderOverlay', () => { expect(onClosePane).toHaveBeenCalledWith(1) expect(onRemoveTitle).not.toHaveBeenCalled() }) + + it('ignores IME composition Enter before submitting a pane title rename', () => { + const { container, onRenameSubmit } = renderOverlay({ + paneTitles: { 1: 'server', 2: '' }, + renamingPaneId: 1, + renameValue: '日本語 pane' + }) + const input = container.querySelector('.pane-title-input') + + expect(input).not.toBeNull() + + pressInputKey(input as HTMLInputElement, 'Enter', { isComposing: true }) + + expect(onRenameSubmit).not.toHaveBeenCalled() + + pressInputKey(input as HTMLInputElement, 'Enter') + + expect(onRenameSubmit).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx index 3ad25d41bdd..fc1602f866e 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx @@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { translate } from '@/i18n/i18n' import { WORKSPACE_FILE_PATH_MIME, WORKSPACE_FILE_PATHS_MIME } from '@/lib/workspace-file-drag' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import type { PtyTransport } from './pty-transport' import { handleInternalTerminalFileDrop } from './terminal-drop-handler' @@ -183,6 +184,11 @@ export default function TerminalPaneHeaderOverlay({ value={renameValue} onChange={(event) => onRenameValueChange(event.target.value)} onKeyDown={(event) => { + // Why: an Enter that only confirms a CJK IME candidate must + // not commit the rename; wait for a non-composition Enter. + if (isImeCompositionKeyDown(event)) { + return + } if (event.key === 'Enter') { onRenameSubmit() } else if (event.key === 'Escape') { diff --git a/src/renderer/src/lib/ime-composition-keyboard-event.test.ts b/src/renderer/src/lib/ime-composition-keyboard-event.test.ts new file mode 100644 index 00000000000..daa02c5411e --- /dev/null +++ b/src/renderer/src/lib/ime-composition-keyboard-event.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import type { KeyboardEvent as ReactKeyboardEvent } from 'react' +import { isImeCompositionKeyDown } from './ime-composition-keyboard-event' + +function keyEvent(nativeEvent: { isComposing?: boolean; keyCode?: number }): ReactKeyboardEvent { + return { + nativeEvent: { + isComposing: nativeEvent.isComposing ?? false, + keyCode: nativeEvent.keyCode ?? 13 + } + } as unknown as ReactKeyboardEvent +} + +describe('isImeCompositionKeyDown', () => { + it('is true while the IME is composing', () => { + expect(isImeCompositionKeyDown(keyEvent({ isComposing: true }))).toBe(true) + }) + + it('is true for the keyCode 229 fallback when isComposing is not set', () => { + expect(isImeCompositionKeyDown(keyEvent({ isComposing: false, keyCode: 229 }))).toBe(true) + }) + + it('is false for a plain Enter outside of composition', () => { + expect(isImeCompositionKeyDown(keyEvent({ isComposing: false, keyCode: 13 }))).toBe(false) + }) +}) diff --git a/src/renderer/src/lib/ime-composition-keyboard-event.ts b/src/renderer/src/lib/ime-composition-keyboard-event.ts new file mode 100644 index 00000000000..c5faed78d16 --- /dev/null +++ b/src/renderer/src/lib/ime-composition-keyboard-event.ts @@ -0,0 +1,13 @@ +import type { KeyboardEvent as ReactKeyboardEvent } from 'react' + +/** + * Why: CJK IMEs (Japanese/Chinese/Korean) fire a keydown for the Enter that + * only confirms a conversion candidate. Rename/title inputs that commit on + * `Enter` must ignore that keydown, otherwise they submit mid-composition with a + * half-converted value. `isComposing` covers most browsers; `keyCode === 229` is + * a defensive fallback for IMEs that don't set `isComposing` on keydown. + */ +export function isImeCompositionKeyDown(event: ReactKeyboardEvent): boolean { + const nativeEvent = event.nativeEvent + return nativeEvent.isComposing || nativeEvent.keyCode === 229 +} From 10e8f8689975fe7fa6faef3012bf276cc3e0a382 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 2 Jul 2026 23:43:35 -0700 Subject: [PATCH 19/48] Coalesce PTY input write bursts (#7205) Co-authored-by: Orca --- .../pty-input-write-queue.test.ts | 157 ++++++++++++++ .../terminal-pane/pty-input-write-queue.ts | 155 ++++++++++++++ .../components/terminal-pane/pty-transport.ts | 101 +-------- .../pane-terminal-mouse-wheel.test.ts | 150 +++++++++++++- .../pane-manager/pane-terminal-mouse-wheel.ts | 4 +- .../pane-terminal-tui-wheel-reports.ts | 54 ++++- .../fixtures/visible-tui-scroll-fixture.cjs | 43 +++- tests/e2e/terminal-tui-wheel-drain.spec.ts | 195 ++++++++++++++++++ 8 files changed, 756 insertions(+), 103 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-input-write-queue.ts create mode 100644 tests/e2e/terminal-tui-wheel-drain.spec.ts diff --git a/src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts b/src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts new file mode 100644 index 00000000000..ddf856f05ae --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from 'vitest' +import { + TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS, + createPtyInputWriteQueue +} from './pty-input-write-queue' +import { + TERMINAL_INPUT_CHUNK_MAX_BYTES, + TERMINAL_INPUT_MAX_BYTES +} from '../../../../shared/terminal-input' + +const WHEEL_UP_REPORT = '\x1b[<64;60;20M' + +type WriteRecord = { id: string; data: string } + +function createRecordingQueue(options: { writable?: () => boolean } = {}): { + writes: WriteRecord[] + queue: ReturnType +} { + const writes: WriteRecord[] = [] + const queue = createPtyInputWriteQueue({ + isWritable: () => options.writable?.() ?? true, + write: (id, data) => writes.push({ id, data }) + }) + return { writes, queue } +} + +describe('pty input write queue', () => { + it('coalesces a dense burst of wheel reports instead of one write per macrotask turn', async () => { + const { writes, queue } = createRecordingQueue() + + // Simulates a 2s aggressive trackpad gesture at 120Hz: 240 SGR reports + // enqueued while the drain cannot run between events. + for (let i = 0; i < 240; i += 1) { + expect(queue.enqueue('pty-1', WHEEL_UP_REPORT)).toBe(true) + } + await queue.waitForDrain() + + // First report flushes immediately (keystroke latency); everything queued + // behind it must drain as a single coalesced write, not 239 timer turns. + expect(writes.length).toBe(2) + expect(writes[0]?.data).toBe(WHEEL_UP_REPORT) + expect(writes[1]?.data).toBe(WHEEL_UP_REPORT.repeat(239)) + expect(writes.map((write) => write.id)).toEqual(['pty-1', 'pty-1']) + }) + + it('preserves byte order and content across coalesced writes', async () => { + const { writes, queue } = createRecordingQueue() + + const inputs = ['a', '\x1b[<65;1;1M', 'bc', '\x1b[A', 'd'] + for (const input of inputs) { + queue.enqueue('pty-1', input) + } + await queue.waitForDrain() + + expect(writes.map((write) => write.data).join('')).toBe(inputs.join('')) + }) + + it('does not coalesce across different PTY ids', async () => { + const writes: WriteRecord[] = [] + const queue = createPtyInputWriteQueue({ + isWritable: () => true, + write: (id, data) => writes.push({ id, data }) + }) + + queue.enqueue('pty-1', 'a') + queue.enqueue('pty-1', 'b') + queue.enqueue('pty-2', 'c') + queue.enqueue('pty-1', 'd') + await queue.waitForDrain() + + expect(writes).toEqual([ + { id: 'pty-1', data: 'a' }, + { id: 'pty-1', data: 'b' }, + { id: 'pty-2', data: 'c' }, + { id: 'pty-1', data: 'd' } + ]) + }) + + it('keeps coalesced payloads under the input chunk byte cap', async () => { + const { writes, queue } = createRecordingQueue() + + const piece = 'x'.repeat(1000) + for (let i = 0; i < 12; i += 1) { + queue.enqueue('pty-1', piece) + } + await queue.waitForDrain() + + expect(writes.map((write) => write.data).join('')).toBe(piece.repeat(12)) + for (const write of writes) { + expect(write.data.length).toBeLessThanOrEqual(TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS) + } + expect(writes.length).toBeGreaterThan(1) + }) + + it('still chunks oversized items and keeps trailing input ordered behind them', async () => { + const { writes, queue } = createRecordingQueue() + + const large = 'y'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES * 2 + 100) + queue.enqueue('pty-1', 'before') + queue.enqueue('pty-1', large) + queue.enqueue('pty-1', 'after') + await queue.waitForDrain() + + expect(writes.map((write) => write.data).join('')).toBe(`before${large}after`) + expect(writes.at(-1)?.data).toBe('after') + for (const write of writes) { + expect(write.data.length).toBeLessThanOrEqual(TERMINAL_INPUT_CHUNK_MAX_BYTES) + } + }) + + it('rejects input over the terminal input byte limit without writing', async () => { + const { writes, queue } = createRecordingQueue() + + expect(queue.enqueue('pty-1', 'z'.repeat(TERMINAL_INPUT_MAX_BYTES + 1))).toBe(false) + await queue.waitForDrain() + + expect(writes).toEqual([]) + }) + + it('drops queued input for PTYs that are no longer writable', async () => { + let writable = true + const { writes, queue } = createRecordingQueue({ writable: () => writable }) + + queue.enqueue('pty-1', 'a') + writable = false + queue.enqueue('pty-1', 'b') + await queue.waitForDrain() + + expect(writes).toEqual([{ id: 'pty-1', data: 'a' }]) + }) + + it('clear() drops pending input that has not been written yet', async () => { + const writes: WriteRecord[] = [] + const pendingYields: (() => void)[] = [] + const queue = createPtyInputWriteQueue({ + isWritable: () => true, + write: (id, data) => writes.push({ id, data }), + yieldBetweenWrites: () => + new Promise((resolve) => { + pendingYields.push(resolve) + }) + }) + + const large = 'y'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES * 3) + queue.enqueue('pty-1', large) + queue.enqueue('pty-1', 'tail') + // First chunk is written synchronously, then the drain parks on the yield. + expect(writes.length).toBe(1) + + queue.clear() + pendingYields.shift()?.() + await queue.waitForDrain() + + expect(writes.length).toBe(1) + expect(writes.map((write) => write.data).join('')).not.toContain('tail') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-input-write-queue.ts b/src/renderer/src/components/terminal-pane/pty-input-write-queue.ts new file mode 100644 index 00000000000..b20e764df15 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-input-write-queue.ts @@ -0,0 +1,155 @@ +import { + isTerminalInputTooLargeWithDeferredMeasurement, + iterateTerminalInputChunks +} from '../../../../shared/terminal-input' + +// Why: 4096 UTF-16 code units encode to at most ~12KB UTF-8, safely under the +// 16KB TERMINAL_INPUT_CHUNK_MAX_BYTES cap without paying byte measurement on +// the hot input path. +export const TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS = 4096 + +type PendingPtyInputWrite = { + id: string + text: string + tooLarge: boolean | Promise + chunks?: Iterator + nextChunk?: string +} + +export type PtyInputWriteQueue = { + enqueue: (id: string, data: string) => boolean + waitForDrain: () => Promise + clear: () => void +} + +export type PtyInputWriteQueueDeps = { + isWritable: (id: string) => boolean + write: (id: string, data: string) => void + yieldBetweenWrites?: () => Promise +} + +function defaultYieldBetweenWrites(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)) +} + +function isCoalescibleText(text: string): boolean { + return text.length <= TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS +} + +export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInputWriteQueue { + const yieldBetweenWrites = deps.yieldBetweenWrites ?? defaultYieldBetweenWrites + let pending: PendingPtyInputWrite[] = [] + let drainPromise: Promise | null = null + + async function drain(): Promise { + while (pending.length > 0) { + const next = pending[0] + if (!next) { + pending.shift() + continue + } + if (!deps.isWritable(next.id)) { + pending.shift() + continue + } + if (next.tooLarge !== false) { + next.tooLarge = await Promise.resolve(next.tooLarge).catch(() => true) + if (next.tooLarge) { + pending.shift() + continue + } + if (!deps.isWritable(next.id)) { + pending.shift() + continue + } + } + // Why: dense input streams (SGR wheel reports during trackpad momentum, + // key auto-repeat) enqueue one tiny item per event. Writing one item per + // macrotask turn lets Chromium's nested-timer clamp pace the drain at + // ≥4ms per item, so a fast gesture's reports reach the PTY seconds after + // the gesture ended and the TUI visibly replays them one by one. + // Coalescing consecutive validated small items into a single write keeps + // the PTY byte stream identical while draining the backlog in one turn. + if (next.chunks === undefined && isCoalescibleText(next.text)) { + let payload = next.text + pending.shift() + while (pending.length > 0) { + const peek = pending[0] + if ( + !peek || + peek.id !== next.id || + peek.tooLarge !== false || + peek.chunks !== undefined || + !isCoalescibleText(peek.text) || + payload.length + peek.text.length > TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS + ) { + break + } + payload += peek.text + pending.shift() + } + deps.write(next.id, payload) + if (pending.length > 0) { + await yieldBetweenWrites() + } + continue + } + next.chunks ??= iterateTerminalInputChunks(next.text) + const chunk = + next.nextChunk === undefined ? next.chunks.next() : { done: false, value: next.nextChunk } + next.nextChunk = undefined + if (chunk.done) { + pending.shift() + continue + } + deps.write(next.id, chunk.value) + const following = next.chunks.next() + if (following.done) { + pending.shift() + } else { + next.nextChunk = following.value + } + if (pending.length > 0) { + await yieldBetweenWrites() + } + } + } + + function scheduleDrain(): void { + if (drainPromise) { + return + } + drainPromise = drain().finally(() => { + drainPromise = null + if (pending.length > 0) { + scheduleDrain() + } + }) + } + + return { + enqueue(id: string, data: string): boolean { + try { + const tooLarge = isTerminalInputTooLargeWithDeferredMeasurement(data) + if (tooLarge === true) { + return false + } + pending.push({ id, text: data, tooLarge }) + scheduleDrain() + return true + } catch { + return false + } + }, + + async waitForDrain(): Promise { + while (drainPromise) { + await drainPromise + } + }, + + clear(): void { + pending = [] + } + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index 474ee267832..f15f6ea990b 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -23,6 +23,7 @@ import { getEagerPtyBufferHandle } from './pty-dispatcher' import { drainPreHandlerPtyData, drainPreHandlerPtyExit } from './pty-pre-handler-buffer' +import { createPtyInputWriteQueue } from './pty-input-write-queue' import type { PtyDataMeta } from './pty-dispatcher' import type { IpcPtyTransportOptions, PtyConnectResult, PtyTransport } from './pty-transport-types' import { createBellDetector } from './bell-detector' @@ -59,14 +60,6 @@ const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED' const STALE_TITLE_TIMEOUT = 3000 // ms before stale working title is cleared const MAX_PTY_SIDE_EFFECTS_PER_DRAIN = 64 -type PendingPtyInputWrite = { - id: string - text: string - tooLarge: boolean | Promise - chunks?: Iterator - nextChunk?: string -} - // Why: onAgentStatus callback added to IpcPtyTransportOptions in pty-dispatcher // so the OSC 9999 status payloads can be forwarded to the store. @@ -472,8 +465,10 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra // unread marks, or notifications for unrelated worktrees just because Orca // is reconnecting background terminals on launch. let suppressAttentionEvents = false - let pendingInputWrites: PendingPtyInputWrite[] = [] - let inputWriteDrainPromise: Promise | null = null + const inputWriteQueue = createPtyInputWriteQueue({ + isWritable: (id) => connected && ptyId === id, + write: (id, data) => window.api.pty.write(id, data) + }) const outputProcessor = createPtyOutputProcessor({ onTitleChange, onBell, @@ -541,84 +536,6 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra return new Promise((resolve) => setTimeout(resolve, 0)) } - async function drainPendingInputWrites(): Promise { - while (pendingInputWrites.length > 0) { - const next = pendingInputWrites[0] - if (!next) { - continue - } - if (!connected || ptyId !== next.id) { - pendingInputWrites.shift() - continue - } - if (next.tooLarge !== false) { - next.tooLarge = await Promise.resolve(next.tooLarge).catch(() => true) - if (next.tooLarge) { - pendingInputWrites.shift() - continue - } - if (!connected || ptyId !== next.id) { - pendingInputWrites.shift() - continue - } - } - next.chunks ??= iterateTerminalInputChunks(next.text) - const chunk = - next.nextChunk === undefined ? next.chunks.next() : { done: false, value: next.nextChunk } - next.nextChunk = undefined - if (chunk.done) { - pendingInputWrites.shift() - continue - } - window.api.pty.write(next.id, chunk.value) - const following = next.chunks.next() - if (following.done) { - pendingInputWrites.shift() - } else { - next.nextChunk = following.value - } - if (pendingInputWrites.length > 0) { - await yieldToInputWriteDrain() - } - } - } - - function schedulePendingInputWriteDrain(): void { - if (inputWriteDrainPromise) { - return - } - inputWriteDrainPromise = drainPendingInputWrites().finally(() => { - inputWriteDrainPromise = null - if (pendingInputWrites.length > 0) { - schedulePendingInputWriteDrain() - } - }) - } - - function clearPendingInputWrites(): void { - pendingInputWrites = [] - } - - function enqueuePtyInputWrite(id: string, data: string): boolean { - try { - const tooLarge = isTerminalInputTooLargeWithDeferredMeasurement(data) - if (tooLarge === true) { - return false - } - pendingInputWrites.push({ id, text: data, tooLarge }) - schedulePendingInputWriteDrain() - return true - } catch { - return false - } - } - - async function waitForPendingInputWrites(): Promise { - while (inputWriteDrainPromise) { - await inputWriteDrainPromise - } - } - async function writeAcceptedPtyInput(id: string, data: string): Promise { try { const tooLarge = isTerminalInputTooLargeWithDeferredMeasurement(data) @@ -887,7 +804,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra disconnect() { clearAccumulatedState() - clearPendingInputWrites() + inputWriteQueue.clear() if (ptyId) { const id = ptyId window.api.pty.kill(id) @@ -900,7 +817,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra detach() { clearAccumulatedState() - clearPendingInputWrites() + inputWriteQueue.clear() if (ptyId) { // Why: detach() is used for in-session remounts such as moving a tab // between split groups. Stop delivering data/title events into the @@ -918,7 +835,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra if (!connected || !ptyId) { return false } - return enqueuePtyInputWrite(ptyId, data) + return inputWriteQueue.enqueue(ptyId, data) }, ...(connectionId @@ -929,7 +846,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra return false } const id = ptyId - await waitForPendingInputWrites() + await inputWriteQueue.waitForDrain() if (!connected || ptyId !== id) { return false } diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.test.ts b/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.test.ts index 3a15574255f..05190661d5d 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.test.ts @@ -243,6 +243,66 @@ describe('terminal mouse wheel multiplier', () => { expect(reports).toEqual([0, 0, 0, 1]) }) + it('does not burst-boost rapid trackpad-like pixel deltas', () => { + const state = createTerminalTuiMouseWheelDistanceState() + + const reports = [0, 16, 32, 48].map((timeStamp) => + resolveTerminalTuiMouseWheelReportCount( + { deltaY: 4, deltaMode: DOM_DELTA_PIXEL, timeStamp }, + 1, + state, + { cellHeight: 16 } + ) + ) + + expect(reports).toEqual([0, 0, 0, 1]) + }) + + it('tracks a decaying trackpad-like momentum tail with linear row distance', () => { + const state = createTerminalTuiMouseWheelDistanceState() + + const reports = [16, 20, 24, 28, 20, 12, 6, 3].map((deltaY, index) => + resolveTerminalTuiMouseWheelReportCount( + { deltaY, deltaMode: DOM_DELTA_PIXEL, timeStamp: index * 16 }, + 1, + state, + { cellHeight: 16 } + ) + ) + + expect(reports).toEqual([1, 1, 1, 2, 1, 1, 0, 1]) + }) + + it('drops pending trackpad distance on each direction change', () => { + const state = createTerminalTuiMouseWheelDistanceState() + + const reports = [16, 20, 24, -16, -20, -24, -18, -10, 6, -4].map((deltaY, index) => + resolveTerminalTuiMouseWheelReportCount( + { deltaY, deltaMode: DOM_DELTA_PIXEL, timeStamp: index * 16 }, + 1, + state, + { cellHeight: 16 } + ) + ) + + expect(reports).toEqual([1, 1, 1, 1, 1, 1, 1, 1, 0, 0]) + }) + + it('emits the full linear distance for a fast trackpad-like flick event', () => { + const state = createTerminalTuiMouseWheelDistanceState() + + const reports = [16 * 12, 16 * 12, 16 * 12].map((deltaY, index) => + resolveTerminalTuiMouseWheelReportCount( + { deltaY, deltaMode: DOM_DELTA_PIXEL, timeStamp: index * 16 }, + 1, + state, + { cellHeight: 16 } + ) + ) + + expect(reports).toEqual([12, 12, 12]) + }) + it('resets pending fractional distance when the user changes direction', () => { const state = createTerminalTuiMouseWheelDistanceState() @@ -269,7 +329,7 @@ describe('terminal mouse wheel multiplier', () => { expect(shouldMultiplyTerminalMouseWheel(wheelEvent(), terminalElement(false))).toBe(false) }) - it('leaves trackpad-like pixel scrolling one-to-one', () => { + it('handles trackpad-like TUI pixel scrolling while mouse reporting is active', () => { expect( shouldMultiplyTerminalMouseWheel( wheelEvent({ @@ -278,7 +338,7 @@ describe('terminal mouse wheel multiplier', () => { }), terminalElement() ) - ).toBe(false) + ).toBe(true) }) it('multiplies notched mouse wheel ticks even when Chromium exposes a small pixel delta', () => { @@ -358,6 +418,92 @@ describe('terminal mouse wheel multiplier', () => { expect(shouldMultiplyTerminalMouseWheel(dispatched[0]!, target)).toBe(false) }) + it('replays trackpad-like TUI pixel scrolling with responsive direction reversal', async () => { + vi.stubGlobal('WheelEvent', TestWheelEvent) + const handlers: ((event: WheelEvent) => boolean)[] = [] + const target = Object.assign(new EventTarget(), { + classList: { + contains: (className: string) => className === 'enable-mouse-events' + } + }) as unknown as EventTarget & HTMLElement + const dispatched: WheelEvent[] = [] + target.addEventListener('wheel', (event) => dispatched.push(event as WheelEvent)) + attachTerminalMouseWheelMultiplier( + { + attachCustomWheelEventHandler: (handler) => { + handlers.push(handler) + }, + element: target, + rows: 24 + }, + { getTuiMouseWheelMultiplier: () => 1 } + ) + + const events = [ + [4, 0], + [4, 16], + [4, 32], + [4, 48], + [-4, 64], + [-4, 80], + [-4, 96], + [-4, 112] + ].map(([deltaY, timeStamp]) => { + const event = new TestWheelEvent('wheel', { + bubbles: true, + cancelable: true, + deltaMode: DOM_DELTA_PIXEL, + deltaY + }) as WheelEvent + Object.defineProperty(event, 'timeStamp', { + configurable: true, + value: timeStamp + }) + return event + }) + + for (const event of events) { + expect(handlers[0]?.(event)).toBe(false) + await Promise.resolve() + } + + expect(dispatched.map((entry) => entry.deltaY)).toEqual([1, -1]) + }) + + it('replays a fast trackpad-like flick as one full synthetic report batch', async () => { + vi.stubGlobal('WheelEvent', TestWheelEvent) + const handlers: ((event: WheelEvent) => boolean)[] = [] + const target = Object.assign(new EventTarget(), { + classList: { + contains: (className: string) => className === 'enable-mouse-events' + } + }) as unknown as EventTarget & HTMLElement + const dispatched: WheelEvent[] = [] + target.addEventListener('wheel', (event) => dispatched.push(event as WheelEvent)) + attachTerminalMouseWheelMultiplier( + { + attachCustomWheelEventHandler: (handler) => { + handlers.push(handler) + }, + element: target, + rows: 24 + }, + { getTuiMouseWheelMultiplier: () => 1 } + ) + + const event = new TestWheelEvent('wheel', { + bubbles: true, + cancelable: true, + deltaMode: DOM_DELTA_PIXEL, + deltaY: 16 * 12 + }) as WheelEvent + + expect(handlers[0]?.(event)).toBe(false) + await Promise.resolve() + + expect(dispatched.map((entry) => entry.deltaY)).toEqual(Array(12).fill(1)) + }) + it('drains resolved TUI wheel reports without a frame-rate cap', async () => { vi.stubGlobal('WheelEvent', TestWheelEvent) const handlers: ((event: WheelEvent) => boolean)[] = [] diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.ts b/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.ts index ab192e393f7..f35c31efba7 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.ts @@ -1,7 +1,6 @@ import type { Terminal } from '@xterm/xterm' import { createTerminalTuiMouseWheelDistanceState, - isDiscreteTerminalTuiWheelEvent, normalizeTerminalTuiMouseWheelMultiplier, resolveTerminalTuiMouseWheelReportCount, resolveTerminalWheelDirection @@ -110,8 +109,7 @@ export function shouldMultiplyTerminalMouseWheel( isReplayedWheelEvent(event) || !terminalElement?.classList.contains(XTERM_MOUSE_REPORTING_CLASS) || event.deltaY === 0 || - event.shiftKey || - !isDiscreteTerminalTuiWheelEvent(event) + event.shiftKey ) { return false } diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-tui-wheel-reports.ts b/src/renderer/src/lib/pane-manager/pane-terminal-tui-wheel-reports.ts index 1b92edb390f..8736dc6504e 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-tui-wheel-reports.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-tui-wheel-reports.ts @@ -67,6 +67,11 @@ function legacyVerticalWheelDelta(event: TerminalTuiWheelEventInput): number | n return null } +function hasDiscreteLegacyWheelDelta(event: TerminalTuiWheelEventInput): boolean { + const legacyDelta = legacyVerticalWheelDelta(event) + return legacyDelta !== null && Math.abs(legacyDelta) >= LEGACY_MOUSE_WHEEL_DELTA_MIN +} + export function isDiscreteTerminalTuiWheelEvent(event: TerminalTuiWheelEventInput): boolean { if ((event.deltaMode ?? DOM_DELTA_PIXEL) !== DOM_DELTA_PIXEL) { return true @@ -76,8 +81,21 @@ export function isDiscreteTerminalTuiWheelEvent(event: TerminalTuiWheelEventInpu return true } - const legacyDelta = legacyVerticalWheelDelta(event) - return legacyDelta !== null && Math.abs(legacyDelta) >= LEGACY_MOUSE_WHEEL_DELTA_MIN + return hasDiscreteLegacyWheelDelta(event) +} + +function canBurstBoostWheelEvent(event: TerminalTuiWheelEventInput): boolean { + if ((event.deltaMode ?? DOM_DELTA_PIXEL) !== DOM_DELTA_PIXEL) { + return true + } + + return hasDiscreteLegacyWheelDelta(event) +} + +function isTrackpadLikePixelWheelEvent(event: TerminalTuiWheelEventInput): boolean { + return ( + (event.deltaMode ?? DOM_DELTA_PIXEL) === DOM_DELTA_PIXEL && !hasDiscreteLegacyWheelDelta(event) + ) } function wheelInputTime(event: TerminalTuiWheelEventInput): number | null { @@ -130,6 +148,13 @@ function resolveBurstWheelDistanceRows( state: TerminalTuiMouseWheelDistanceState, distanceRows: number ): number { + if (!canBurstBoostWheelEvent(event)) { + state.fastStreak = 0 + state.lastDistanceRows = null + state.lastInputAt = null + return 0 + } + const currentInputAt = wheelInputTime(event) if (currentInputAt === null) { state.fastStreak = 0 @@ -165,6 +190,26 @@ function resolveBurstWheelDistanceRows( return TUI_WHEEL_BURST_MAX_BONUS_ROWS * cadence * (state.fastStreak / TUI_WHEEL_BURST_RAMP_EVENTS) } +function resolveTrackpadPixelWheelReportCount( + event: TerminalTuiWheelEventInput, + state: TerminalTuiMouseWheelDistanceState, + distanceRows: number +): number | null { + if (!isTrackpadLikePixelWheelEvent(event)) { + return null + } + + // Why: trackpad pixel streams map 1:1 to physical distance: one report per + // terminal row scrolled, fractional remainder carried. No per-event cap and + // no momentum-tail suppression: the input write queue batches whatever a + // busy frame accumulates into a single PTY write, so the TUI applies it at + // once instead of replaying it, and inertial scrolling stays real-time. + const totalRows = state.pendingRows + distanceRows + const reports = Math.trunc(totalRows) + state.pendingRows = totalRows - reports + return reports +} + export function normalizeTerminalTuiMouseWheelMultiplier(value: number | undefined): number { if (typeof value !== 'number' || !Number.isFinite(value)) { return TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER @@ -193,6 +238,11 @@ export function resolveTerminalTuiMouseWheelReportCount( state.pendingDirection = direction const distanceRows = resolveWheelDistanceRows(event, metrics) + const trackpadReportCount = resolveTrackpadPixelWheelReportCount(event, state, distanceRows) + if (trackpadReportCount !== null) { + return trackpadReportCount + } + const rows = Math.min( TUI_WHEEL_BURST_MAX_DISTANCE_ROWS_PER_EVENT, diff --git a/tests/e2e/fixtures/visible-tui-scroll-fixture.cjs b/tests/e2e/fixtures/visible-tui-scroll-fixture.cjs index 207aac49cfc..137bc48dee1 100644 --- a/tests/e2e/fixtures/visible-tui-scroll-fixture.cjs +++ b/tests/e2e/fixtures/visible-tui-scroll-fixture.cjs @@ -1,6 +1,20 @@ +const fs = require('node:fs') + const ESC = '\x1b' const MOUSE_REPORT_PATTERN = new RegExp(`${ESC}\\[<(64|65);\\d+;\\d+M`, 'g') +// Optional flags: +// --log append " " per consumed stdin +// chunk so tests can measure when reports reached the PTY. +// --heavy emit Claude-Code-scale redraw frames (~4KB of styled cells +// per render, ~430KB/s at 120 reports/s — matches output +// volume measured from a real Claude Code session scrolling +// at 120 reports/s) instead of minimal rows. +const args = process.argv.slice(2) +const logIndex = args.indexOf('--log') +const LOG_PATH = logIndex >= 0 ? args[logIndex + 1] : null +const HEAVY_FRAMES = args.includes('--heavy') + let offset = 0 let pending = '' @@ -12,12 +26,28 @@ function visibleRows() { return Math.max(3, process.stdout.rows || 24) } +function visibleCols() { + return Math.max(20, process.stdout.columns || 80) +} + +function heavyRowFiller(row, cols) { + // ~19 bytes per 8 visible columns of styled filler. + const unit = `${ESC}[38;5;${((row * 17) % 200) + 16}m········` + const units = Math.max(0, Math.floor((cols - 24) / 8)) + return `${unit.repeat(units)}${ESC}[0m` +} + function render() { - write(`${ESC}[H`) - write(`TUI_SCROLL_READY offset=${offset}${ESC}[K`) - for (let row = 1; row < visibleRows(); row += 1) { - write(`\r\nTUI_SCROLL_ROW_${String(offset + row - 1).padStart(4, '0')}${ESC}[K`) + const rows = visibleRows() + const cols = visibleCols() + let frame = `${ESC}[?2026h${ESC}[H` + frame += `TUI_SCROLL_READY offset=${offset}${ESC}[K` + for (let row = 1; row < rows; row += 1) { + const label = `TUI_SCROLL_ROW_${String(offset + row - 1).padStart(4, '0')}` + frame += `\r\n${label}${HEAVY_FRAMES ? ` ${heavyRowFiller(row, cols)}` : ''}${ESC}[K` } + frame += `${ESC}[?2026l` + write(frame) } function cleanup() { @@ -42,14 +72,19 @@ process.stdin.on('data', (chunk) => { pending += chunk let match let lastIndex = 0 + let reportsInChunk = 0 MOUSE_REPORT_PATTERN.lastIndex = 0 while ((match = MOUSE_REPORT_PATTERN.exec(pending)) !== null) { offset = Math.max(0, offset + (match[1] === '65' ? 1 : -1)) + reportsInChunk += 1 lastIndex = MOUSE_REPORT_PATTERN.lastIndex } if (lastIndex > 0) { + if (LOG_PATH) { + fs.appendFileSync(LOG_PATH, `${Date.now()} ${reportsInChunk}\n`) + } pending = pending.slice(lastIndex) render() return diff --git a/tests/e2e/terminal-tui-wheel-drain.spec.ts b/tests/e2e/terminal-tui-wheel-drain.spec.ts new file mode 100644 index 00000000000..c228159852c --- /dev/null +++ b/tests/e2e/terminal-tui-wheel-drain.spec.ts @@ -0,0 +1,195 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + execInTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager +} from './helpers/terminal' + +const VISIBLE_TUI_FIXTURE_PATH = path.join( + process.cwd(), + 'tests/e2e/fixtures/visible-tui-scroll-fixture.cjs' +) + +// Reports must stop reaching the PTY shortly after the gesture ends. Before +// the input-write-queue coalescing fix, each SGR report drained in its own +// >=4ms-clamped macrotask turn; a dense trackpad stream delivered through the +// real input pipeline (which outprioritizes timers) plus TUI-scale redraw +// output starved that drain, and the backlog replayed for seconds after the +// fingers left the trackpad. +const MAX_ARRIVAL_LAG_MS = 900 +const WHEEL_EVENTS = 240 + +type WheelStreamResult = { + dispatchedEvents: number + inputEndWallClockMs: number +} + +async function startHeavyTuiFixture(page: Page, logPath: string): Promise { + await waitForSessionReady(page) + await waitForActiveWorktree(page) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) + await page.evaluate(() => + window.__store?.getState().updateSettings({ terminalTuiScrollSensitivity: 1 }) + ) + + const ptyId = await waitForActivePanePtyId(page) + await execInTerminal( + page, + ptyId, + `node ${JSON.stringify(VISIBLE_TUI_FIXTURE_PATH)} --heavy --log ${JSON.stringify(logPath)}` + ) + + await expect + .poll( + () => + page.evaluate(() => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + return pane?.terminal.element?.classList.contains('enable-mouse-events') ?? false + }), + { timeout: 15_000, message: 'fixture did not enable mouse reporting' } + ) + .toBe(true) +} + +async function terminalWheelTarget( + page: Page +): Promise<{ x: number; y: number; cellHeight: number }> { + return page.evaluate(() => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + const screen = pane?.terminal.element?.querySelector('.xterm-screen') + if (!pane?.terminal || !screen) { + throw new Error('Active terminal screen unavailable') + } + const rect = screen.getBoundingClientRect() + return { + x: rect.left + rect.width / 2, + y: rect.top + Math.min(rect.height - 1, 40), + cellHeight: + pane.terminal._core?._renderService?.dimensions?.css?.cell?.height ?? + rect.height / pane.terminal.rows + } + }) +} + +/** + * Drives real wheel input (CDP mouse events through the compositor input + * pipeline — the same priority class as physical trackpad input) rather than + * synthetic DOM dispatchEvent, so input can genuinely compete with the + * renderer's timer-based PTY input drain the way a physical gesture does. + */ +async function dispatchTrackpadWheelStream( + page: Page, + options: { alternate: boolean; events: number; deltaY: number } +): Promise { + const target = await terminalWheelTarget(page) + await page.mouse.move(target.x, target.y) + for (let i = 0; i < options.events; i += 1) { + const direction = options.alternate && Math.floor(i / 18) % 2 === 1 ? -1 : 1 + // No artificial sleep: CDP round-trips pace this near real trackpad rates + // while keeping the renderer's input queue continuously occupied. + await page.mouse.wheel(0, direction * options.deltaY) + } + const inputEndWallClockMs = await page.evaluate(() => Date.now()) + return { dispatchedEvents: options.events, inputEndWallClockMs } +} + +type ReportArrival = { atMs: number; reports: number } + +function readReportArrivalLog(logPath: string): ReportArrival[] { + if (!fs.existsSync(logPath)) { + return [] + } + return fs + .readFileSync(logPath, 'utf8') + .split('\n') + .filter(Boolean) + .map((line) => { + const [atMs, reports] = line.split(' ') + return { atMs: Number(atMs), reports: Number(reports) } + }) +} + +function summarizeArrivals( + arrivals: ReportArrival[], + input: WheelStreamResult +): { arrivalLagMs: number; chunks: number; totalReports: number } { + const totalReports = arrivals.reduce((sum, entry) => sum + entry.reports, 0) + const lastArrivalMs = arrivals.at(-1)?.atMs ?? 0 + return { + arrivalLagMs: lastArrivalMs - input.inputEndWallClockMs, + chunks: arrivals.length, + totalReports + } +} + +test.describe('terminal TUI wheel report drain', () => { + test('dense trackpad-like wheel stream reaches the PTY while the gesture happens', async ({ + orcaPage + }) => { + const logPath = path.join(os.tmpdir(), `tui-wheel-drain-${Date.now()}.log`) + await startHeavyTuiFixture(orcaPage, logPath) + + const target = await terminalWheelTarget(orcaPage) + const input = await dispatchTrackpadWheelStream(orcaPage, { + alternate: false, + events: WHEEL_EVENTS, + deltaY: Math.min(49, target.cellHeight) + }) + // Give a laggy drain ample time to expose itself before reading the log. + await orcaPage.waitForTimeout(8000) + + const summary = summarizeArrivals(readReportArrivalLog(logPath), input) + fs.rmSync(logPath, { force: true }) + console.log(`[tui-wheel-drain] dense: ${JSON.stringify(summary)}`) + + // The full gesture distance must reach the TUI (no dead/eaten scrolls)... + expect(summary.totalReports, JSON.stringify(summary)).toBeGreaterThanOrEqual(WHEEL_EVENTS - 10) + // ...while the gesture happens, not replayed 1-by-1 afterwards. + expect(summary.arrivalLagMs, JSON.stringify(summary)).toBeLessThanOrEqual(MAX_ARRIVAL_LAG_MS) + }) + + test('aggressive alternating trackpad-like gesture does not replay after input ends', async ({ + orcaPage + }) => { + const logPath = path.join(os.tmpdir(), `tui-wheel-drain-alt-${Date.now()}.log`) + await startHeavyTuiFixture(orcaPage, logPath) + + const target = await terminalWheelTarget(orcaPage) + const input = await dispatchTrackpadWheelStream(orcaPage, { + alternate: true, + events: WHEEL_EVENTS, + deltaY: Math.min(49, target.cellHeight) + }) + await orcaPage.waitForTimeout(8000) + + const summary = summarizeArrivals(readReportArrivalLog(logPath), input) + fs.rmSync(logPath, { force: true }) + console.log(`[tui-wheel-drain] alternate: ${JSON.stringify(summary)}`) + + expect(summary.arrivalLagMs, JSON.stringify(summary)).toBeLessThanOrEqual(MAX_ARRIVAL_LAG_MS) + }) +}) From 8e1ce20289c6a5d835fb0cd25f1fd5c34d067938 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 06:44:20 +0000 Subject: [PATCH 20/48] release: v1.4.120-rc.6 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5e37d338f40..ce09f6ab7b8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.120-rc.5", + "version": "1.4.120-rc.6", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 0ab4d8925c52d259eeb205f7bea87509f5031883 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 07:06:43 +0000 Subject: [PATCH 21/48] release: v1.4.120 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ce09f6ab7b8..60184c71afe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.120-rc.6", + "version": "1.4.120", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From f1bcb7739286a5006124d68c85f0fbe5c8de91d0 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 3 Jul 2026 00:16:19 -0700 Subject: [PATCH 22/48] fix: parse Claude Fable current-week usage Claude Code 2.1.199 renders the Fable plan bucket as Current week (Fable), while Orca only parsed a standalone Fable heading. Accept weekly-style Fable headings as the distinct Fable weekly meter while keeping broader Fable-weekly copy as a section boundary to avoid false positives. Validation: - pnpm exec vitest run --config config/vitest.config.ts src/main/rate-limits/claude-pty.test.ts src/main/rate-limits/claude-fetcher.test.ts src/main/rate-limits/service.test.ts src/renderer/src/components/status-bar/tooltip.test.ts src/renderer/src/components/status-bar/inline-usage-bars.test.tsx - pnpm run typecheck - pnpm exec oxlint src/main/rate-limits/claude-pty.ts src/main/rate-limits/claude-pty.test.ts src/main/rate-limits/claude-fetcher.ts src/main/rate-limits/claude-fetcher.test.ts src/main/rate-limits/service.ts src/main/rate-limits/service.test.ts src/renderer/src/components/status-bar/tooltip.test.ts src/renderer/src/components/status-bar/inline-usage-bars.test.tsx - git diff --check HEAD~1..HEAD - live local Claude fetcher returned fableWeekly: 62 --- src/main/rate-limits/claude-pty.test.ts | 43 +++++++++++++++++++++++++ src/main/rate-limits/claude-pty.ts | 12 +++++-- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/src/main/rate-limits/claude-pty.test.ts b/src/main/rate-limits/claude-pty.test.ts index a613194868e..acea13231c7 100644 --- a/src/main/rate-limits/claude-pty.test.ts +++ b/src/main/rate-limits/claude-pty.test.ts @@ -258,6 +258,49 @@ describe('fetchViaPty', () => { }) }) + it('parses Claude current-week Fable usage as a distinct weekly window', async () => { + const term = makeMockTerm() + spawnMock.mockReturnValue(term) + + const resultPromise = fetchViaPty() + + await vi.advanceTimersByTimeAsync(2_000) + term.emitData(` + Plan usage limits + + Current session + 8% used + Resets 3:39am + + Current week (all models) + 33% used + Resets Jul 3 at 12:59pm + + Current week (Fable) + 62% used + Resets Jul 3 at 12:59pm + `) + await vi.advanceTimersByTimeAsync(2_000) + + await expect(resultPromise).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + session: { + usedPercent: 8, + resetDescription: '3:39am' + }, + weekly: { + usedPercent: 33, + resetDescription: 'Jul 3 at 12:59pm' + }, + fableWeekly: { + usedPercent: 62, + resetDescription: 'Jul 3 at 12:59pm' + }, + error: null + }) + }) + it('does not let an incomplete Fable section consume later usage sections', async () => { const term = makeMockTerm() spawnMock.mockReturnValue(term) diff --git a/src/main/rate-limits/claude-pty.ts b/src/main/rate-limits/claude-pty.ts index f0363eba3f2..20919a391ca 100644 --- a/src/main/rate-limits/claude-pty.ts +++ b/src/main/rate-limits/claude-pty.ts @@ -22,6 +22,8 @@ const SESSION_RE = /current\s*session/i const WEEKLY_RE = /(?:current\s*week|weekly\s*(?:limits?|usage|rate\s*limits?)|7\s*[- ]?\s*day)/i const FABLE_WORD_RE = /\bfable\b/i const FABLE_LABEL_RE = /^\s*fable\s*$/i +const FABLE_WEEKLY_LABEL_RE = + /(?:current\s*week|weekly\s*(?:limits?|usage|rate\s*limits?)|7\s*[- ]?\s*day)\s*(?:\([^)]*\bfable\b[^)]*\)|[-:]?\s*\bfable\b)/i const PERCENT_RE = /(\d{1,3})(?:\.\d+)?\s*%\s*(used|consumed|left|remaining|available)/i const RESET_LINE_RE = /resets?\s+(?:at\s+|in\s+)?(.+)/i const ESC = String.fromCharCode(27) @@ -45,6 +47,12 @@ function matchesFableBoundary(line: string): boolean { return FABLE_LABEL_RE.test(line) || (FABLE_WORD_RE.test(line) && WEEKLY_RE.test(line)) } +function matchesFableUsageLabel(line: string): boolean { + // Why: broad Fable-weekly copy should stop nearby scans, but only a real + // Fable usage heading should produce the distinct Fable meter. + return FABLE_LABEL_RE.test(line) || FABLE_WEEKLY_LABEL_RE.test(line) +} + function isSectionLabel(line: string): boolean { return SESSION_RE.test(line) || matchesWeeklyLabel(line) || matchesFableBoundary(line) } @@ -104,7 +112,7 @@ function parsePtyUsage(output: string): { const sessionPct = extractPercentAfterLabel(lines, (line) => SESSION_RE.test(line)) const weeklyPct = extractPercentAfterLabel(lines, matchesWeeklyLabel) - const fableWeeklyPct = extractPercentAfterLabel(lines, (line) => FABLE_LABEL_RE.test(line)) + const fableWeeklyPct = extractPercentAfterLabel(lines, matchesFableUsageLabel) const session: RateLimitWindow | null = sessionPct !== null @@ -132,7 +140,7 @@ function parsePtyUsage(output: string): { usedPercent: Math.min(100, Math.max(0, fableWeeklyPct)), windowMinutes: 10080, resetsAt: null, - resetDescription: extractResetAfterLabel(lines, (line) => FABLE_LABEL_RE.test(line)) + resetDescription: extractResetAfterLabel(lines, matchesFableUsageLabel) } : null From 0ff2c090029ad0f86429dd89ac34a1bb6e45afad Mon Sep 17 00:00:00 2001 From: Dvitash Date: Fri, 3 Jul 2026 03:26:37 -0400 Subject: [PATCH 23/48] Fix typed OMP remote title flicker (#6954) --- src/main/runtime/orca-runtime.test.ts | 297 +++++++++++ src/main/runtime/orca-runtime.ts | 119 ++++- .../terminal-pane/pty-connection.test.ts | 467 +++++++++++++++++- .../terminal-pane/pty-connection.ts | 236 ++++++++- src/shared/agent-detection.test.ts | 9 + src/shared/agent-title-owner.ts | 8 + 6 files changed, 1108 insertions(+), 28 deletions(-) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index ee8d6edd608..8b3a65720c1 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -12262,6 +12262,303 @@ describe('OrcaRuntimeService', () => { expect(getForegroundProcess).toHaveBeenCalledTimes(2) }) + it('normalizes Pi-compatible mobile session status to OMP for an unknown-launch foreground omp PTY', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-typed-omp' }) + const getForegroundProcess = vi.fn(async () => 'omp') + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess + }) + const terminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'typed-omp-tab', + leafId: HEADLESS_LEAF_ID, + title: 'Terminal' + }) + + runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi ready\x07', 123) + await new Promise((resolve) => setImmediate(resolve)) + + const result = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect(getForegroundProcess).toHaveBeenCalledWith('pty-typed-omp') + expect(result.tabs[0]).toEqual( + expect.objectContaining({ + type: 'terminal', + title: 'OMP ready', + agentStatus: expect.objectContaining({ + state: 'done', + agentType: 'omp', + terminalHandle: terminal.handle, + terminalTitle: 'OMP ready' + }) + }) + ) + expect(result.tabs[0]).not.toHaveProperty('launchAgent') + }) + + it('waits for unknown-launch foreground owner before publishing Pi-compatible mobile status', async () => { + const foregroundProcess = deferred() + const spawn = vi.fn().mockResolvedValue({ id: 'pty-typed-omp' }) + const getForegroundProcess = vi.fn(() => foregroundProcess.promise) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess + }) + const terminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'typed-omp-tab', + leafId: HEADLESS_LEAF_ID, + title: 'Terminal' + }) + const events: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot)) + + runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi ready\x07', 123) + await new Promise((resolve) => setImmediate(resolve)) + + expect(getForegroundProcess).toHaveBeenCalledWith('pty-typed-omp') + expect(events).toHaveLength(0) + + foregroundProcess.resolve('omp') + await new Promise((resolve) => setImmediate(resolve)) + + expect(events).toEqual([ + expect.objectContaining({ + tabs: [ + expect.objectContaining({ + type: 'terminal', + title: 'OMP ready', + agentStatus: expect.objectContaining({ + state: 'done', + agentType: 'omp', + terminalHandle: terminal.handle, + terminalTitle: 'OMP ready' + }) + }) + ] + }) + ]) + + unsubscribe() + }) + + it('keeps same-status Pi-compatible title changes queued behind the foreground owner probe', async () => { + const foregroundProcess = deferred() + const spawn = vi.fn().mockResolvedValue({ id: 'pty-typed-omp' }) + const getForegroundProcess = vi.fn(() => foregroundProcess.promise) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess + }) + const terminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'typed-omp-tab', + leafId: HEADLESS_LEAF_ID, + title: 'Terminal' + }) + const events: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot)) + + runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi ready\x07', 123) + await new Promise((resolve) => setImmediate(resolve)) + runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi idle\x07', 124) + await new Promise((resolve) => setImmediate(resolve)) + + expect(getForegroundProcess).toHaveBeenCalledTimes(1) + expect(events).toHaveLength(0) + + foregroundProcess.resolve('omp') + await new Promise((resolve) => setImmediate(resolve)) + + expect(events).toEqual([ + expect.objectContaining({ + tabs: [ + expect.objectContaining({ + type: 'terminal', + title: 'OMP ready', + agentStatus: expect.objectContaining({ + state: 'done', + agentType: 'omp', + terminalHandle: terminal.handle, + terminalTitle: 'OMP ready' + }) + }) + ] + }) + ]) + + unsubscribe() + }) + + it('coalesces same-status title frames behind one post-title foreground probe', async () => { + const staleForegroundProcess = deferred() + const freshForegroundProcess = deferred() + const spawn = vi.fn().mockResolvedValue({ id: 'pty-typed-omp' }) + const getForegroundProcess = vi + .fn() + .mockReturnValueOnce(staleForegroundProcess.promise) + .mockReturnValueOnce(freshForegroundProcess.promise) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess + }) + const terminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'typed-omp-tab', + leafId: HEADLESS_LEAF_ID, + title: 'Terminal' + }) + const events: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot)) + + runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi ready\x07', 123) + await new Promise((resolve) => setImmediate(resolve)) + runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi idle\x07', 124) + runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi done\x07', 125) + await new Promise((resolve) => setImmediate(resolve)) + + expect(getForegroundProcess).toHaveBeenCalledTimes(1) + expect(events).toHaveLength(0) + + staleForegroundProcess.resolve(null) + await new Promise((resolve) => setImmediate(resolve)) + + expect(getForegroundProcess).toHaveBeenCalledTimes(2) + expect(events).toHaveLength(0) + + freshForegroundProcess.resolve('omp') + await new Promise((resolve) => setImmediate(resolve)) + + expect(getForegroundProcess).toHaveBeenCalledTimes(2) + expect(events).toEqual([ + expect.objectContaining({ + tabs: [ + expect.objectContaining({ + type: 'terminal', + title: 'OMP ready', + agentStatus: expect.objectContaining({ + state: 'done', + agentType: 'omp', + terminalHandle: terminal.handle, + terminalTitle: 'OMP ready' + }) + }) + ] + }) + ]) + + unsubscribe() + }) + + it('starts a post-title foreground probe when an older pending probe finds no owner', async () => { + const staleForegroundProcess = deferred() + const freshForegroundProcess = deferred() + const spawn = vi.fn().mockResolvedValue({ id: 'pty-typed-omp' }) + const getForegroundProcess = vi + .fn() + .mockReturnValueOnce(staleForegroundProcess.promise) + .mockReturnValueOnce(freshForegroundProcess.promise) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess + }) + const terminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'typed-omp-tab', + leafId: HEADLESS_LEAF_ID, + title: 'Terminal' + }) + const events: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot)) + + ;( + runtime as unknown as { + refreshPtyForegroundAgentFromController: (ptyId: string) => Promise + } + ).refreshPtyForegroundAgentFromController('pty-typed-omp') + runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi ready\x07', 123) + await new Promise((resolve) => setImmediate(resolve)) + + expect(getForegroundProcess).toHaveBeenCalledTimes(1) + expect(events).toHaveLength(0) + + staleForegroundProcess.resolve(null) + await new Promise((resolve) => setImmediate(resolve)) + + expect(getForegroundProcess).toHaveBeenCalledTimes(2) + expect(events).toHaveLength(0) + + freshForegroundProcess.resolve('omp') + await new Promise((resolve) => setImmediate(resolve)) + + expect(events).toEqual([ + expect.objectContaining({ + tabs: [ + expect.objectContaining({ + type: 'terminal', + title: 'OMP ready', + agentStatus: expect.objectContaining({ + state: 'done', + agentType: 'omp', + terminalHandle: terminal.handle, + terminalTitle: 'OMP ready' + }) + }) + ] + }) + ]) + + unsubscribe() + }) + + it('keeps Pi-compatible mobile session status as Pi for an unknown-launch foreground pi PTY', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-typed-pi' }) + const getForegroundProcess = vi.fn(async () => 'pi') + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess + }) + const terminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'typed-pi-tab', + leafId: HEADLESS_LEAF_ID, + title: 'Terminal' + }) + + runtime.onPtyData('pty-typed-pi', '\x1b]0;Pi ready\x07', 123) + await new Promise((resolve) => setImmediate(resolve)) + + const result = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect(getForegroundProcess).toHaveBeenCalledWith('pty-typed-pi') + expect(result.tabs[0]).toEqual( + expect.objectContaining({ + type: 'terminal', + title: 'Pi ready', + agentStatus: expect.objectContaining({ + state: 'done', + agentType: 'pi', + terminalHandle: terminal.handle, + terminalTitle: 'Pi ready' + }) + }) + ) + expect(result.tabs[0]).not.toHaveProperty('launchAgent') + }) + it('keeps renderer-vetted mobile agent status for custom-titled terminals', async () => { const runtime = new OrcaRuntimeService(store) const leafId = '11111111-1111-4111-8111-111111111111' diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 38414da2155..bbfa9731182 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -20,6 +20,7 @@ import { type AgentStatusEntry } from '../../shared/agent-status-types' import { + hasCompatibleAgentTitleIdentity, normalizeCompatibleAgentStatusEntryForOwner, normalizeCompatibleAgentTitleForOwner } from '../../shared/agent-title-owner' @@ -942,6 +943,12 @@ type RuntimePtyWorktreeRecord = { waitBlockedAt: number | null } +type PtyForegroundAgentRefresh = { + promise: Promise + startedAfterTitleObservation: number + requestedAfterTitleObservation: number +} + function copySleepingAgentLaunchConfig( config: SleepingAgentLaunchConfig ): SleepingAgentLaunchConfig { @@ -1925,7 +1932,8 @@ export class OrcaRuntimeService { private resolvedWorktreeGeneration = 0 private cloneInFlightByPath = new Map>() private agentDetector: AgentDetector | null = null - private ptyForegroundAgentRefreshes = new Map>() + private ptyForegroundAgentRefreshes = new Map() + private ptyDelayedForegroundSnapshotTitleObservations = new Map() private _orchestrationDb: OrchestrationDb | null = null private messageWaitersByHandle = new Map>() // Why: mobile clients subscribe to terminal output via terminal.subscribe. @@ -3253,7 +3261,7 @@ export class OrcaRuntimeService { /** * Publishes a PTY-backed terminal tab snapshot to the synced mobile session, - * normalizing Pi-compatible titles based on launch ownership. + * normalizing Pi-compatible titles based on launch or foreground ownership. */ private publishPtyBackedMobileSessionTerminal( worktreeId: string, @@ -3269,9 +3277,10 @@ export class OrcaRuntimeService { } ): void { const existing = this.mobileSessionTabsByWorktree.get(worktreeId) + const ownerAgent = pty.launchAgent ?? pty.foregroundAgent const title = normalizeCompatibleAgentTitleForOwner( args.title ?? getLatestPtyTitle(pty) ?? 'Terminal', - pty.launchAgent + ownerAgent ) const existingTab = existing?.tabs.find( (candidate): candidate is RuntimeMobileSessionTerminalTab => @@ -5194,11 +5203,25 @@ export class OrcaRuntimeService { if (agentStatus === 'idle' && prevStatus !== 'idle') { this.resolvePtyTuiIdleWaiters(pty, ptyId) } + const shouldDelayMobileSnapshot = + shouldTouchPtyBackedSessionTabs && + this.shouldDelayPtyBackedMobileSnapshotForForegroundAgent(pty, oscTitle) + let foregroundRefresh: Promise | undefined // Why: gate on an actual status transition — braille spinner frames // mutate the title every tick, so probing per-title-change would stream // a foreground query per frame during active work. if (prevStatus !== pty.lastAgentStatus) { - this.refreshPtyForegroundAgent(ptyId) + foregroundRefresh = this.refreshPtyForegroundAgentFromController(ptyId, { + afterTitleObservation: observedAt + }) + } else if (shouldDelayMobileSnapshot) { + // Why: same-status compatible title changes can arrive before the + // foreground owner probe settles; publishing them would flicker. + foregroundRefresh = this.getPendingForegroundAgentRefreshForTitle(ptyId, observedAt) + } + if (foregroundRefresh && shouldDelayMobileSnapshot) { + shouldTouchPtyBackedSessionTabs = false + this.delayPtyBackedMobileSnapshotForForegroundAgent(ptyId, observedAt, foregroundRefresh) } } } @@ -8718,6 +8741,15 @@ export class OrcaRuntimeService { } } + private shouldDelayPtyBackedMobileSnapshotForForegroundAgent( + pty: RuntimePtyWorktreeRecord, + title: string + ): boolean { + return ( + !pty.launchAgent && pty.foregroundAgent === null && hasCompatibleAgentTitleIdentity(title) + ) + } + /** * Schedules an asynchronous query to check which agent process is currently * running in the foreground of a PTY. @@ -8726,19 +8758,75 @@ export class OrcaRuntimeService { void this.refreshPtyForegroundAgentFromController(ptyId) } + private getPendingForegroundAgentRefreshForTitle( + ptyId: string, + titleObservedAt: number + ): Promise | undefined { + if (!this.ptyForegroundAgentRefreshes.has(ptyId)) { + return undefined + } + return this.refreshPtyForegroundAgentFromController(ptyId, { + afterTitleObservation: titleObservedAt + }) + } + + private delayPtyBackedMobileSnapshotForForegroundAgent( + ptyId: string, + titleObservedAt: number, + foregroundRefresh: Promise + ): void { + this.ptyDelayedForegroundSnapshotTitleObservations.set(ptyId, titleObservedAt) + void foregroundRefresh.then((foregroundAgentChanged) => { + if (this.ptyDelayedForegroundSnapshotTitleObservations.get(ptyId) !== titleObservedAt) { + return + } + this.ptyDelayedForegroundSnapshotTitleObservations.delete(ptyId) + if (!foregroundAgentChanged) { + this.touchMobileSessionSnapshotsForPty(ptyId) + } + }) + } + /** * Deduplicates and manages in-flight foreground agent refresh queries * for a specific PTY. */ - private refreshPtyForegroundAgentFromController(ptyId: string): Promise { + private refreshPtyForegroundAgentFromController( + ptyId: string, + options: { afterTitleObservation?: number } = {} + ): Promise { + const startedAfterTitleObservation = options.afterTitleObservation ?? 0 const pendingRefresh = this.ptyForegroundAgentRefreshes.get(ptyId) if (pendingRefresh) { - return pendingRefresh + pendingRefresh.requestedAfterTitleObservation = Math.max( + pendingRefresh.requestedAfterTitleObservation, + startedAfterTitleObservation + ) + return pendingRefresh.promise } - const refresh = this.loadPtyForegroundAgentFromController(ptyId).finally(() => { - this.ptyForegroundAgentRefreshes.delete(ptyId) + const entry: PtyForegroundAgentRefresh = { + promise: Promise.resolve(false), + startedAfterTitleObservation, + requestedAfterTitleObservation: startedAfterTitleObservation + } + const refresh = (async (): Promise => { + while (true) { + entry.startedAfterTitleObservation = entry.requestedAfterTitleObservation + const foregroundAgentChanged = await this.loadPtyForegroundAgentFromController(ptyId) + if ( + foregroundAgentChanged || + entry.requestedAfterTitleObservation <= entry.startedAfterTitleObservation + ) { + return foregroundAgentChanged + } + } + })().finally(() => { + if (this.ptyForegroundAgentRefreshes.get(ptyId) === entry) { + this.ptyForegroundAgentRefreshes.delete(ptyId) + } }) - this.ptyForegroundAgentRefreshes.set(ptyId, refresh) + entry.promise = refresh + this.ptyForegroundAgentRefreshes.set(ptyId, entry) return refresh } @@ -8746,34 +8834,35 @@ export class OrcaRuntimeService { * Queries the PTY controller for the active foreground process, identifies if it * is a recognized agent, and updates the PTY's foreground agent state if changed. */ - private async loadPtyForegroundAgentFromController(ptyId: string): Promise { + private async loadPtyForegroundAgentFromController(ptyId: string): Promise { if (!this.ptyController) { - return + return false } const pty = this.ptysById.get(ptyId) if (!pty?.connected) { - return + return false } // Why: foregroundAgent is only consulted as the owner fallback when // launchAgent is unknown, so a known launchAgent makes the relay // getForegroundProcess round-trip pure waste (covers all launched agents). if (pty.launchAgent) { - return + return false } let foregroundProcess: string | null try { foregroundProcess = await this.ptyController.getForegroundProcess(ptyId) } catch { - return + return false } const foregroundAgent = foregroundProcess ? (recognizeAgentProcess(foregroundProcess)?.agent ?? null) : null if (pty.foregroundAgent === foregroundAgent) { - return + return false } pty.foregroundAgent = foregroundAgent this.touchMobileSessionSnapshotsForPty(ptyId) + return true } private getFreshExplicitAgentStatusForHandle(handle: string): { 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 53da6899409..4b3b796d5a4 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -230,6 +230,9 @@ vi.mock('@/lib/agent-status', async (importOriginal) => { if (/Codex( working)?/.test(title)) { return /working/.test(title) ? 'working' : 'idle' } + if (/^\s*(?:[\u2800-\u28ff]\s+)?(?:Pi|OMP)(?: ready| idle)?\s*$/i.test(title)) { + return /[\u2800-\u28ff]/u.test(title) ? 'working' : 'idle' + } return null }) } @@ -726,15 +729,18 @@ describe('connectPanePty', () => { markWorktreeUnread: vi.fn(), observeTerminalGitHubPullRequestLink: vi.fn(), recordTerminalInput: vi.fn(), - setAgentStatus: vi.fn((paneKey: string, payload: Record) => { - mockStoreState.agentStatusByPaneKey[paneKey] = { - ...payload, - paneKey, - updatedAt: Date.now(), - stateStartedAt: Date.now(), - stateHistory: [] + setAgentStatus: vi.fn( + (paneKey: string, payload: Record, terminalTitle?: string | null) => { + mockStoreState.agentStatusByPaneKey[paneKey] = { + ...payload, + paneKey, + ...(terminalTitle ? { terminalTitle } : {}), + updatedAt: Date.now(), + stateStartedAt: Date.now(), + stateHistory: [] + } } - }), + ), removeAgentStatus: vi.fn(), dropAgentStatus: vi.fn(), markTerminalTabUnread: vi.fn(), @@ -924,6 +930,451 @@ describe('connectPanePty', () => { ) }) + it('normalizes Pi-compatible remote runtime status to OMP after typed omp command', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-omp') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, 'omp\r') + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(transport.sendInput).toHaveBeenCalledWith('omp\r') + expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'OMP ready') + expect(deps.updateTabTitle).toHaveBeenCalledWith('tab-1', 'OMP ready') + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'omp', + terminalTitle: 'OMP ready' + }) + }) + + it('normalizes after shell word deletion edits a typed command to omp', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-omp-edited') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, 'pi \x17omp\r') + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(transport.sendInput).toHaveBeenCalledWith('pi \x17omp\r') + expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'OMP ready') + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'omp', + terminalTitle: 'OMP ready' + }) + }) + + it('keeps Pi-compatible remote runtime status as Pi after typed pi command', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-pi') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, 'pi\r') + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(transport.sendInput).toHaveBeenCalledWith('pi\r') + expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'Pi ready') + expect(deps.updateTabTitle).toHaveBeenCalledWith('tab-1', 'Pi ready') + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'pi', + terminalTitle: 'Pi ready' + }) + }) + + it('does not infer shell ownership from prompts typed inside an existing Pi session', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + const now = Date.now() + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'done', + prompt: '', + agentType: 'pi', + paneKey, + terminalTitle: 'Pi ready', + updatedAt: now, + stateStartedAt: now, + stateHistory: [] + } + mockStoreState.runtimePaneTitlesByTabId = { 'tab-1': { 1: 'Pi ready' } } + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-pi-prompt') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, 'omp\r') + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(transport.sendInput).toHaveBeenCalledWith('omp\r') + expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'Pi ready') + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'pi', + terminalTitle: 'Pi ready' + }) + }) + + it('does not infer shell ownership from prompts typed in a title-only Pi session', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.runtimePaneTitlesByTabId = { 'tab-1': { 1: 'Pi ready' } } + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-pi-title-only') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, 'omp\r') + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(transport.sendInput).toHaveBeenCalledWith('omp\r') + expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'Pi ready') + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'pi', + terminalTitle: 'Pi ready' + }) + }) + + it('lets a new typed omp command override a stale retained done status', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + const now = Date.now() + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'done', + prompt: '', + agentType: 'pi', + paneKey, + terminalTitle: 'Pi ready', + updatedAt: now, + stateStartedAt: now, + stateHistory: [] + } + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-stale-done') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, 'omp\r') + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'OMP ready') + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'omp', + terminalTitle: 'OMP ready' + }) + }) + + it('tracks cursor edits when inferring a typed omp command', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-cursor-edit') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, 'op\x1b[Dm\r') + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(transport.sendInput).toHaveBeenCalledWith('op\x1b[Dm\r') + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'omp', + terminalTitle: 'OMP ready' + }) + }) + + it('tracks delete-key cursor edits when inferring a typed omp command', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-delete-edit') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, 'ommp\x1b[D\x1b[D\x1b[3~\r') + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'omp', + terminalTitle: 'OMP ready' + }) + }) + + it('skips manual agent inference for large paste chunks', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-large-paste') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, `${'x'.repeat(4097)}omp\r`) + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'pi', + terminalTitle: 'Pi ready' + }) + }) + + it('resumes manual agent inference when large paste input is cancelled', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-cancelled-large-paste') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, 'x'.repeat(4097)) + sendTerminalInputThroughPane(pane, '\x03') + sendTerminalInputThroughPane(pane, 'omp\r') + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'omp', + terminalTitle: 'OMP ready' + }) + }) + + it('preserves typed shell ownership through same-chunk command-finished side effects', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-command-finished') + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks?: ConnectCallbacks }) => { + dataCallbackRef.current = callbacks?.onData ?? null + return 'remote:web-env-1@@pty-command-finished' + } + ) + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, 'omp\r') + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + const dataCallback = dataCallbackRef.current + if (!dataCallback || !onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + dataCallback('\x1b]133;D;0\x07') + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'omp', + terminalTitle: 'OMP ready' + }) + }) + it('queues visible bulk output off the synchronous xterm write path', async () => { const { connectPanePty } = await import('./pty-connection') const pane = createPane(1) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index f614aea121b..582d695c322 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -89,7 +89,11 @@ import { makePaneKey, parseLegacyNumericPaneKey } from '../../../../shared/stabl import { createTerminalCommandLifecycle } from './terminal-command-lifecycle' import { dispatchTerminalCommandFinishedEvent } from '@/hooks/terminal-command-finished-event' import { e2eConfig } from '@/lib/e2e-config' -import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-status-types' +import { + AGENT_STATUS_STALE_AFTER_MS, + type AgentStatusEntry, + type AgentType +} from '../../../../shared/agent-status-types' import { isWebTerminalSurfaceTabId } from '@/runtime/web-terminal-surface-id' import { createAgentInterruptInference, @@ -156,6 +160,10 @@ import { normalizeCompatibleAgentTitleForOwner, resolveCompatibleAgentTypeForOwner } from '../../../../shared/agent-title-owner' +import { + isExpectedAgentProcess, + recognizeAgentProcessFromCommandLine +} from '../../../../shared/agent-process-recognition' import type { SetupSplitDirection, TuiAgent } from '../../../../shared/types' import { isWslUncPath } from '../../../../shared/wsl-paths' import { TUI_AGENT_CONFIG } from '../../../../shared/tui-agent-config' @@ -165,7 +173,6 @@ import { beginAgentStartupDeliveryAttempt, releaseAgentStartupDeliveryAttempt } from '@/lib/agent-startup-delayed-delivery' -import { isExpectedAgentProcess } from '../../../../shared/agent-process-recognition' const pendingSpawnByPaneKey = new Map>() const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED' @@ -176,6 +183,7 @@ const AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS = 1500 const AGENT_TASK_COMPLETE_NOTIFICATION_DETAIL_MAX_AGE_MS = 10_000 const COMMAND_CODE_OUTPUT_DONE_SETTLE_MS = 1500 const SSH_SHELL_READY_STARTUP_FALLBACK_MS = 1500 +const MANUAL_AGENT_COMMAND_MAX_CHARS = 4096 const STARTUP_DRAFT_PASTE_QUIET_MS = 1500 const STARTUP_DRAFT_PASTE_TIMEOUT_MS = 8000 const HIDDEN_OUTPUT_RESTORE_SCROLLBACK_ROWS = 5000 @@ -1238,12 +1246,219 @@ export function connectPanePty( } /** * Resolves the authoritative owner agent type for this pane, checking tab launch, - * pane startup, and store state configuration. + * pane startup, typed command ownership, and store state configuration. * * Why: launch ownership wins so Pi-compatible live titles/hooks can't repaint an - * OMP-owned pane back to Pi; the stored status agentType is only the last-resort - * fallback because it can itself be a Pi-compatible frame. + * OMP-owned pane back to Pi; command ownership covers manually typed `omp` + * in generic terminals where launch metadata does not exist. */ + let commandInferredPaneAgent: TuiAgent | null = null + let pendingShellCommandLine = '' + let pendingShellCommandCursor = 0 + let commandInferredPaneAgentGeneration = 0 + let shellCommandInferenceSuspendedUntilCommandEnd = false + const resetPendingShellCommandLine = (): void => { + pendingShellCommandLine = '' + pendingShellCommandCursor = 0 + } + const rememberCommandInferredPaneAgent = (): void => { + const commandLine = pendingShellCommandLine.trim() + resetPendingShellCommandLine() + const nextAgent = commandLine + ? (recognizeAgentProcessFromCommandLine(commandLine)?.agent ?? null) + : null + commandInferredPaneAgent = nextAgent + commandInferredPaneAgentGeneration += 1 + } + const clearCommandInferredPaneAgent = (): void => { + commandInferredPaneAgent = null + resetPendingShellCommandLine() + commandInferredPaneAgentGeneration += 1 + } + const clearCommandInferredPaneAgentAfterPtySideEffects = (): void => { + const generation = commandInferredPaneAgentGeneration + resetPendingShellCommandLine() + queueMicrotask(() => { + setTimeout(() => { + if (commandInferredPaneAgentGeneration === generation) { + clearCommandInferredPaneAgent() + } + }, 0) + }) + } + const appendPendingShellCommandInput = (text: string): void => { + const available = MANUAL_AGENT_COMMAND_MAX_CHARS - pendingShellCommandLine.length + if (available <= 0) { + shellCommandInferenceSuspendedUntilCommandEnd = true + return + } + const inserted = text.slice(0, available) + pendingShellCommandLine = + pendingShellCommandLine.slice(0, pendingShellCommandCursor) + + inserted + + pendingShellCommandLine.slice(pendingShellCommandCursor) + pendingShellCommandCursor += inserted.length + if (inserted.length < text.length) { + shellCommandInferenceSuspendedUntilCommandEnd = true + } + } + const deletePendingShellCommandWord = (): void => { + const beforeCursor = pendingShellCommandLine.slice(0, pendingShellCommandCursor) + const afterCursor = pendingShellCommandLine.slice(pendingShellCommandCursor) + const nextBeforeCursor = beforeCursor.replace(/[^\S\r\n]*\S+[^\S\r\n]*$/, '') + pendingShellCommandLine = nextBeforeCursor + afterCursor + pendingShellCommandCursor = nextBeforeCursor.length + } + const cancelSuspendedShellCommandInference = (): void => { + if (!shellCommandInferenceSuspendedUntilCommandEnd) { + return + } + shellCommandInferenceSuspendedUntilCommandEnd = false + resetPendingShellCommandLine() + } + const deletePendingShellCommandCharacter = (): void => { + if (pendingShellCommandCursor === 0) { + return + } + pendingShellCommandLine = + pendingShellCommandLine.slice(0, pendingShellCommandCursor - 1) + + pendingShellCommandLine.slice(pendingShellCommandCursor) + pendingShellCommandCursor -= 1 + } + const deletePendingShellCommandCharacterAtCursor = (): void => { + if (pendingShellCommandCursor >= pendingShellCommandLine.length) { + return + } + pendingShellCommandLine = + pendingShellCommandLine.slice(0, pendingShellCommandCursor) + + pendingShellCommandLine.slice(pendingShellCommandCursor + 1) + } + const movePendingShellCommandCursor = (delta: number): void => { + pendingShellCommandCursor = Math.min( + pendingShellCommandLine.length, + Math.max(0, pendingShellCommandCursor + delta) + ) + } + const consumeShellCommandCsiSequence = (data: string, index: number): number | null => { + if (data.charCodeAt(index) !== 0x1b || data[index + 1] !== '[') { + return null + } + let cursor = index + 2 + while (cursor < data.length && /[0-9;?]/.test(data[cursor]!)) { + cursor += 1 + } + const final = data[cursor] + if (!final || !/[~A-Za-z]/.test(final)) { + return null + } + const params = data.slice(index + 2, cursor) + if (final === 'D') { + movePendingShellCommandCursor(-1) + } else if (final === 'C') { + movePendingShellCommandCursor(1) + } else if (final === 'H' || (final === '~' && params === '1')) { + pendingShellCommandCursor = 0 + } else if (final === 'F' || (final === '~' && params === '4')) { + pendingShellCommandCursor = pendingShellCommandLine.length + } else if (final === '~' && params === '3') { + deletePendingShellCommandCharacterAtCursor() + } else if (final === '~' && (params === '200' || params === '201')) { + // Bracketed paste wrappers are terminal framing, not shell command text. + } else { + resetPendingShellCommandLine() + } + return cursor + 1 + } + const getLivePaneAgentTitle = (): string | null => { + const state = useAppStore.getState() + const runtimeTitle = state.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id] + const tabTitle = (state.tabsByWorktree[deps.worktreeId] ?? []).find( + (entry) => entry.id === deps.tabId + )?.title + return runtimeTitle ?? tabTitle ?? null + } + const hasFreshPaneAgentSurface = (): boolean => { + const state = useAppStore.getState() + const entry = state.agentStatusByPaneKey[cacheKey] + const now = Date.now() + const entryIsFresh = + entry && + typeof entry.updatedAt === 'number' && + now - entry.updatedAt <= AGENT_STATUS_STALE_AFTER_MS + if (entryIsFresh && entry.state !== 'done') { + return true + } + const liveTitle = getLivePaneAgentTitle() + return detectAgentStatusFromTitle(liveTitle ?? '') !== null + } + const observeAcceptedShellCommandInput = (data: string): void => { + if (commandInferredPaneAgent) { + return + } + // Why: bytes typed inside a live agent TUI are prompt text, not shell + // commands, even if they spell another agent binary name. + if (hasFreshPaneAgentSurface()) { + resetPendingShellCommandLine() + return + } + if (shellCommandInferenceSuspendedUntilCommandEnd) { + if (data.includes('\x03') || data.includes('\x15')) { + shellCommandInferenceSuspendedUntilCommandEnd = false + resetPendingShellCommandLine() + } + if (data.includes('\r') || data.includes('\n')) { + shellCommandInferenceSuspendedUntilCommandEnd = false + } + return + } + if (data.length > MANUAL_AGENT_COMMAND_MAX_CHARS) { + resetPendingShellCommandLine() + shellCommandInferenceSuspendedUntilCommandEnd = !data.includes('\r') && !data.includes('\n') + return + } + for (let index = 0; index < data.length; index += 1) { + const char = data[index]! + if (char === '\r' || char === '\n') { + shellCommandInferenceSuspendedUntilCommandEnd = false + rememberCommandInferredPaneAgent() + if (commandInferredPaneAgent) { + return + } + continue + } + if (char === '\x7f' || char === '\b') { + deletePendingShellCommandCharacter() + continue + } + if (char === '\x17') { + deletePendingShellCommandWord() + continue + } + if (char === '\x03' || char === '\x15') { + resetPendingShellCommandLine() + continue + } + if (char === '\x1b') { + const nextIndex = consumeShellCommandCsiSequence(data, index) + if (nextIndex !== null) { + index = nextIndex - 1 + continue + } + resetPendingShellCommandLine() + continue + } + if (char < ' ') { + resetPendingShellCommandLine() + continue + } + if (char >= ' ') { + appendPendingShellCommandInput(char) + if (shellCommandInferenceSuspendedUntilCommandEnd) { + return + } + } + } + } const getAuthoritativePaneAgent = (): AgentType | undefined => { const state = useAppStore.getState() const tab = (state.tabsByWorktree[deps.worktreeId] ?? []).find( @@ -1253,6 +1468,7 @@ export function connectPanePty( tab?.launchAgent ?? paneStartup?.launchAgent ?? paneStartup?.initialAgentStatus?.agent ?? + commandInferredPaneAgent ?? state.agentStatusByPaneKey[cacheKey]?.agentType ) } @@ -1522,6 +1738,7 @@ export function connectPanePty( } const commandLifecycle = createTerminalCommandLifecycle({ onCommandFinished: () => { + clearCommandInferredPaneAgentAfterPtySideEffects() // Why: the finished command may have moved HEAD or the index (e.g. // `git checkout`); nudge git UI now instead of waiting for a poll. dispatchTerminalCommandFinishedEvent(deps.worktreeId) @@ -2192,6 +2409,7 @@ export function connectPanePty( } } const onAgentExited = (): void => { + clearCommandInferredPaneAgent() // Why: when the terminal title reverts to a plain shell (e.g., "bash", "zsh"), // the agent has exited. Clear any running cache timer so the sidebar doesn't // show a stale countdown for a tab that no longer has an active Claude session. @@ -2461,6 +2679,11 @@ export function connectPanePty( // excluded because those transports do not expose sendInputAccepted. const acknowledgedIntent = intent ?? inferIntentFromExactTerminalInput(data) if (acknowledgedIntent && transport.sendInputAccepted) { + if (acknowledgedIntent === 'ctrl-c') { + // Why: the accepted-write callback is async; let the next command be + // inferred if the user cancelled an oversized line and immediately typed. + cancelSuspendedShellCommandInference() + } clearPendingTerminalInputIntent() markTerminalInputSent() const writePromise = transport @@ -2468,6 +2691,7 @@ export function connectPanePty( .then((accepted) => { if (accepted) { recordAcceptedTerminalInputForHibernation() + observeAcceptedShellCommandInput(data) observeAcceptedTerminalInput(data, acknowledgedIntent) interruptInference.observeInputIntent(acknowledgedIntent) observeTitleOnlyInterrupt() @@ -2482,6 +2706,7 @@ export function connectPanePty( if (intent) { if (transport.sendInput(data)) { markAcceptedTerminalInputSent() + observeAcceptedShellCommandInput(data) observeAcceptedTerminalInput(data, intent) } clearPendingTerminalInputIntent() @@ -2489,6 +2714,7 @@ export function connectPanePty( } if (transport.sendInput(data)) { markAcceptedTerminalInputSent() + observeAcceptedShellCommandInput(data) observeAcceptedTerminalInput(data) observeSentTerminalInputIntent(data) } else { diff --git a/src/shared/agent-detection.test.ts b/src/shared/agent-detection.test.ts index b4f8495548a..a1bee91aef3 100644 --- a/src/shared/agent-detection.test.ts +++ b/src/shared/agent-detection.test.ts @@ -8,6 +8,7 @@ import { MAX_OSC_TITLE_CHARS } from './agent-detection' import { + hasCompatibleAgentTitleIdentity, normalizeCompatibleAgentStatusEntryForOwner, normalizeCompatibleAgentTitleForOwner, resolveCompatibleAgentTypeForOwner @@ -114,6 +115,14 @@ describe('Pi-compatible title detection', () => { expect(normalizeCompatibleAgentTitleForOwner('\u280b Pi', 'codex')).toBe('\u280b Pi') }) + it('identifies titles whose compatible identity can be re-owned', () => { + expect(hasCompatibleAgentTitleIdentity('Pi ready')).toBe(true) + expect(hasCompatibleAgentTitleIdentity('π - tmp')).toBe(true) + expect(hasCompatibleAgentTitleIdentity('\u280b OMP')).toBe(true) + expect(hasCompatibleAgentTitleIdentity('Fix pi bugs')).toBe(false) + expect(hasCompatibleAgentTitleIdentity('\u280b Codex')).toBe(false) + }) + it('normalizes Pi-compatible status identity and terminal title to the owner', () => { const status = normalizeCompatibleAgentStatusEntryForOwner( { diff --git a/src/shared/agent-title-owner.ts b/src/shared/agent-title-owner.ts index a72a46b1cb8..4e1dfda55af 100644 --- a/src/shared/agent-title-owner.ts +++ b/src/shared/agent-title-owner.ts @@ -43,6 +43,14 @@ function getProfileForTitle(title: string): TitleProfileMatch | null { return null } +/** + * Why: unknown-launch remote sessions need to wait for foreground ownership + * before publishing title frames whose identity can be re-owned. + */ +export function hasCompatibleAgentTitleIdentity(title: string): boolean { + return Boolean(getProfileForTitle(title)?.profile.titleIdentityGroup) +} + /** * Detects the agent status (working, permission, idle) from a terminal title, * accounting for legacy Pi titles. From 471881c14f07665ca17a596b11cd46d910904b7b Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 3 Jul 2026 01:06:13 -0700 Subject: [PATCH 24/48] Update WeChat QR code --- docs/assets/wechat-qr.png | Bin 62128 -> 443867 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/docs/assets/wechat-qr.png b/docs/assets/wechat-qr.png index 3c71c4952ea643ab6b2ac1233ffab84890d7d3a1..bf270d857e800cc3f19c4375f2e89140e30efee7 100644 GIT binary patch literal 443867 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

mVSHw72pz-@?~w3_Cb0iSjjPlQZCiY*#AyV! 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 z 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%0Dbc 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 z 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
{ - if (e.button !== 0) { - return - } - onActivate() - listeners?.onPointerDown?.(e) + onTabPointerDown( + e, + listeners?.onPointerDown as ((event: React.PointerEvent) => void) | undefined + ) }} onMouseDown={(e) => { if (e.button === 1) { diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.tsx index 3fb1a28ca5d..9de55f9c95e 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.tsx @@ -30,6 +30,7 @@ import { EditorFileTabContextMenu } from './EditorFileTabContextMenu' import { translate } from '@/i18n/i18n' import { TAB_CONTAINER_WIDTH_CLASSES, TAB_LABEL_WIDTH_CLASSES } from './tab-width-rules' import { EditorFileTabCloseButton } from './EditorFileTabCloseButton' +import { useTabStripPointerActivation } from './tab-strip-pointer-activation' export default function EditorFileTab({ file, @@ -201,6 +202,12 @@ export default function EditorFileTab({ }, [menuOpen]) const dragListeners = isRenaming ? undefined : listeners + // Why: defer activation to pointer-up so dragging the tab (reorder / move into + // another pane / split) does not switch the active tab mid-gesture. + const { onPointerDown: onTabPointerDown } = useTabStripPointerActivation({ + onActivate, + disabled: isRenaming + }) const tabRoot = (
{ - if (isRenaming || e.button !== 0) { - return - } - onActivate() - dragListeners?.onPointerDown?.(e) + onTabPointerDown( + e, + dragListeners?.onPointerDown as ((event: React.PointerEvent) => void) | undefined + ) }} onDoubleClick={() => { if (file.isPreview && onMakePermanent) { diff --git a/src/renderer/src/components/tab-bar/SortableTab.tsx b/src/renderer/src/components/tab-bar/SortableTab.tsx index 3bef8529a75..fa90c59e553 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.tsx @@ -25,6 +25,7 @@ import { SortableTabContextMenu } from './SortableTabContextMenu' import { translate } from '@/i18n/i18n' import { TAB_CONTAINER_WIDTH_CLASSES, TAB_LABEL_WIDTH_CLASSES } from './tab-width-rules' import { useShortcutKeyDetails } from '@/hooks/useShortcutLabel' +import { useTabStripPointerActivation } from './tab-strip-pointer-activation' type SortableTabProps = { tab: TerminalTab @@ -210,6 +211,16 @@ export default function SortableTab({ // so dnd-kit's a11y attributes (aria-roledescription, etc.) remain on the element — only // the pointer listeners are gated so a drag can't start while typing. const dragListeners = isEditing ? undefined : listeners + const handleActivate = useCallback(() => { + onActivate(tab.id) + }, [onActivate, tab.id]) + // Why: defer activation to pointer-up so pressing a tab to drag it (reorder / + // move into another pane / split) does not switch the active tab or steal + // terminal focus mid-gesture. See tab-strip-pointer-activation. + const { onPointerDown: onTabPointerDown } = useTabStripPointerActivation({ + onActivate: handleActivate, + disabled: isEditing + }) const closeShortcut = useShortcutKeyDetails('tab.close') const tabTitle = tab.customTitle ?? tab.title const tabRoot = ( @@ -243,11 +254,10 @@ export default function SortableTab({ handleRenameOpen() }} onPointerDown={(e) => { - if (isEditing || e.button !== 0) { - return - } - onActivate(tab.id) - dragListeners?.onPointerDown?.(e) + onTabPointerDown( + e, + dragListeners?.onPointerDown as ((event: React.PointerEvent) => void) | undefined + ) }} onMouseDown={(e) => { // Why: prevent default browser middle-click behavior (auto-scroll) diff --git a/src/renderer/src/components/tab-bar/tab-strip-pointer-activation.test.tsx b/src/renderer/src/components/tab-bar/tab-strip-pointer-activation.test.tsx new file mode 100644 index 00000000000..3bd467e49b0 --- /dev/null +++ b/src/renderer/src/components/tab-bar/tab-strip-pointer-activation.test.tsx @@ -0,0 +1,111 @@ +// @vitest-environment happy-dom + +import { act, renderHook } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { TAB_DRAG_ACTIVATION_DISTANCE_PX } from '../tab-group/useTabDragSplit' +import { useTabStripPointerActivation } from './tab-strip-pointer-activation' + +function pointerDownEvent(clientX: number, clientY: number, button = 0): React.PointerEvent { + return { button, clientX, clientY } as unknown as React.PointerEvent +} + +function firePointer(type: string, clientX: number, clientY: number): void { + act(() => { + window.dispatchEvent(new PointerEvent(type, { clientX, clientY, bubbles: true })) + }) +} + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('useTabStripPointerActivation', () => { + it('activates on a release that never crossed the drag threshold (a click)', () => { + const onActivate = vi.fn() + const dragListener = vi.fn() + const { result } = renderHook(() => useTabStripPointerActivation({ onActivate })) + + act(() => result.current.onPointerDown(pointerDownEvent(10, 10), dragListener)) + // Why: the dnd-kit gesture must start on pointerdown even though activation + // is deferred. + expect(dragListener).toHaveBeenCalledTimes(1) + expect(onActivate).not.toHaveBeenCalled() + + // Release within the threshold -> click -> activate. + firePointer('pointerup', 12, 11) + expect(onActivate).toHaveBeenCalledTimes(1) + }) + + it('suppresses activation when the pointer travels past the drag threshold', () => { + const onActivate = vi.fn() + const { result } = renderHook(() => useTabStripPointerActivation({ onActivate })) + + act(() => result.current.onPointerDown(pointerDownEvent(10, 10))) + firePointer('pointermove', 10 + TAB_DRAG_ACTIVATION_DISTANCE_PX + 5, 10) + firePointer('pointerup', 300, 40) + + expect(onActivate).not.toHaveBeenCalled() + }) + + it('stays a drag even if the pointer returns near the start before release', () => { + const onActivate = vi.fn() + const { result } = renderHook(() => useTabStripPointerActivation({ onActivate })) + + act(() => result.current.onPointerDown(pointerDownEvent(10, 10))) + // Cross the threshold, then come back over the origin and release. + firePointer('pointermove', 200, 200) + firePointer('pointerup', 11, 11) + + expect(onActivate).not.toHaveBeenCalled() + }) + + it('does not activate when the press is cancelled', () => { + const onActivate = vi.fn() + const { result } = renderHook(() => useTabStripPointerActivation({ onActivate })) + + act(() => result.current.onPointerDown(pointerDownEvent(10, 10))) + firePointer('pointercancel', 10, 10) + firePointer('pointerup', 10, 10) + + expect(onActivate).not.toHaveBeenCalled() + }) + + it('ignores non-left buttons and disabled presses', () => { + const onActivate = vi.fn() + const dragListener = vi.fn() + const { result, rerender } = renderHook( + ({ disabled }: { disabled: boolean }) => + useTabStripPointerActivation({ onActivate, disabled }), + { initialProps: { disabled: false } } + ) + + // Right-click: ignored. + act(() => result.current.onPointerDown(pointerDownEvent(10, 10, 2), dragListener)) + firePointer('pointerup', 10, 10) + expect(onActivate).not.toHaveBeenCalled() + expect(dragListener).not.toHaveBeenCalled() + + // Disabled: ignored. + rerender({ disabled: true }) + act(() => result.current.onPointerDown(pointerDownEvent(10, 10), dragListener)) + firePointer('pointerup', 10, 10) + expect(onActivate).not.toHaveBeenCalled() + expect(dragListener).not.toHaveBeenCalled() + }) + + it('activates a click that lands after a prior drag gesture (regression #6395)', () => { + const onActivate = vi.fn() + const { result } = renderHook(() => useTabStripPointerActivation({ onActivate })) + + // First gesture: a drag (reorder). No activation. + act(() => result.current.onPointerDown(pointerDownEvent(10, 10))) + firePointer('pointermove', 300, 10) + firePointer('pointerup', 300, 10) + expect(onActivate).not.toHaveBeenCalled() + + // Second gesture: a plain click. Must activate. + act(() => result.current.onPointerDown(pointerDownEvent(400, 10))) + firePointer('pointerup', 400, 10) + expect(onActivate).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/tab-bar/tab-strip-pointer-activation.ts b/src/renderer/src/components/tab-bar/tab-strip-pointer-activation.ts new file mode 100644 index 00000000000..e1a32e1e0d8 --- /dev/null +++ b/src/renderer/src/components/tab-bar/tab-strip-pointer-activation.ts @@ -0,0 +1,91 @@ +import { useCallback, useEffect, useRef } from 'react' +import { TAB_DRAG_ACTIVATION_DISTANCE_PX } from '../tab-group/useTabDragSplit' + +/** + * Defer tab activation to pointer-up and suppress it when the press turns into a + * drag. PR #5927 shipped this so dragging a tab (to reorder, move into another + * pane, or split) never switched the active tab or stole terminal focus + * mid-gesture; #6395 removed it (activating eagerly on pointerdown) to fix + * click-to-switch-after-reorder, which regressed the drag feature. + * + * We gate on measured pointer DISPLACEMENT, not the drag-active context ref the + * old hook used — that ref clears asynchronously relative to the drop's + * pointerup, which is what made #6395's click-after-reorder misfire. Displacement + * mirrors dnd-kit's own activation threshold exactly: once the pointer travels + * past it the gesture is a drag (activation suppressed); a release within it is a + * click (activate). Because each press measures its own gesture, a click after a + * reorder always activates. + */ +export function useTabStripPointerActivation({ + onActivate, + disabled = false +}: { + onActivate: () => void + disabled?: boolean +}): { + onPointerDown: ( + event: React.PointerEvent, + dragListener?: (event: React.PointerEvent) => void + ) => void +} { + const onActivateRef = useRef(onActivate) + onActivateRef.current = onActivate + const cleanupRef = useRef<(() => void) | null>(null) + + // Why: a press still holding when the tab unmounts (tab closed mid-drag, group + // collapse) would otherwise leak its window listeners and later fire activation + // on a dead closure. + useEffect(() => () => cleanupRef.current?.(), []) + + const onPointerDown = useCallback( + (event: React.PointerEvent, dragListener?: (event: React.PointerEvent) => void) => { + if (disabled || event.button !== 0) { + return + } + // Why: start the dnd-kit gesture immediately on pointerdown; only the + // activation decision is deferred to release. + dragListener?.(event) + + cleanupRef.current?.() + const startX = event.clientX + const startY = event.clientY + let draggedPastThreshold = false + + const cleanup = (): void => { + window.removeEventListener('pointermove', onPointerMove) + window.removeEventListener('pointerup', onPointerUp) + window.removeEventListener('pointercancel', onPointerCancel) + cleanupRef.current = null + } + const onPointerMove = (moveEvent: PointerEvent): void => { + if ( + Math.hypot(moveEvent.clientX - startX, moveEvent.clientY - startY) >= + TAB_DRAG_ACTIVATION_DISTANCE_PX + ) { + draggedPastThreshold = true + } + } + const onPointerUp = (): void => { + const wasDrag = draggedPastThreshold + cleanup() + // Why: only a release that never crossed the drag threshold is a click. + // Activating after a real drag would yank the just-dropped tab's pane + // back to the source selection. + if (!wasDrag) { + onActivateRef.current() + } + } + const onPointerCancel = (): void => { + cleanup() + } + + window.addEventListener('pointermove', onPointerMove) + window.addEventListener('pointerup', onPointerUp) + window.addEventListener('pointercancel', onPointerCancel) + cleanupRef.current = cleanup + }, + [disabled] + ) + + return { onPointerDown } +} diff --git a/src/renderer/src/components/tab-group/TabGroupPanel.tsx b/src/renderer/src/components/tab-group/TabGroupPanel.tsx index 9f85c325a08..57dbf5946fc 100644 --- a/src/renderer/src/components/tab-group/TabGroupPanel.tsx +++ b/src/renderer/src/components/tab-group/TabGroupPanel.tsx @@ -225,7 +225,9 @@ export default function TabGroupPanel({ user can only drag from the tiny left-sidebar header strip. */}
{/* Why: Electron's native drag hit-test only respects no-drag on DOM diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index b1f90ac2f60..dd5d2116b8c 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -15,7 +15,11 @@ import { resolveOpaqueTerminalBackground, resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme' -import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager' +import type { + ManagedPane, + PaneExternalDropTarget, + PaneManager +} from '@/lib/pane-manager/pane-manager' import TerminalSearch from '@/components/TerminalSearch' import type { PtyTransport } from './pty-transport' import { fitPanes, isWindowsUserAgent } from './pane-helpers' @@ -62,6 +66,11 @@ import { useSystemPrefersDark } from './use-system-prefers-dark' import { useTerminalPaneGlobalEffects } from './use-terminal-pane-global-effects' import { useTerminalPaneLifecycle } from './use-terminal-pane-lifecycle' import { useTerminalPaneContextMenu } from './use-terminal-pane-context-menu' +import { + detachTerminalPaneToTab, + isTerminalTabStripDropTarget, + resolveTerminalTabStripDropTarget +} from './terminal-pane-tab-detach' import type { PreparedAgentSessionFork } from './terminal-agent-session-fork' import { useNotificationDispatch } from './use-notification-dispatch' import { connectPanePty } from './pty-connection' @@ -1298,6 +1307,54 @@ export default function TerminalPane({ setPendingCloseConfirmation(null) }, []) + const resolveExternalPaneDropTarget = useCallback( + ({ + sourcePaneId, + clientX, + clientY + }: { + sourcePaneId: number + clientX: number + clientY: number + }): PaneExternalDropTarget | null => { + const manager = managerRef.current + const panes = manager?.getPanes() ?? [] + if (panes.length <= 1 || !panes.some((pane) => pane.id === sourcePaneId)) { + return null + } + return resolveTerminalTabStripDropTarget({ + clientX, + clientY, + groupsByWorktree: useAppStore.getState().groupsByWorktree, + worktreeId + }) + }, + [worktreeId] + ) + + const handleExternalPaneDrop = useCallback( + (sourcePaneId: number, target: PaneExternalDropTarget): boolean => { + if (!isTerminalTabStripDropTarget(target)) { + return false + } + const fallbackPtyId = paneTransportsRef.current.get(sourcePaneId)?.getPtyId() ?? null + return ( + detachTerminalPaneToTab({ + fallbackPtyId, + getStore: useAppStore.getState, + manager: managerRef.current, + persistLayoutSnapshot, + sourcePaneId, + sourceTabId: tabId, + targetGroupId: target.groupId, + targetIndex: target.insertionIndex, + worktreeId + }) !== null + ) + }, + [persistLayoutSnapshot, tabId, worktreeId] + ) + useTerminalPaneLifecycle({ tabId, worktreeId, @@ -1354,7 +1411,9 @@ export default function TerminalPane({ paneTitlesRef, setRenamingPaneId, setPaneCount, - setPaneLayoutRevision + setPaneLayoutRevision, + resolveExternalPaneDropTarget, + onExternalPaneDrop: handleExternalPaneDrop }) useEffect(() => { diff --git a/src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.test.ts b/src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.test.ts new file mode 100644 index 00000000000..782113e1641 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest' +import type { TerminalLayoutSnapshot } from '../../../../shared/types' +import { detachTerminalLayoutLeaf } from './terminal-layout-leaf-detach' + +const LEAF_1 = '11111111-1111-4111-8111-111111111111' +const LEAF_2 = '22222222-2222-4222-8222-222222222222' +const LEAF_3 = '33333333-3333-4333-8333-333333333333' + +function splitLayout(): TerminalLayoutSnapshot { + return { + root: { + type: 'split', + direction: 'vertical', + ratio: 0.25, + first: { type: 'leaf', leafId: LEAF_1 }, + second: { + type: 'split', + direction: 'horizontal', + ratio: 0.7, + first: { type: 'leaf', leafId: LEAF_2 }, + second: { type: 'leaf', leafId: LEAF_3 } + } + }, + activeLeafId: LEAF_2, + expandedLeafId: LEAF_3, + ptyIdsByLeafId: { + [LEAF_1]: 'pty-1', + [LEAF_2]: 'remote:env-1@@terminal-1', + [LEAF_3]: 'pty-3' + }, + buffersByLeafId: { + [LEAF_1]: 'buffer-1', + [LEAF_2]: 'buffer-2', + [LEAF_3]: 'buffer-3' + }, + scrollbackRefsByLeafId: { + [LEAF_1]: 'scrollback-1', + [LEAF_2]: 'scrollback-2', + [LEAF_3]: 'scrollback-3' + }, + titlesByLeafId: { + [LEAF_1]: 'one', + [LEAF_2]: 'remote shell', + [LEAF_3]: 'three' + } + } +} + +describe('detachTerminalLayoutLeaf', () => { + it('extracts a nested leaf into a single-pane layout while preserving SSH PTY state', () => { + const detached = detachTerminalLayoutLeaf(splitLayout(), LEAF_2) + + expect(detached?.ptyId).toBe('remote:env-1@@terminal-1') + expect(detached?.detachedLayout).toEqual({ + root: { type: 'leaf', leafId: LEAF_2 }, + activeLeafId: LEAF_2, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_2]: 'remote:env-1@@terminal-1' }, + buffersByLeafId: { [LEAF_2]: 'buffer-2' }, + scrollbackRefsByLeafId: { [LEAF_2]: 'scrollback-2' }, + titlesByLeafId: { [LEAF_2]: 'remote shell' } + }) + expect(detached?.sourceLayout).toEqual({ + root: { + type: 'split', + direction: 'vertical', + ratio: 0.25, + first: { type: 'leaf', leafId: LEAF_1 }, + second: { type: 'leaf', leafId: LEAF_3 } + }, + activeLeafId: LEAF_1, + expandedLeafId: LEAF_3, + ptyIdsByLeafId: { + [LEAF_1]: 'pty-1', + [LEAF_3]: 'pty-3' + }, + buffersByLeafId: { + [LEAF_1]: 'buffer-1', + [LEAF_3]: 'buffer-3' + }, + scrollbackRefsByLeafId: { + [LEAF_1]: 'scrollback-1', + [LEAF_3]: 'scrollback-3' + }, + titlesByLeafId: { + [LEAF_1]: 'one', + [LEAF_3]: 'three' + } + }) + }) + + it('clears source expanded selection when the expanded leaf is detached', () => { + const detached = detachTerminalLayoutLeaf(splitLayout(), LEAF_3) + + expect(detached?.sourceLayout.expandedLeafId).toBeNull() + expect(detached?.sourceLayout.root).toEqual({ + type: 'split', + direction: 'vertical', + ratio: 0.25, + first: { type: 'leaf', leafId: LEAF_1 }, + second: { type: 'leaf', leafId: LEAF_2 } + }) + }) + + it('returns null for missing or only leaf layouts', () => { + expect(detachTerminalLayoutLeaf(splitLayout(), 'missing')).toBeNull() + expect( + detachTerminalLayoutLeaf( + { + root: { type: 'leaf', leafId: LEAF_1 }, + activeLeafId: LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_1]: 'pty-1' } + }, + LEAF_1 + ) + ).toBeNull() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.ts b/src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.ts new file mode 100644 index 00000000000..d43702057f1 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.ts @@ -0,0 +1,119 @@ +import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode } from '../../../../shared/types' +import { + collectLeafIdsInOrder, + normalizeTerminalLayoutSnapshot, + resolveTerminalLayoutActiveLeafId +} from './terminal-layout-leaf-ids' + +export type DetachedTerminalLayoutLeaf = { + sourceLayout: TerminalLayoutSnapshot + detachedLayout: TerminalLayoutSnapshot + ptyId: string | null +} + +function removeLeafFromTree( + node: TerminalPaneLayoutNode, + leafId: string +): { node: TerminalPaneLayoutNode | null; removed: boolean } { + if (node.type === 'leaf') { + return node.leafId === leafId ? { node: null, removed: true } : { node, removed: false } + } + + const first = removeLeafFromTree(node.first, leafId) + const second = removeLeafFromTree(node.second, leafId) + if (!first.removed && !second.removed) { + return { node, removed: false } + } + if (!first.node) { + return { node: second.node, removed: true } + } + if (!second.node) { + return { node: first.node, removed: true } + } + return { + node: { + ...node, + first: first.node, + second: second.node + }, + removed: true + } +} + +function omitLeafRecord( + source: Record | undefined, + leafId: string +): Record | undefined { + if (!source || !Object.prototype.hasOwnProperty.call(source, leafId)) { + return source + } + const next = { ...source } + delete next[leafId] + return Object.keys(next).length > 0 ? next : undefined +} + +function singleLeafRecord( + source: Record | undefined, + leafId: string +): Record | undefined { + const value = source?.[leafId] + return value ? { [leafId]: value } : undefined +} + +export function detachTerminalLayoutLeaf( + snapshot: TerminalLayoutSnapshot | null | undefined, + leafId: string +): DetachedTerminalLayoutLeaf | null { + const layout = normalizeTerminalLayoutSnapshot(snapshot).snapshot + if (!layout.root) { + return null + } + + const originalLeafIds = collectLeafIdsInOrder(layout.root) + if (!originalLeafIds.includes(leafId) || originalLeafIds.length <= 1) { + return null + } + + const removal = removeLeafFromTree(layout.root, leafId) + if (!removal.removed || !removal.node) { + return null + } + + const ptyIdsByLeafId = omitLeafRecord(layout.ptyIdsByLeafId, leafId) + const buffersByLeafId = omitLeafRecord(layout.buffersByLeafId, leafId) + const scrollbackRefsByLeafId = omitLeafRecord(layout.scrollbackRefsByLeafId, leafId) + const titlesByLeafId = omitLeafRecord(layout.titlesByLeafId, leafId) + const sourceLayout: TerminalLayoutSnapshot = { + root: removal.node, + activeLeafId: resolveTerminalLayoutActiveLeafId({ + root: removal.node, + activeLeafId: layout.activeLeafId === leafId ? null : layout.activeLeafId, + ptyIdsByLeafId + }), + expandedLeafId: layout.expandedLeafId === leafId ? null : layout.expandedLeafId, + ...(ptyIdsByLeafId ? { ptyIdsByLeafId } : {}), + ...(buffersByLeafId ? { buffersByLeafId } : {}), + ...(scrollbackRefsByLeafId ? { scrollbackRefsByLeafId } : {}), + ...(titlesByLeafId ? { titlesByLeafId } : {}) + } + + const detachedPtyIdsByLeafId = singleLeafRecord(layout.ptyIdsByLeafId, leafId) + const detachedBuffersByLeafId = singleLeafRecord(layout.buffersByLeafId, leafId) + const detachedScrollbackRefsByLeafId = singleLeafRecord(layout.scrollbackRefsByLeafId, leafId) + const detachedTitlesByLeafId = singleLeafRecord(layout.titlesByLeafId, leafId) + return { + sourceLayout, + detachedLayout: { + root: { type: 'leaf', leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ...(detachedPtyIdsByLeafId ? { ptyIdsByLeafId: detachedPtyIdsByLeafId } : {}), + ...(detachedBuffersByLeafId ? { buffersByLeafId: detachedBuffersByLeafId } : {}), + ...(detachedScrollbackRefsByLeafId + ? { scrollbackRefsByLeafId: detachedScrollbackRefsByLeafId } + : {}), + ...(detachedTitlesByLeafId ? { titlesByLeafId: detachedTitlesByLeafId } : {}) + }, + ptyId: detachedPtyIdsByLeafId?.[leafId] ?? null + } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts new file mode 100644 index 00000000000..a92e03cce8c --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts @@ -0,0 +1,383 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AppState } from '@/store' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/types' +import { + detachTerminalPaneToTab, + resolveTerminalTabStripDropTarget, + type TerminalPaneTabDetachStore +} from './terminal-pane-tab-detach' + +const WORKTREE_ID = 'repo-1::/worktree' +const SOURCE_TAB_ID = 'tab-source' +const TARGET_GROUP_ID = 'group-target' +const EXISTING_TAB_1 = 'tab-existing-1' +const EXISTING_TAB_2 = 'tab-existing-2' +const LEAF_1 = '11111111-1111-4111-8111-111111111111' +const LEAF_2 = '22222222-2222-4222-8222-222222222222' + +function rect(args: { left: number; top: number; width: number; height: number }): DOMRect { + return { + left: args.left, + top: args.top, + right: args.left + args.width, + bottom: args.top + args.height, + width: args.width, + height: args.height + } as DOMRect +} + +function splitLayout(): TerminalLayoutSnapshot { + return { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEAF_1 }, + second: { type: 'leaf', leafId: LEAF_2 } + }, + activeLeafId: LEAF_2, + expandedLeafId: null, + ptyIdsByLeafId: { + [LEAF_1]: 'pty-left', + [LEAF_2]: 'remote:env-1@@terminal-1' + }, + buffersByLeafId: { + [LEAF_2]: 'remote-buffer' + }, + titlesByLeafId: { + [LEAF_2]: 'remote shell' + } + } +} + +function createTerminalTab(id: string, ptyId: string | null): TerminalTab { + return { + id, + ptyId, + worktreeId: WORKTREE_ID, + title: 'Terminal 2', + defaultTitle: 'Terminal 2', + customTitle: null, + color: null, + sortOrder: 1, + createdAt: 1 + } +} + +function createStore( + layout: TerminalLayoutSnapshot = splitLayout(), + targetTabOrder: string[] = [EXISTING_TAB_1, EXISTING_TAB_2] +): TerminalPaneTabDetachStore { + const store = { + createTab: vi.fn((_worktreeId, _targetGroupId, _shellOverride, options) => { + const tab = createTerminalTab('tab-detached', options?.initialPtyId ?? null) + const group = store.groupsByWorktree[WORKTREE_ID]?.find( + (candidate) => candidate.id === TARGET_GROUP_ID + ) + if (group && !group.tabOrder.includes(tab.id)) { + group.tabOrder = [...group.tabOrder, tab.id] + } + return tab + }), + groupsByWorktree: { + [WORKTREE_ID]: [ + { + id: TARGET_GROUP_ID, + worktreeId: WORKTREE_ID, + activeTabId: targetTabOrder[0] ?? null, + tabOrder: targetTabOrder, + recentTabIds: [] + } + ] + }, + reorderUnifiedTabs: vi.fn((groupId: string, tabIds: string[]) => { + const group = store.groupsByWorktree[WORKTREE_ID]?.find( + (candidate) => candidate.id === groupId + ) + if (group) { + group.tabOrder = tabIds + } + }), + setActiveTab: vi.fn(), + setActiveTabType: vi.fn(), + setTabLayout: vi.fn((tabId: string, nextLayout: TerminalLayoutSnapshot | null) => { + if (nextLayout) { + store.terminalLayoutsByTabId[tabId] = nextLayout + } else { + delete store.terminalLayoutsByTabId[tabId] + } + }), + syncPaneDetachPtyOwnership: vi.fn(), + terminalLayoutsByTabId: { + [SOURCE_TAB_ID]: layout + } + } + return store as unknown as TerminalPaneTabDetachStore +} + +describe('resolveTerminalTabStripDropTarget', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('finds a same-worktree tab strip under overlay elements', () => { + const stripRect = rect({ left: 0, top: 0, width: 300, height: 32 }) + const strip = { + dataset: { tabGroupStripId: TARGET_GROUP_ID, worktreeId: WORKTREE_ID }, + getBoundingClientRect: () => stripRect, + querySelectorAll: () => [] + } + const overlay = { closest: () => null } + const child = { closest: () => strip } + vi.stubGlobal('document', { + elementsFromPoint: vi.fn(() => [overlay, child]), + elementFromPoint: vi.fn() + }) + + expect( + resolveTerminalTabStripDropTarget({ + clientX: 10, + clientY: 10, + groupsByWorktree: { + [WORKTREE_ID]: [{ id: TARGET_GROUP_ID } as AppState['groupsByWorktree'][string][number]] + }, + worktreeId: WORKTREE_ID + }) + ).toEqual({ + id: TARGET_GROUP_ID, + groupId: TARGET_GROUP_ID, + worktreeId: WORKTREE_ID, + rect: stripRect + }) + }) + + it('resolves the insertion slot from the hovered tab side', () => { + const stripRect = rect({ left: 0, top: 0, width: 300, height: 32 }) + const firstTabRect = rect({ left: 0, top: 0, width: 80, height: 32 }) + const secondTabRect = rect({ left: 80, top: 0, width: 80, height: 32 }) + const firstTab = { + dataset: { tabId: EXISTING_TAB_1 }, + getBoundingClientRect: () => firstTabRect + } + const secondTab = { + dataset: { tabId: EXISTING_TAB_2 }, + getBoundingClientRect: () => secondTabRect + } + const strip = { + dataset: { tabGroupStripId: TARGET_GROUP_ID, worktreeId: WORKTREE_ID }, + getBoundingClientRect: () => stripRect, + querySelectorAll: () => [firstTab, secondTab] + } + vi.stubGlobal('document', { + elementsFromPoint: vi.fn(() => [{ closest: () => firstTab }, { closest: () => strip }]), + elementFromPoint: vi.fn() + }) + + expect( + resolveTerminalTabStripDropTarget({ + clientX: 60, + clientY: 10, + groupsByWorktree: { + [WORKTREE_ID]: [ + { + id: TARGET_GROUP_ID, + activeTabId: EXISTING_TAB_1, + tabOrder: [EXISTING_TAB_1, EXISTING_TAB_2], + worktreeId: WORKTREE_ID + } as AppState['groupsByWorktree'][string][number] + ] + }, + worktreeId: WORKTREE_ID + }) + ).toMatchObject({ + groupId: TARGET_GROUP_ID, + insertionIndex: 1, + overlayKind: 'insertion', + rect: rect({ left: 80, top: 0, width: 2, height: 32 }) + }) + }) + + it('ignores strips from another worktree', () => { + const strip = { + dataset: { tabGroupStripId: TARGET_GROUP_ID, worktreeId: 'other-worktree' }, + getBoundingClientRect: () => + ({ left: 0, top: 0, right: 300, bottom: 32, width: 300, height: 32 }) as DOMRect + } + vi.stubGlobal('document', { + elementsFromPoint: vi.fn(() => [{ closest: () => strip }]), + elementFromPoint: vi.fn() + }) + + expect( + resolveTerminalTabStripDropTarget({ + clientX: 10, + clientY: 10, + groupsByWorktree: { + [WORKTREE_ID]: [{ id: TARGET_GROUP_ID } as AppState['groupsByWorktree'][string][number]] + }, + worktreeId: WORKTREE_ID + }) + ).toBeNull() + }) +}) + +describe('detachTerminalPaneToTab', () => { + it('creates a new terminal tab with the detached leaf layout and PTY id', () => { + const store = createStore() + const manager = { + getPanes: vi.fn(() => [{ id: 1 }, { id: 2 }]), + getLeafId: vi.fn((paneId: number) => (paneId === 2 ? LEAF_2 : LEAF_1)), + detachPaneForExternalMove: vi.fn(() => true) + } + const persistLayoutSnapshot = vi.fn() + + const result = detachTerminalPaneToTab({ + manager, + getStore: () => store, + persistLayoutSnapshot, + sourcePaneId: 2, + sourceTabId: SOURCE_TAB_ID, + targetGroupId: TARGET_GROUP_ID, + worktreeId: WORKTREE_ID + }) + + expect(result?.ptyId).toBe('remote:env-1@@terminal-1') + expect(manager.detachPaneForExternalMove).toHaveBeenCalledWith(2) + expect(store.createTab).toHaveBeenCalledWith(WORKTREE_ID, TARGET_GROUP_ID, undefined, { + activate: true, + initialPtyId: 'remote:env-1@@terminal-1', + recordInteraction: true + }) + expect(store.setTabLayout).toHaveBeenCalledWith(SOURCE_TAB_ID, { + root: { type: 'leaf', leafId: LEAF_1 }, + activeLeafId: LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_1]: 'pty-left' } + }) + expect(store.setTabLayout).toHaveBeenCalledWith('tab-detached', { + root: { type: 'leaf', leafId: LEAF_2 }, + activeLeafId: LEAF_2, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_2]: 'remote:env-1@@terminal-1' }, + buffersByLeafId: { [LEAF_2]: 'remote-buffer' }, + titlesByLeafId: { [LEAF_2]: 'remote shell' } + }) + expect(store.syncPaneDetachPtyOwnership).toHaveBeenCalledWith({ + detachedPtyId: 'remote:env-1@@terminal-1', + sourceLayout: { + root: { type: 'leaf', leafId: LEAF_1 }, + activeLeafId: LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_1]: 'pty-left' } + }, + sourceTabId: SOURCE_TAB_ID, + targetTabId: 'tab-detached' + }) + expect(store.setActiveTab).toHaveBeenCalledWith('tab-detached') + expect(store.setActiveTabType).toHaveBeenCalledWith('terminal') + expect(persistLayoutSnapshot).toHaveBeenCalled() + }) + + it('syncs PTY ownership when the primary source pane is detached', () => { + const store = createStore() + const manager = { + getPanes: vi.fn(() => [{ id: 1 }, { id: 2 }]), + getLeafId: vi.fn((paneId: number) => (paneId === 1 ? LEAF_1 : LEAF_2)), + detachPaneForExternalMove: vi.fn(() => true) + } + + const result = detachTerminalPaneToTab({ + getStore: () => store, + manager, + persistLayoutSnapshot: vi.fn(), + sourcePaneId: 1, + sourceTabId: SOURCE_TAB_ID, + targetGroupId: TARGET_GROUP_ID, + worktreeId: WORKTREE_ID + }) + + expect(result?.ptyId).toBe('pty-left') + expect(store.setTabLayout).toHaveBeenCalledWith(SOURCE_TAB_ID, { + root: { type: 'leaf', leafId: LEAF_2 }, + activeLeafId: LEAF_2, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_2]: 'remote:env-1@@terminal-1' }, + buffersByLeafId: { [LEAF_2]: 'remote-buffer' }, + titlesByLeafId: { [LEAF_2]: 'remote shell' } + }) + expect(store.syncPaneDetachPtyOwnership).toHaveBeenCalledWith({ + detachedPtyId: 'pty-left', + sourceLayout: { + root: { type: 'leaf', leafId: LEAF_2 }, + activeLeafId: LEAF_2, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_2]: 'remote:env-1@@terminal-1' }, + buffersByLeafId: { [LEAF_2]: 'remote-buffer' }, + titlesByLeafId: { [LEAF_2]: 'remote shell' } + }, + sourceTabId: SOURCE_TAB_ID, + targetTabId: 'tab-detached' + }) + }) + + it('moves the detached tab into the requested group slot', () => { + const store = createStore(splitLayout(), [EXISTING_TAB_1, EXISTING_TAB_2]) + const manager = { + getPanes: vi.fn(() => [{ id: 1 }, { id: 2 }]), + getLeafId: vi.fn((paneId: number) => (paneId === 2 ? LEAF_2 : LEAF_1)), + detachPaneForExternalMove: vi.fn(() => true) + } + + detachTerminalPaneToTab({ + getStore: () => store, + manager, + persistLayoutSnapshot: vi.fn(), + sourcePaneId: 2, + sourceTabId: SOURCE_TAB_ID, + targetGroupId: TARGET_GROUP_ID, + targetIndex: 1, + worktreeId: WORKTREE_ID + }) + + expect(store.reorderUnifiedTabs).toHaveBeenCalledWith( + TARGET_GROUP_ID, + [EXISTING_TAB_1, 'tab-detached', EXISTING_TAB_2], + { recordInteraction: false } + ) + }) + + it('uses the live transport PTY id when the snapshot has not persisted it yet', () => { + const store = createStore({ + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEAF_1 }, + second: { type: 'leaf', leafId: LEAF_2 } + }, + activeLeafId: LEAF_2, + expandedLeafId: null + }) + const manager = { + getPanes: vi.fn(() => [{ id: 1 }, { id: 2 }]), + getLeafId: vi.fn(() => LEAF_2), + detachPaneForExternalMove: vi.fn(() => true) + } + + detachTerminalPaneToTab({ + fallbackPtyId: 'remote:env-2@@terminal-9', + getStore: () => store, + manager, + persistLayoutSnapshot: vi.fn(), + sourcePaneId: 2, + sourceTabId: SOURCE_TAB_ID, + targetGroupId: TARGET_GROUP_ID, + worktreeId: WORKTREE_ID + }) + + expect(store.setTabLayout).toHaveBeenCalledWith('tab-detached', { + root: { type: 'leaf', leafId: LEAF_2 }, + activeLeafId: LEAF_2, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_2]: 'remote:env-2@@terminal-9' } + }) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts new file mode 100644 index 00000000000..26f3d5996e3 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts @@ -0,0 +1,306 @@ +import type { PaneExternalDropTarget } from '@/lib/pane-manager/pane-manager' +import type { AppState } from '@/store' +import type { TerminalTab } from '../../../../shared/types' +import { detachTerminalLayoutLeaf } from './terminal-layout-leaf-detach' + +const TAB_GROUP_STRIP_SELECTOR = '[data-tab-group-strip-id][data-worktree-id]' + +export type TerminalPaneTabDetachStore = Pick< + AppState, + | 'createTab' + | 'groupsByWorktree' + | 'reorderUnifiedTabs' + | 'setActiveTab' + | 'setActiveTabType' + | 'setTabLayout' + | 'syncPaneDetachPtyOwnership' + | 'terminalLayoutsByTabId' +> + +type TerminalPaneTabDetachManager = { + getPanes: () => readonly { id: number }[] + getLeafId: (paneId: number) => string | null + detachPaneForExternalMove: (paneId: number) => boolean +} + +export type TerminalTabStripDropTarget = PaneExternalDropTarget & { + groupId: string + insertionIndex?: number + worktreeId: string +} + +export type DetachedTerminalPaneTab = { + tab: TerminalTab + leafId: string + ptyId: string | null +} + +function pointWithinRect(clientX: number, clientY: number, rect: DOMRect): boolean { + return ( + clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom + ) +} + +function rectFromBox(args: { left: number; top: number; width: number; height: number }): DOMRect { + return { + left: args.left, + top: args.top, + right: args.left + args.width, + bottom: args.top + args.height, + width: args.width, + height: args.height + } as DOMRect +} + +function clampIndex(index: number, max: number): number { + return Math.min(Math.max(index, 0), max) +} + +function getTabElements(strip: HTMLElement): HTMLElement[] { + return Array.from(strip.querySelectorAll('[data-tab-id]')).filter( + (element) => typeof element.dataset.tabId === 'string' && element.dataset.tabId.length > 0 + ) +} + +function getInsertionMarkerRect( + tabRects: DOMRect[], + insertionIndex: number, + stripRect: DOMRect +): DOMRect { + const markerWidth = 2 + const clampedIndex = clampIndex(insertionIndex, tabRects.length) + const rawLeft = + clampedIndex < tabRects.length + ? (tabRects[clampedIndex]?.left ?? stripRect.left) + : (tabRects.at(-1)?.right ?? stripRect.left) - markerWidth + const left = Math.min(Math.max(rawLeft, stripRect.left), stripRect.right - markerWidth) + return rectFromBox({ left, top: stripRect.top, width: markerWidth, height: stripRect.height }) +} + +function resolveTabStripInsertion(args: { + clientX: number + clientY: number + groupTabOrderLength: number + strip: HTMLElement + stripRect: DOMRect +}): { index: number; rect: DOMRect } | null { + const tabs = getTabElements(args.strip) + if (tabs.length === 0) { + return null + } + const tabRects = tabs.map((tab) => tab.getBoundingClientRect()) + + for (let index = 0; index < tabRects.length; index += 1) { + const tabRect = tabRects[index] + if (!tabRect) { + continue + } + if (args.clientX < tabRect.left) { + const insertionIndex = clampIndex(index, args.groupTabOrderLength) + return { + index: insertionIndex, + rect: getInsertionMarkerRect(tabRects, insertionIndex, args.stripRect) + } + } + if (pointWithinRect(args.clientX, args.clientY, tabRect)) { + const insertionIndex = clampIndex( + index + (args.clientX < tabRect.left + tabRect.width / 2 ? 0 : 1), + args.groupTabOrderLength + ) + return { + index: insertionIndex, + rect: getInsertionMarkerRect(tabRects, insertionIndex, args.stripRect) + } + } + } + + const insertionIndex = args.groupTabOrderLength + return { + index: insertionIndex, + rect: getInsertionMarkerRect(tabRects, insertionIndex, args.stripRect) + } +} + +function getElementsFromPoint(clientX: number, clientY: number): Element[] { + if (typeof document === 'undefined') { + return [] + } + const elements = document.elementsFromPoint?.(clientX, clientY) + if (elements && elements.length > 0) { + return elements + } + const element = document.elementFromPoint?.(clientX, clientY) + return element ? [element] : [] +} + +export function resolveTerminalTabStripDropTarget(args: { + clientX: number + clientY: number + groupsByWorktree: TerminalPaneTabDetachStore['groupsByWorktree'] + worktreeId: string +}): TerminalTabStripDropTarget | null { + const groups = args.groupsByWorktree[args.worktreeId] ?? [] + const groupById = new Map(groups.map((group) => [group.id, group])) + const validGroupIds = new Set(groups.map((group) => group.id)) + if (validGroupIds.size === 0) { + return null + } + + for (const element of getElementsFromPoint(args.clientX, args.clientY)) { + const strip = element.closest(TAB_GROUP_STRIP_SELECTOR) + const groupId = strip?.dataset.tabGroupStripId + const worktreeId = strip?.dataset.worktreeId + if (!strip || !groupId || worktreeId !== args.worktreeId || !validGroupIds.has(groupId)) { + continue + } + const rect = strip.getBoundingClientRect() + if (!pointWithinRect(args.clientX, args.clientY, rect)) { + continue + } + const group = groupById.get(groupId) + const insertion = group + ? resolveTabStripInsertion({ + clientX: args.clientX, + clientY: args.clientY, + groupTabOrderLength: group.tabOrder?.length ?? 0, + strip, + stripRect: rect + }) + : null + return insertion + ? { + id: groupId, + groupId, + insertionIndex: insertion.index, + overlayKind: 'insertion', + rect: insertion.rect, + worktreeId + } + : { id: groupId, groupId, worktreeId, rect } + } + + return null +} + +function withDetachedPtyFallback(args: { + leafId: string + ptyId: string | null + detachedLayout: NonNullable>['detachedLayout'] +}): NonNullable>['detachedLayout'] { + if (!args.ptyId || args.detachedLayout.ptyIdsByLeafId?.[args.leafId]) { + return args.detachedLayout + } + return { + ...args.detachedLayout, + ptyIdsByLeafId: { + ...args.detachedLayout.ptyIdsByLeafId, + [args.leafId]: args.ptyId + } + } +} + +export function isTerminalTabStripDropTarget( + target: PaneExternalDropTarget +): target is TerminalTabStripDropTarget { + const candidate = target as Partial + return typeof candidate.groupId === 'string' && typeof candidate.worktreeId === 'string' +} + +function moveCreatedTabToIndex(args: { + groupId: string + store: TerminalPaneTabDetachStore + tabId: string + targetIndex: number | undefined + worktreeId: string +}): void { + if (args.targetIndex === undefined) { + return + } + const group = args.store.groupsByWorktree[args.worktreeId]?.find( + (candidate) => candidate.id === args.groupId + ) + if (!group) { + return + } + const orderWithoutCreatedTab = (group.tabOrder ?? []).filter((id) => id !== args.tabId) + const insertionIndex = clampIndex(args.targetIndex, orderWithoutCreatedTab.length) + const nextOrder = [...orderWithoutCreatedTab] + nextOrder.splice(insertionIndex, 0, args.tabId) + args.store.reorderUnifiedTabs(args.groupId, nextOrder, { recordInteraction: false }) +} + +export function detachTerminalPaneToTab(args: { + fallbackPtyId?: string | null + getStore: () => TerminalPaneTabDetachStore + manager: TerminalPaneTabDetachManager | null + persistLayoutSnapshot: () => void + sourcePaneId: number + sourceTabId: string + targetGroupId: string + targetIndex?: number + worktreeId: string +}): DetachedTerminalPaneTab | null { + const initialStore = args.getStore() + const targetGroupExists = + initialStore.groupsByWorktree[args.worktreeId]?.some( + (group) => group.id === args.targetGroupId + ) ?? false + if (!args.manager || !targetGroupExists || args.manager.getPanes().length <= 1) { + return null + } + + const sourceLeafId = args.manager.getLeafId(args.sourcePaneId) + if (!sourceLeafId) { + return null + } + + args.persistLayoutSnapshot() + const store = args.getStore() + const detached = detachTerminalLayoutLeaf( + store.terminalLayoutsByTabId[args.sourceTabId], + sourceLeafId + ) + if (!detached) { + return null + } + + const ptyId = detached.ptyId ?? args.fallbackPtyId ?? null + const detachedLayout = withDetachedPtyFallback({ + leafId: sourceLeafId, + ptyId, + detachedLayout: detached.detachedLayout + }) + + // Why: remove the renderer pane only after the layout/PTY handoff has been + // computed; the close callback detaches listeners but must not kill the PTY. + if (!args.manager.detachPaneForExternalMove(args.sourcePaneId)) { + return null + } + + const latestStore = args.getStore() + const tab = latestStore.createTab(args.worktreeId, args.targetGroupId, undefined, { + activate: true, + initialPtyId: ptyId ?? undefined, + recordInteraction: true + }) + const afterCreateStore = args.getStore() + moveCreatedTabToIndex({ + groupId: args.targetGroupId, + store: afterCreateStore, + tabId: tab.id, + targetIndex: args.targetIndex, + worktreeId: args.worktreeId + }) + afterCreateStore.setTabLayout(args.sourceTabId, detached.sourceLayout) + afterCreateStore.setTabLayout(tab.id, detachedLayout) + afterCreateStore.syncPaneDetachPtyOwnership({ + detachedPtyId: ptyId, + sourceLayout: detached.sourceLayout, + sourceTabId: args.sourceTabId, + targetTabId: tab.id + }) + afterCreateStore.setActiveTab(tab.id) + afterCreateStore.setActiveTabType('terminal') + + return { tab, leafId: sourceLeafId, ptyId } +} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 1674e150fcd..3597f9dd28e 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -2,7 +2,11 @@ import { useEffect, useRef } from 'react' import type { IDisposable, Terminal } from '@xterm/xterm' import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-types' -import { PaneManager } from '@/lib/pane-manager/pane-manager' +import { + PaneManager, + type PaneExternalDropHandler, + type PaneExternalDropResolver +} from '@/lib/pane-manager/pane-manager' import { consumePendingWebRuntimeSplitMirrorTelemetry } from '@/runtime/web-runtime-session' import { normalizeTerminalFastScrollSensitivity, @@ -273,6 +277,8 @@ type UseTerminalPaneLifecycleDeps = { // Why: same pane count does not imply same geometry; drag-reorder can move // panes without resizing them, so overlay rects need a layout-change tick. setPaneLayoutRevision: React.Dispatch> + resolveExternalPaneDropTarget?: PaneExternalDropResolver + onExternalPaneDrop?: PaneExternalDropHandler } export function suppressIntentionalPaneCloseExit( @@ -532,7 +538,9 @@ export function useTerminalPaneLifecycle({ paneTitlesRef, setRenamingPaneId, setPaneCount, - setPaneLayoutRevision + setPaneLayoutRevision, + resolveExternalPaneDropTarget, + onExternalPaneDrop }: UseTerminalPaneLifecycleDeps): void { const terminalScrollbackRows = normalizeDesktopTerminalScrollbackRows( settings?.terminalScrollbackRows @@ -1094,6 +1102,7 @@ export function useTerminalPaneLifecycle({ queueResizeAll(true) }, onPaneClosed: (paneId, closedPane) => { + const isDetachedToTab = closedPane?.reason === 'detach' const linkProviderDisposable = linkProviderDisposablesRef.current.get(paneId) if (linkProviderDisposable) { linkProviderDisposable.dispose() @@ -1169,31 +1178,37 @@ export function useTerminalPaneLifecycle({ panePtyBinding.dispose() panePtyBindings.delete(paneId) } - // Why: closing a pane is user-initiated teardown of this row — drop - // (not remove) so any retained `done` snapshot for this pane is also - // cleared and a same-frame live→gone transition cannot re-snapshot - // it via the retention sync. This is pane-keyed state, so it must - // clear even if the PTY transport was already removed. const leafId = closedPane?.leafId - if (leafId) { + if (leafId && !isDetachedToTab) { + // Why: closing a pane is user-initiated teardown of this row — drop + // (not remove) so any retained `done` snapshot for this pane is also + // cleared and a same-frame live→gone transition cannot re-snapshot + // it via the retention sync. This is pane-keyed state, so it must + // clear even if the PTY transport was already removed. const paneKey = makePaneKey(tabId, leafId) useAppStore.getState().setCacheTimerStartedAt(paneKey, null) clearTerminalPaneUnread(paneKey) useAppStore.getState().dropAgentStatus(paneKey) } if (transport) { - const ptyId = suppressIntentionalPaneCloseExit( - transport, - useAppStore.getState().suppressPtyExit - ) - if (ptyId) { - // Why: user/CLI pane closes intentionally tear down this PTY after - // PaneManager has already promoted the sibling. Suppress that exit - // so the last-surviving pane is not mistaken for an exited tab. - syncPanePtyLayoutBinding(paneId, null) - clearTabPtyId(tabId, ptyId) + if (isDetachedToTab) { + // Why: pane-to-tab detach hands the PTY to a newly-created tab; + // detach renderer listeners without sending a process teardown. + transport.detach?.() + } else { + const ptyId = suppressIntentionalPaneCloseExit( + transport, + useAppStore.getState().suppressPtyExit + ) + if (ptyId) { + // Why: user/CLI pane closes intentionally tear down this PTY after + // PaneManager has already promoted the sibling. Suppress that exit + // so the last-surviving pane is not mistaken for an exited tab. + syncPanePtyLayoutBinding(paneId, null) + clearTabPtyId(tabId, ptyId) + } + transport.destroy?.() } - transport.destroy?.() paneTransportsRef.current.delete(paneId) } clearRuntimePaneTitle(tabId, paneId) @@ -1292,6 +1307,8 @@ export function useTerminalPaneLifecycle({ releaseWebviewDragPassthrough?.() releaseWebviewDragPassthrough = null }, + resolveExternalPaneDropTarget, + onExternalPaneDrop, terminalOptions: () => { const currentSettings = settingsRef.current const terminalFontWeights = resolveTerminalFontWeights(currentSettings?.terminalFontWeight) diff --git a/src/renderer/src/lib/pane-manager/pane-drag-pointer.ts b/src/renderer/src/lib/pane-manager/pane-drag-pointer.ts index 45d71f151e0..3be4db71d72 100644 --- a/src/renderer/src/lib/pane-manager/pane-drag-pointer.ts +++ b/src/renderer/src/lib/pane-manager/pane-drag-pointer.ts @@ -1,4 +1,4 @@ -import type { DropZone, ManagedPaneInternal } from './pane-manager-types' +import type { DropZone, ManagedPaneInternal, PaneExternalDropTarget } from './pane-manager-types' import type { DragReorderCallbacks, DragReorderState } from './pane-drag-reorder' import { handlePaneDrop, @@ -62,14 +62,18 @@ export function beginPaneDragFromPointerDown( callbacks.getRoot().classList.remove('is-pane-dragging') callbacks.getPanes().get(paneId)?.container.classList.remove('is-drag-source') try { - if (commitDrop && state.currentDropTarget && state.dragSourcePaneId !== null) { - handlePaneDrop( - state.dragSourcePaneId, - state.currentDropTarget.paneId, - state.currentDropTarget.zone, - state, - callbacks - ) + if (commitDrop && state.dragSourcePaneId !== null) { + if (state.currentDropTarget) { + handlePaneDrop( + state.dragSourcePaneId, + state.currentDropTarget.paneId, + state.currentDropTarget.zone, + state, + callbacks + ) + } else if (state.currentExternalDropTarget) { + callbacks.onExternalPaneDrop?.(state.dragSourcePaneId, state.currentExternalDropTarget) + } } } finally { // Why: pointer capture can be lost crossing Electron webviews; always @@ -78,6 +82,7 @@ export function beginPaneDragFromPointerDown( hideDropOverlay(state) state.dragSourcePaneId = null state.currentDropTarget = null + state.currentExternalDropTarget = null } } @@ -166,8 +171,20 @@ function updateDropTarget( } const targetPane = findDropTargetPane(clientX, clientY, state, callbacks) if (!targetPane) { - overlay.style.display = 'none' + const sourcePaneId = state.dragSourcePaneId + const externalTarget = + sourcePaneId === null + ? null + : (callbacks.resolveExternalDropTarget?.({ sourcePaneId, clientX, clientY }) ?? null) + if (!externalTarget) { + overlay.style.display = 'none' + state.currentDropTarget = null + state.currentExternalDropTarget = null + return + } state.currentDropTarget = null + state.currentExternalDropTarget = externalTarget + positionExternalDropOverlay(overlay, externalTarget) return } @@ -180,9 +197,11 @@ function updateDropTarget( ) { overlay.style.display = 'none' state.currentDropTarget = null + state.currentExternalDropTarget = null return } state.currentDropTarget = { paneId: targetPane.id, zone } + state.currentExternalDropTarget = null positionDropOverlay(overlay, rect, zone) } @@ -223,6 +242,7 @@ function resolveDropZone(clientX: number, clientY: number, rect: DOMRect): DropZ function positionDropOverlay(overlay: HTMLElement, rect: DOMRect, zone: DropZone): void { overlay.style.display = '' + overlay.dataset.paneDropOverlayKind = 'area' const scrollX = window.scrollX const scrollY = window.scrollY const halfWidth = rect.width / 2 @@ -233,3 +253,13 @@ function positionDropOverlay(overlay: HTMLElement, rect: DOMRect, zone: DropZone overlay.style.width = `${zone === 'left' || zone === 'right' ? halfWidth : rect.width}px` overlay.style.height = `${zone === 'top' || zone === 'bottom' ? halfHeight : rect.height}px` } + +function positionExternalDropOverlay(overlay: HTMLElement, target: PaneExternalDropTarget): void { + const rect = target.rect + overlay.style.display = '' + overlay.dataset.paneDropOverlayKind = target.overlayKind ?? 'area' + overlay.style.left = `${rect.left + window.scrollX}px` + overlay.style.top = `${rect.top + window.scrollY}px` + overlay.style.width = `${rect.width}px` + overlay.style.height = `${rect.height}px` +} diff --git a/src/renderer/src/lib/pane-manager/pane-drag-reorder.test.ts b/src/renderer/src/lib/pane-manager/pane-drag-reorder.test.ts index dd81b2063fe..1f5996526cd 100644 --- a/src/renderer/src/lib/pane-manager/pane-drag-reorder.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-drag-reorder.test.ts @@ -229,6 +229,7 @@ describe('attachPaneDrag', () => { expect(appendedElements[0].removed).toBe(true) expect(state.dragSourcePaneId).toBeNull() expect(state.currentDropTarget).toBeNull() + expect(state.currentExternalDropTarget).toBeNull() expect(state.cleanupActiveDrag).toBeNull() expect(onDragActiveChange).toHaveBeenLastCalledWith(false) expect(detachPaneFromTree).not.toHaveBeenCalled() @@ -288,10 +289,83 @@ describe('attachPaneDrag', () => { expect(appendedElements[0]?.removed).toBe(true) expect(state.dragSourcePaneId).toBeNull() expect(state.currentDropTarget).toBeNull() + expect(state.currentExternalDropTarget).toBeNull() expect(state.cleanupActiveDrag).toBeNull() expect(onDragActiveChange).toHaveBeenLastCalledWith(false) }) + it('drops onto an external target when no pane target is under the pointer', () => { + const handle = new FakeElement() + const root = new FakeElement(['pane-manager-root']) + const sourcePane = createPane( + 1, + new FakeElement(['pane'], { + left: 0, + top: 80, + right: 100, + bottom: 180, + width: 100, + height: 100 + }) + ) + const siblingPane = createPane( + 2, + new FakeElement(['pane'], { + left: 120, + top: 80, + right: 220, + bottom: 180, + width: 100, + height: 100 + }) + ) + const panes = new Map([ + [sourcePane.id, sourcePane], + [siblingPane.id, siblingPane] + ]) + const externalTarget = { + id: 'group-1', + overlayKind: 'insertion' as const, + rect: { left: 0, top: 0, right: 300, bottom: 32, width: 300, height: 32 } as DOMRect + } + const onExternalPaneDrop = vi.fn(() => true) + const state = createDragReorderState() + + attachPaneDrag(handle as unknown as HTMLElement, sourcePane.id, state, { + getPanes: () => panes, + getRoot: () => root as unknown as HTMLElement, + getStyleOptions: () => ({}), + isDestroyed: () => false, + safeFit: vi.fn(), + applyPaneOpacity: vi.fn(), + applyDividerStyles: vi.fn(), + refitPanesUnder: vi.fn(), + resolveExternalDropTarget: ({ clientX, clientY }) => + clientX === 10 && clientY === 10 ? externalTarget : null, + onExternalPaneDrop + }) + + handle.dispatchPointer('pointerdown', pointerEvent({ clientX: 10, clientY: 90 })) + handle.dispatchPointer('pointermove', pointerEvent({ clientX: 10, clientY: 10 })) + + expect(state.currentDropTarget).toBeNull() + expect(state.currentExternalDropTarget).toBe(externalTarget) + expect(appendedElements[0].style).toMatchObject({ + left: '0px', + top: '0px', + width: '300px', + height: '32px' + }) + expect(appendedElements[0].dataset.paneDropOverlayKind).toBe('insertion') + + handle.dispatchPointer('pointerup', pointerEvent({ pointerId: 1 })) + + expect(onExternalPaneDrop).toHaveBeenCalledWith(sourcePane.id, externalTarget) + expect(detachPaneFromTree).not.toHaveBeenCalled() + expect(insertPaneNextTo).not.toHaveBeenCalled() + expect(state.currentExternalDropTarget).toBeNull() + }) + it('returns cleanup that removes handle listeners and cancels active drag capture', () => { const handle = new FakeElement() const root = new FakeElement(['pane-manager-root']) diff --git a/src/renderer/src/lib/pane-manager/pane-drag-reorder.ts b/src/renderer/src/lib/pane-manager/pane-drag-reorder.ts index 6b08dc4b48e..7c1803227c1 100644 --- a/src/renderer/src/lib/pane-manager/pane-drag-reorder.ts +++ b/src/renderer/src/lib/pane-manager/pane-drag-reorder.ts @@ -1,5 +1,12 @@ -import type { DropZone, ManagedPane, ManagedPaneInternal } from './pane-manager-types' -import type { PaneStyleOptions } from './pane-manager-types' +import type { + DropZone, + ManagedPane, + ManagedPaneInternal, + PaneExternalDropHandler, + PaneExternalDropResolver, + PaneExternalDropTarget, + PaneStyleOptions +} from './pane-manager-types' import { detachPaneFromTree, findPaneChildren, insertPaneNextTo } from './pane-tree-ops' // --------------------------------------------------------------------------- @@ -10,6 +17,7 @@ export type DragReorderState = { dragSourcePaneId: number | null dropOverlay: HTMLElement | null currentDropTarget: { paneId: number; zone: DropZone } | null + currentExternalDropTarget: PaneExternalDropTarget | null cleanupActiveDrag: ((commitDrop: boolean) => void) | null } @@ -25,6 +33,8 @@ export type DragReorderCallbacks = { requestPaneReparentFrame?: (callback: FrameRequestCallback) => void onLayoutChanged?: () => void onDragActiveChange?: (active: boolean) => void + resolveExternalDropTarget?: PaneExternalDropResolver + onExternalPaneDrop?: PaneExternalDropHandler } export function createDragReorderState(): DragReorderState { @@ -32,6 +42,7 @@ export function createDragReorderState(): DragReorderState { dragSourcePaneId: null, dropOverlay: null, currentDropTarget: null, + currentExternalDropTarget: null, cleanupActiveDrag: null } } @@ -44,6 +55,7 @@ export function cancelActivePaneDrag(state: DragReorderState): void { hideDropOverlay(state) state.dragSourcePaneId = null state.currentDropTarget = null + state.currentExternalDropTarget = null } /** True when dropping source onto target in zone would leave pane order unchanged. */ diff --git a/src/renderer/src/lib/pane-manager/pane-manager-types.ts b/src/renderer/src/lib/pane-manager/pane-manager-types.ts index e4e002a5351..1ab542bf787 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-types.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-types.ts @@ -27,8 +27,26 @@ export type PaneSpawnHints = { export type ClosedPaneInfo = { paneId: number leafId: TerminalLeafId + reason?: 'close' | 'detach' } +export type PaneExternalDropTarget = { + id: string + rect: DOMRect + overlayKind?: 'area' | 'insertion' +} + +export type PaneExternalDropResolver = (args: { + sourcePaneId: number + clientX: number + clientY: number +}) => PaneExternalDropTarget | null + +export type PaneExternalDropHandler = ( + sourcePaneId: number, + target: PaneExternalDropTarget +) => boolean + export type PaneManagerOptions = { onPaneCreated?: (pane: ManagedPane, spawnHints?: PaneSpawnHints) => void | Promise onPaneClosed?: (paneId: number, closedPane?: ClosedPaneInfo) => void @@ -37,6 +55,8 @@ export type PaneManagerOptions = { /** Why: Electron webviews can steal pointer streams from renderer-owned * pane drags unless callers temporarily put them in pointer passthrough. */ onPaneDragActiveChange?: (active: boolean) => void + resolveExternalPaneDropTarget?: PaneExternalDropResolver + onExternalPaneDrop?: PaneExternalDropHandler terminalOptions?: (paneId: number) => Partial terminalTuiScrollSensitivity?: () => number | undefined onLinkClick?: (event: MouseEvent | undefined, url: string) => void diff --git a/src/renderer/src/lib/pane-manager/pane-manager.ts b/src/renderer/src/lib/pane-manager/pane-manager.ts index 61ff5a7d42d..77ad5398890 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager.ts @@ -5,7 +5,10 @@ import type { ManagedPane, ManagedPaneInternal, PaneRenderingDiagnostics, - DropZone + DropZone, + PaneExternalDropHandler, + PaneExternalDropResolver, + PaneExternalDropTarget } from './pane-manager-types' import type { SplitPaneAroundLeafIdsOptions } from './pane-subtree-split' import { @@ -40,11 +43,23 @@ import type { TerminalLeafId } from '../../../../shared/stable-pane-id' import { registerLivePaneManager, unregisterLivePaneManager } from './pane-manager-registry' import { schedulePaneRevealRepaint } from './pane-reveal-repaint' import { PaneIdentityRegistry } from './pane-identity-registry' -import { closeManagedPane, splitManagedPane } from './pane-split-close' +import { + closeManagedPane, + detachManagedPaneForExternalMove, + splitManagedPane +} from './pane-split-close' import { FIRST_PANE_ID } from '../../../../shared/pane-key' import { splitPaneAroundMountedSubtree } from './pane-subtree-split' -export type { PaneManagerOptions, PaneStyleOptions, ManagedPane, DropZone } +export type { + PaneManagerOptions, + PaneStyleOptions, + ManagedPane, + DropZone, + PaneExternalDropTarget, + PaneExternalDropResolver, + PaneExternalDropHandler +} export class PaneManager { private root: HTMLElement @@ -158,6 +173,22 @@ export class PaneManager { }) } + detachPaneForExternalMove(paneId: number): boolean { + return detachManagedPaneForExternalMove({ + paneId, + activePaneId: this.activePaneId, + panes: this.panes, + root: this.root, + styleOptions: this.styleOptions, + managerOptions: this.options, + getDragCallbacks: () => this.getDragCallbacks(), + releasePaneIdentity: (numericPaneId) => this.identities.release(numericPaneId), + setActivePaneId: (id) => { + this.activePaneId = id + } + }) + } + getPanes(): ManagedPane[] { return Array.from(this.panes.values()).map(toPublicPane) } @@ -404,7 +435,9 @@ export class PaneManager { this.requestPaneReparentFrame(callback) }, onLayoutChanged: this.options.onLayoutChanged, - onDragActiveChange: this.options.onPaneDragActiveChange + onDragActiveChange: this.options.onPaneDragActiveChange, + resolveExternalDropTarget: this.options.resolveExternalPaneDropTarget, + onExternalPaneDrop: this.options.onExternalPaneDrop } } diff --git a/src/renderer/src/lib/pane-manager/pane-split-close.ts b/src/renderer/src/lib/pane-manager/pane-split-close.ts index 2d7c50a62f0..b7d8356e0a5 100644 --- a/src/renderer/src/lib/pane-manager/pane-split-close.ts +++ b/src/renderer/src/lib/pane-manager/pane-split-close.ts @@ -180,10 +180,39 @@ export function closeManagedPane(args: CloseManagedPaneArgs): void { safeFit(p) } updateMultiPaneState(args.getDragCallbacks()) - args.managerOptions.onPaneClosed?.(args.paneId, { paneId: args.paneId, leafId: closedLeafId }) + args.managerOptions.onPaneClosed?.(args.paneId, { + paneId: args.paneId, + leafId: closedLeafId, + reason: 'close' + }) args.managerOptions.onLayoutChanged?.() } +export function detachManagedPaneForExternalMove(args: CloseManagedPaneArgs): boolean { + const pane = args.panes.get(args.paneId) + if (!pane || args.panes.size <= 1) { + return false + } + const closedLeafId = pane.leafId + args.releasePaneIdentity(args.paneId) + removePaneContainer(args, pane) + const nextActivePaneId = activateReplacementPane(args) + applyPaneOpacity(args.panes.values(), nextActivePaneId, args.styleOptions) + for (const p of args.panes.values()) { + safeFit(p) + } + updateMultiPaneState(args.getDragCallbacks()) + // Why: pane-to-tab detach tears down only this renderer pane; the PTY is + // adopted by the new tab, so TerminalPane must skip process-close cleanup. + args.managerOptions.onPaneClosed?.(args.paneId, { + paneId: args.paneId, + leafId: closedLeafId, + reason: 'detach' + }) + args.managerOptions.onLayoutChanged?.() + return true +} + function removePaneContainer(args: CloseManagedPaneArgs, pane: ManagedPaneInternal): void { const paneContainer = pane.container const parent = paneContainer.parentElement diff --git a/src/renderer/src/store/slices/store-cascades.test.ts b/src/renderer/src/store/slices/store-cascades.test.ts index 9e2f89bc160..fbecc991416 100644 --- a/src/renderer/src/store/slices/store-cascades.test.ts +++ b/src/renderer/src/store/slices/store-cascades.test.ts @@ -1117,6 +1117,57 @@ describe('setActiveWorktree', () => { expect(groups[0].tabOrder).toEqual([terminal.id]) }) + it('moves live PTY ownership when detaching a primary pane to a tab', () => { + const store = createTestStore() + const wt = 'repo1::/path/wt1' + const sourceTabId = 'tab-source' + const targetTabId = 'tab-target' + + seedStore(store, { + worktreesByRepo: { + repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })] + }, + tabsByWorktree: { + [wt]: [ + makeTab({ id: sourceTabId, worktreeId: wt, ptyId: 'pty-detached' }), + makeTab({ id: targetTabId, worktreeId: wt, ptyId: null }) + ] + }, + ptyIdsByTabId: { + [sourceTabId]: ['pty-detached', 'pty-survivor'], + [targetTabId]: ['pty-detached'] + }, + lastKnownRelayPtyIdByTabId: { + [sourceTabId]: 'pty-detached', + [targetTabId]: 'pty-detached' + } + }) + + store.getState().syncPaneDetachPtyOwnership({ + detachedPtyId: 'pty-detached', + sourceLayout: { + root: { type: 'leaf', leafId: 'survivor-leaf' }, + activeLeafId: 'survivor-leaf', + expandedLeafId: null, + ptyIdsByLeafId: { 'survivor-leaf': 'pty-survivor' } + }, + sourceTabId, + targetTabId + }) + + const state = store.getState() + expect(state.ptyIdsByTabId[sourceTabId]).toEqual(['pty-survivor']) + expect(state.ptyIdsByTabId[targetTabId]).toEqual(['pty-detached']) + expect(state.lastKnownRelayPtyIdByTabId[sourceTabId]).toBe('pty-survivor') + expect(state.lastKnownRelayPtyIdByTabId[targetTabId]).toBe('pty-detached') + expect(state.tabsByWorktree[wt].find((tab) => tab.id === sourceTabId)?.ptyId).toBe( + 'pty-survivor' + ) + expect(state.tabsByWorktree[wt].find((tab) => tab.id === targetTabId)?.ptyId).toBe( + 'pty-detached' + ) + }) + it('stores trimmed quick command labels on terminal and unified tabs', () => { const store = createTestStore() const wt = 'repo1::/path/wt1' diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 0e6f30ddefc..bbd43ff773b 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -293,6 +293,36 @@ function equalStringSets(a: readonly string[], b: readonly string[]): boolean { return a.every((value) => bSet.has(value)) } +function uniquePtyIds(ptyIds: readonly (string | null | undefined)[]): string[] { + return [...new Set(ptyIds.filter((ptyId): ptyId is string => Boolean(ptyId)))] +} + +function resolvePrimaryLayoutPtyId(layout: TerminalLayoutSnapshot): string | null { + const ptyIdsByLeafId = layout.ptyIdsByLeafId ?? {} + const activePtyId = layout.activeLeafId ? ptyIdsByLeafId[layout.activeLeafId] : undefined + return activePtyId ?? Object.values(ptyIdsByLeafId)[0] ?? null +} + +function withTerminalTabPtyId( + tabsByWorktree: Record, + tabId: string, + ptyId: string | null +): Record { + for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { + const index = tabs.findIndex((tab) => tab.id === tabId) + if (index === -1) { + continue + } + if (tabs[index]?.ptyId === ptyId) { + return tabsByWorktree + } + const nextTabs = [...tabs] + nextTabs[index] = { ...nextTabs[index]!, ptyId } + return { ...tabsByWorktree, [worktreeId]: nextTabs } + } + return tabsByWorktree +} + export type AutomaticAgentResumeClaim = { worktreeId: string launchAgent: TuiAgent @@ -506,6 +536,12 @@ export type TerminalSlice = { setTabPaneExpanded: (tabId: string, expanded: boolean) => void setTabCanExpandPane: (tabId: string, canExpand: boolean) => void setTabLayout: (tabId: string, layout: TerminalLayoutSnapshot | null) => void + syncPaneDetachPtyOwnership: (args: { + detachedPtyId: string | null + sourceLayout: TerminalLayoutSnapshot + sourceTabId: string + targetTabId: string + }) => void queueTabStartupCommand: ( tabId: string, startup: { @@ -2455,6 +2491,54 @@ export const createTerminalSlice: StateCreator }) }, + syncPaneDetachPtyOwnership: ({ detachedPtyId, sourceLayout, sourceTabId, targetTabId }) => { + set((s) => { + const layoutSourcePtyIds = uniquePtyIds(Object.values(sourceLayout.ptyIdsByLeafId ?? {})) + const existingSourcePtyIds = (s.ptyIdsByTabId[sourceTabId] ?? []).filter( + (ptyId) => ptyId !== detachedPtyId + ) + const sourcePtyIds = layoutSourcePtyIds.length > 0 ? layoutSourcePtyIds : existingSourcePtyIds + const sourcePrimaryPtyId = resolvePrimaryLayoutPtyId(sourceLayout) ?? sourcePtyIds[0] ?? null + const nextPtyIdsByTabId = { + ...s.ptyIdsByTabId, + [sourceTabId]: sourcePtyIds + } + if (detachedPtyId) { + nextPtyIdsByTabId[targetTabId] = uniquePtyIds([ + ...(nextPtyIdsByTabId[targetTabId] ?? []), + detachedPtyId + ]) + } + + const nextLastKnownRelayPtyIdByTabId = { ...s.lastKnownRelayPtyIdByTabId } + if (sourcePrimaryPtyId) { + nextLastKnownRelayPtyIdByTabId[sourceTabId] = sourcePrimaryPtyId + } else { + delete nextLastKnownRelayPtyIdByTabId[sourceTabId] + } + if (detachedPtyId) { + nextLastKnownRelayPtyIdByTabId[targetTabId] = detachedPtyId + } + + // Why: pane-to-tab detach moves live PTY ownership without spawning or + // exiting processes, so sync identity maps directly without activity bumps. + const sourceTabsByWorktree = withTerminalTabPtyId( + s.tabsByWorktree, + sourceTabId, + sourcePrimaryPtyId + ) + const nextTabsByWorktree = detachedPtyId + ? withTerminalTabPtyId(sourceTabsByWorktree, targetTabId, detachedPtyId) + : sourceTabsByWorktree + + return { + ptyIdsByTabId: nextPtyIdsByTabId, + lastKnownRelayPtyIdByTabId: nextLastKnownRelayPtyIdByTabId, + ...(nextTabsByWorktree !== s.tabsByWorktree ? { tabsByWorktree: nextTabsByWorktree } : {}) + } + }) + }, + queueTabStartupCommand: (tabId, startup) => { // Why: launchToken is only meaningful for tracked launch-config reuse; // plain startup commands must not mint or carry a synthetic token. From ce680071a24a213608becb05736c1323853c3a90 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 3 Jul 2026 02:15:24 -0700 Subject: [PATCH 27/48] Place terminal URL tooltip at pane corner (#7221) --- .../pane-manager/pane-dom-creation.test.ts | 16 ++++++ .../src/lib/pane-manager/pane-dom-creation.ts | 4 +- .../lib/pane-manager/pane-lifecycle.test.ts | 55 ++++++++++++++----- .../src/lib/pane-manager/pane-lifecycle.ts | 6 +- 4 files changed, 63 insertions(+), 18 deletions(-) diff --git a/src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts b/src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts index 7f256d4304a..3165e86c04e 100644 --- a/src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts @@ -57,6 +57,22 @@ afterEach(() => { }) describe('createPaneDOM link tooltips', () => { + it('anchors WebLinks hover text to the unpadded terminal window corner', () => { + const leafId = '11111111-1111-4111-8111-111111111111' as TerminalLeafId + const pane = createPaneDOM( + 1, + leafId, + {}, + { active: null } as never, + {} as never, + vi.fn(), + vi.fn() + ) + + expect(pane.linkTooltip.style.left).toBe('0px') + expect(pane.linkTooltip.style.bottom).toBe('0px') + }) + it('uses desktop modifier-click text for WebLinks hover hints', () => { const leafId = '11111111-1111-4111-8111-111111111111' as TerminalLeafId diff --git a/src/renderer/src/lib/pane-manager/pane-dom-creation.ts b/src/renderer/src/lib/pane-manager/pane-dom-creation.ts index 2e556f44400..43b628ffbd5 100644 --- a/src/renderer/src/lib/pane-manager/pane-dom-creation.ts +++ b/src/renderer/src/lib/pane-manager/pane-dom-creation.ts @@ -66,8 +66,10 @@ export function createPaneDOM( const linkTooltip = document.createElement('div') linkTooltip.className = 'pane-link-tooltip' linkTooltip.classList.add('xterm-hover') + // Why: Ghostty-style URL hover belongs to the terminal window corner; do not + // let terminal content padding shift it inward. linkTooltip.style.cssText = - 'display:none;position:absolute;bottom:4px;left:8px;z-index:40;' + + 'display:none;position:absolute;bottom:0;left:0;z-index:40;' + 'padding:5px 8px;border-radius:4px;font-size:11px;font-family:inherit;' + 'color:#a1a1aa;background:rgba(24,24,27,0.85);border:1px solid rgba(63,63,70,0.6);' + 'pointer-events:none;max-width:80%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;' diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts index cb41bca09b8..16791a485b4 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts @@ -444,14 +444,32 @@ describe('openTerminal — Unicode 11 ordering', () => { } } - const fakeContainer = { + const fakePaneContainer = { appendChild: vi.fn(), addEventListener: vi.fn() } as unknown as HTMLDivElement + const fakeXtermContainer = { + appendChild: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn() + } as unknown as HTMLDivElement const fakeTooltip = {} as unknown as HTMLDivElement + const fakeTerminalElement = { + appendChild: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + querySelector: vi.fn(() => null), + classList: { contains: vi.fn(() => false) } + } as unknown as HTMLElement + vi.stubGlobal( + 'MutationObserver', + vi.fn(function MutationObserver() { + return { observe: vi.fn(), disconnect: vi.fn() } + }) + ) const terminal = { - element: null as HTMLElement | null, + element: fakeTerminalElement, textarea: null, cols: 80, rows: 24, @@ -485,8 +503,8 @@ describe('openTerminal — Unicode 11 ordering', () => { leafId, stablePaneId: leafId, terminal, - container: fakeContainer, - xtermContainer: fakeContainer, + container: fakePaneContainer, + xtermContainer: fakeXtermContainer, linkTooltip: fakeTooltip, terminalGpuAcceleration: 'off', gpuRenderingEnabled: false, @@ -507,19 +525,26 @@ describe('openTerminal — Unicode 11 ordering', () => { debugLabel: null } - openTerminal(pane) + try { + openTerminal(pane) - expect(events).toContain('loadAddon:unicode11') - expect(events).toContain('activeVersion=11') + expect(fakePaneContainer.appendChild).toHaveBeenCalledWith(fakeTooltip) + expect(fakeXtermContainer.appendChild).not.toHaveBeenCalled() + expect(fakeTerminalElement.appendChild).not.toHaveBeenCalled() + expect(events).toContain('loadAddon:unicode11') + expect(events).toContain('activeVersion=11') - const unicodeIdx = events.indexOf('activeVersion=11') - const writeIdx = events.indexOf('write') - if (writeIdx !== -1) { - expect(unicodeIdx).toBeLessThan(writeIdx) + const unicodeIdx = events.indexOf('activeVersion=11') + const writeIdx = events.indexOf('write') + if (writeIdx !== -1) { + expect(unicodeIdx).toBeLessThan(writeIdx) + } + + const loadUnicodeIdx = events.indexOf('loadAddon:unicode11') + expect(loadUnicodeIdx).toBeLessThan(unicodeIdx) + expect(events.indexOf('open')).toBeLessThan(loadUnicodeIdx) + } finally { + vi.unstubAllGlobals() } - - const loadUnicodeIdx = events.indexOf('loadAddon:unicode11') - expect(loadUnicodeIdx).toBeLessThan(unicodeIdx) - expect(events.indexOf('open')).toBeLessThan(loadUnicodeIdx) }) }) diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts index 490b09bb065..c6727c3c1c1 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts @@ -29,6 +29,7 @@ export { createPaneDOM } from './pane-dom-creation' export function openTerminal(pane: ManagedPaneInternal): void { const { terminal, + container, xtermContainer, linkTooltip, terminalTuiScrollSensitivity, @@ -41,8 +42,9 @@ export function openTerminal(pane: ManagedPaneInternal): void { // Open terminal into DOM terminal.open(xtermContainer) - const linkTooltipContainer = terminal.element ?? xtermContainer - linkTooltipContainer.appendChild(linkTooltip) + // Why: terminal.element sits under the padded xterm container. Pane-level + // placement keeps the hover URL on the true bottom-left window corner. + container.appendChild(linkTooltip) // Load addons (order matters: WebGL must be after open()) terminal.loadAddon(fitAddon) From 653f349b5f7bcbf049687e00794c612b8bf3f364 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 3 Jul 2026 02:55:27 -0700 Subject: [PATCH 28/48] Fix terminal theme selection persistence (#7227) Fixes #7166. --- src/main/persistence.test.ts | 18 ++++ .../TerminalThemeSections.lifecycle.test.ts | 93 +++++++++++++++++-- .../settings/TerminalThemeSections.tsx | 59 +++++++++++- 3 files changed, 157 insertions(+), 13 deletions(-) diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 419016f6f83..2e609b20558 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -2828,6 +2828,24 @@ describe('Store', () => { expect(store.getSettings().terminalUseSeparateLightTheme).toBe(false) }) + it('round-trips selected terminal theme names across reload', async () => { + const store = await createStore() + + store.updateSettings({ + terminalThemeDark: 'One Light', + terminalThemeLight: 'GitHub Light' + }) + store.flush() + + const persisted = readDataFile() as PersistedState + expect(persisted.settings.terminalThemeDark).toBe('One Light') + expect(persisted.settings.terminalThemeLight).toBe('GitHub Light') + + const reopened = await createStore() + expect(reopened.getSettings().terminalThemeDark).toBe('One Light') + expect(reopened.getSettings().terminalThemeLight).toBe('GitHub Light') + }) + // ── 5. addRepo and getRepo ────────────────────────────────────────── it('addRepo stores a repo retrievable by getRepo', async () => { diff --git a/src/renderer/src/components/settings/TerminalThemeSections.lifecycle.test.ts b/src/renderer/src/components/settings/TerminalThemeSections.lifecycle.test.ts index 5e8b643e111..0114ea3ebf4 100644 --- a/src/renderer/src/components/settings/TerminalThemeSections.lifecycle.test.ts +++ b/src/renderer/src/components/settings/TerminalThemeSections.lifecycle.test.ts @@ -8,12 +8,15 @@ vi.mock('react', async () => { const actual = await vi.importActual('react') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() return { ...actual, - useState: (initial: T) => [ - themeTarget ?? initial, - (value: T) => { - themeTarget = value as 'dark' | 'light' - } - ] + useState: (initial: T | (() => T)) => { + const initialValue = typeof initial === 'function' ? (initial as () => T)() : initial + return [ + themeTarget ?? initialValue, + (value: T) => { + themeTarget = value as 'dark' | 'light' + } + ] + } } }) @@ -23,7 +26,10 @@ vi.mock('./TerminalSettingsPreview', () => ({ } })) -import { TerminalThemeCatalogSection } from './TerminalThemeSections' +import { + resetTerminalThemeTargetMemoryForTests, + TerminalThemeCatalogSection +} from './TerminalThemeSections' type ReactElementLike = { type: unknown @@ -50,6 +56,7 @@ const warpThemesMock: UseWarpThemeImportReturn = { function makeSettings(overrides: Partial = {}): GlobalSettings { return { + theme: 'system', terminalUseSeparateLightTheme: false, terminalThemeDark: 'Ghostty Default Style Dark', terminalThemeLight: 'Builtin Tango Light', @@ -64,12 +71,13 @@ function renderCatalog( settings = makeSettings(), updateSettings = vi.fn(), target?: 'dark' | 'light', - preferredTarget?: 'dark' | 'light' + preferredTarget?: 'dark' | 'light', + systemPrefersDark = true ): React.JSX.Element { themeTarget = target return TerminalThemeCatalogSection({ settings, - systemPrefersDark: true, + systemPrefersDark, themeSearch: '', setThemeSearch: () => {}, updateSettings, @@ -167,6 +175,7 @@ function findButtonTexts(node: unknown): string[] { describe('TerminalThemeCatalogSection', () => { beforeEach(() => { themeTarget = 'dark' + resetTerminalThemeTargetMemoryForTests() vi.clearAllMocks() }) @@ -180,6 +189,72 @@ describe('TerminalThemeCatalogSection', () => { ) }) + it('opens on the active light theme when the system appearance is light', () => { + const updateSettings = vi.fn() + const element = renderCatalog( + makeSettings({ terminalUseSeparateLightTheme: true }), + updateSettings, + undefined, + undefined, + false + ) + const picker = findElementByTypeName(element, 'ThemePicker') + const preview = findElementByTypeName(element, 'TerminalSettingsPreview') + const selectTheme = picker?.props?.onSelectTheme as (theme: string) => void + + expect(picker?.props?.selectedTheme).toBe('Builtin Tango Light') + expect(preview?.props?.modeOverride).toBe('light') + + selectTheme('GitHub Light') + + expect(updateSettings).toHaveBeenCalledWith({ terminalThemeLight: 'GitHub Light' }) + }) + + it('reopens the manually edited light target while the active appearance is dark', () => { + const firstOpen = renderCatalog( + makeSettings({ terminalUseSeparateLightTheme: true }), + vi.fn(), + undefined, + undefined, + true + ) + const targetControl = findElementByTypeName(firstOpen, 'SettingsSegmentedControl') + const selectTarget = targetControl?.props?.onChange as (target: 'dark' | 'light') => void + + selectTarget('light') + + const reopened = renderCatalog( + makeSettings({ terminalUseSeparateLightTheme: true }), + vi.fn(), + undefined, + undefined, + true + ) + const picker = findElementByTypeName(reopened, 'ThemePicker') + const preview = findElementByTypeName(reopened, 'TerminalSettingsPreview') + + expect(picker?.props?.selectedTheme).toBe('Builtin Tango Light') + expect(preview?.props?.modeOverride).toBe('light') + }) + + it('opens a customized light-only theme after reload even when the active appearance is dark', () => { + const element = renderCatalog( + makeSettings({ + terminalUseSeparateLightTheme: true, + terminalThemeLight: 'GitHub Light' + }), + vi.fn(), + undefined, + undefined, + true + ) + const picker = findElementByTypeName(element, 'ThemePicker') + const preview = findElementByTypeName(element, 'TerminalSettingsPreview') + + expect(picker?.props?.selectedTheme).toBe('GitHub Light') + expect(preview?.props?.modeOverride).toBe('light') + }) + it('keeps the light target enabled while separate light theme is disabled', () => { const element = renderCatalog(makeSettings({ terminalUseSeparateLightTheme: false }), vi.fn()) const targetControl = findElementByTypeName(element, 'SettingsSegmentedControl') diff --git a/src/renderer/src/components/settings/TerminalThemeSections.tsx b/src/renderer/src/components/settings/TerminalThemeSections.tsx index 9479b5f1738..1fa765bb271 100644 --- a/src/renderer/src/components/settings/TerminalThemeSections.tsx +++ b/src/renderer/src/components/settings/TerminalThemeSections.tsx @@ -12,12 +12,56 @@ import { TerminalSettingsPreview } from './TerminalSettingsPreview' import { WarpThemeImportButton } from './WarpThemeImportButton' import { YamlThemeImportButton } from './YamlThemeImportButton' import type { UseWarpThemeImportReturn } from './useWarpThemeImport' -import { getAvailableTerminalThemeOptions } from '@/lib/terminal-theme' +import { + DEFAULT_TERMINAL_THEME_DARK, + DEFAULT_TERMINAL_THEME_LIGHT, + getAvailableTerminalThemeOptions, + resolveEffectiveTerminalAppearance +} from '@/lib/terminal-theme' import { translate } from '@/i18n/i18n' import { cn } from '@/lib/utils' type TerminalThemeTarget = 'dark' | 'light' +let lastEditedTerminalThemeTarget: TerminalThemeTarget | null = null + +function rememberTerminalThemeTarget(target: TerminalThemeTarget): void { + lastEditedTerminalThemeTarget = target +} + +export function resetTerminalThemeTargetMemoryForTests(): void { + lastEditedTerminalThemeTarget = null +} + +function isCustomizedTheme(themeName: string, defaultThemeName: string): boolean { + const trimmed = themeName.trim() + return trimmed.length > 0 && trimmed !== defaultThemeName +} + +function getInitialTerminalThemeTarget( + settings: GlobalSettings, + systemPrefersDark: boolean, + preferredTarget?: TerminalThemeTarget +): TerminalThemeTarget { + if (preferredTarget) { + return preferredTarget + } + if (lastEditedTerminalThemeTarget) { + return lastEditedTerminalThemeTarget + } + const customizedDark = isCustomizedTheme(settings.terminalThemeDark, DEFAULT_TERMINAL_THEME_DARK) + const customizedLight = isCustomizedTheme( + settings.terminalThemeLight, + DEFAULT_TERMINAL_THEME_LIGHT + ) + if (customizedDark !== customizedLight) { + return customizedDark ? 'dark' : 'light' + } + // Why: the picker edits mode-specific slots. Defaulting to the active + // terminal mode makes the selected theme apply immediately in system-light. + return resolveEffectiveTerminalAppearance(settings, systemPrefersDark).mode +} + type TerminalThemeCatalogSectionProps = { settings: GlobalSettings systemPrefersDark: boolean @@ -45,7 +89,13 @@ export function TerminalThemeCatalogSection({ preferredTarget, advancedContent }: TerminalThemeCatalogSectionProps): React.JSX.Element { - const [target, setTarget] = useState(preferredTarget ?? 'dark') + const [target, setTargetState] = useState(() => + getInitialTerminalThemeTarget(settings, systemPrefersDark, preferredTarget) + ) + const setTarget = (nextTarget: TerminalThemeTarget): void => { + rememberTerminalThemeTarget(nextTarget) + setTargetState(nextTarget) + } const themeOptions = getAvailableTerminalThemeOptions(settings) const isLightTarget = target === 'light' const matchDarkMode = !settings.terminalUseSeparateLightTheme @@ -200,11 +250,12 @@ export function TerminalThemeCatalogSection({ themeOptions={themeOptions} query={themeSearch} onQueryChange={setThemeSearch} - onSelectTheme={(theme) => + onSelectTheme={(theme) => { + rememberTerminalThemeTarget(target) updateSettings( isLightTarget ? { terminalThemeLight: theme } : { terminalThemeDark: theme } ) - } + }} importedHighlightSignal={importedHighlightSignal} /> From c8e571f40c03a9547721f455d4e70bf543a11f15 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 3 Jul 2026 03:07:17 -0700 Subject: [PATCH 29/48] fix(sidebar): prevent hovercard text selection drags (#7228) --- .../worktree-sidebar-pointer-drag-dom.test.ts | 43 +++++++++++++++++++ .../worktree-sidebar-pointer-drag-dom.ts | 10 ++++- 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.test.ts diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.test.ts b/src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.test.ts new file mode 100644 index 00000000000..1da451ff024 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.test.ts @@ -0,0 +1,43 @@ +// @vitest-environment happy-dom + +import { describe, expect, it } from 'vitest' +import { isSidebarPointerDragBlocked } from './worktree-sidebar-pointer-drag-dom' + +describe('worktree sidebar pointer drag DOM guards', () => { + it('allows plain row targets to start pointer drags', () => { + const row = document.createElement('div') + const card = document.createElement('div') + row.appendChild(card) + + expect(isSidebarPointerDragBlocked(card, row)).toBe(false) + }) + + it('blocks portaled hover card targets outside the row', () => { + const row = document.createElement('div') + const hoverCardContent = document.createElement('div') + document.body.append(row, hoverCardContent) + + expect(isSidebarPointerDragBlocked(hoverCardContent, row)).toBe(true) + + hoverCardContent.remove() + row.remove() + }) + + it('blocks interactive targets inside the row', () => { + const row = document.createElement('div') + const button = document.createElement('button') + row.appendChild(button) + + expect(isSidebarPointerDragBlocked(button, row)).toBe(true) + }) + + it('blocks icon targets inside interactive row controls', () => { + const row = document.createElement('div') + const button = document.createElement('button') + const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg') + button.appendChild(icon) + row.appendChild(button) + + expect(isSidebarPointerDragBlocked(icon, row)).toBe(true) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.ts b/src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.ts index 740a32abf92..15d102ac3b5 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.ts @@ -17,7 +17,15 @@ const INTERACTIVE_DRAG_BLOCKER_SELECTOR = [ ].join(',') export function isSidebarPointerDragBlocked(target: EventTarget | null, row: HTMLElement): boolean { - if (!(target instanceof HTMLElement)) { + if (!(target instanceof Node)) { + return false + } + // Why: Radix hover cards portal outside the row, but React still bubbles their + // pointer events through row handlers; text selection there must not drag rows. + if (!row.contains(target)) { + return true + } + if (!(target instanceof Element)) { return false } const blocker = target.closest(INTERACTIVE_DRAG_BLOCKER_SELECTOR) From a4f9dda71ada934809d4b97eb5f81a9ddfb3de45 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 10:57:58 +0000 Subject: [PATCH 30/48] release: v1.4.121-rc.1 [rc-slot:2026-07-03-03] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 60184c71afe..858ae4e230b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.120", + "version": "1.4.121-rc.1", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 14034678cc7973834000b79e68b9a99a32a8375d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 12:56:28 +0000 Subject: [PATCH 31/48] Update README downloads badge --- docs/assets/readme-downloads.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index f83dbfcb7ae..ff7c9fd7520 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 3.3m + + downloads: 3.4m @@ -15,7 +15,7 @@ downloads downloads - 3.3m - 3.3m + 3.4m + 3.4m From 96481b1eab3363a3df1175de7cbd6ab9324ed0f2 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 3 Jul 2026 11:03:28 -0700 Subject: [PATCH 32/48] Stop scheduled release cuts (#7258) --- .github/workflows/release-cut.yml | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index a7b8fe1814b..b792dd9cf47 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -1,6 +1,6 @@ name: Cut Release -# Why: single entry point for manually and scheduled cutting releases. +# Why: single entry point for manually cutting releases. # Replaces the old local `pnpm release:*` scripts and the standalone scheduled # RC workflow so releases are always reproducible from CI and can never be # accidentally tagged against an uncommitted or non-main working tree. @@ -37,16 +37,10 @@ on: type: string default: main dry_run: - description: Validate a scheduled-RC run without creating a tag + description: Validate an RC release cut without creating a tag required: false default: false type: boolean - schedule: - # Why: GitHub scheduled workflows are not guaranteed to start exactly on - # time and can be delayed or dropped around high-load periods. We retry - # across the full target hour and dedupe per PT slot. Cron is UTC, so run - # during both PST and PDT equivalents and let the PT-hour gate decide. - - cron: '*/5 10,11,22,23 * * *' permissions: contents: write @@ -182,8 +176,8 @@ jobs: - name: Publish complete release-cut RC drafts from prior runs id: publish_drafts - # Why: this must run even when the current slot already cut a tag; a - # later cron retry may be the first chance to unstick a complete RC draft. + # Why: a manual RC dispatch should unstick any complete RC draft before + # deciding whether to cut another tag. if: steps.window.outputs.allowed == 'true' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 1fd9547501e8fe5b05466cb0029d092278f77e34 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Fri, 3 Jul 2026 15:44:28 -0400 Subject: [PATCH 33/48] fix(agent-completion): stop PermissionRequest hooks from firing false completion notifications (#6316) * test(agent-completion): cover done after waiting pause Co-authored-by: Orca * test(agent-completion): assert attention fires alongside done-after-waiting Wire dispatchAttention into the done-after-waiting regression test so it verifies both halves of the #5698 fix: the mid-turn permission pause surfaces as attention exactly once, and the later genuine 'done' still completes exactly once. Matches sibling tests that assert on both paths. Co-authored-by: Orca --------- Co-authored-by: brennanb2025 Co-authored-by: Orca Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> --- .../agent-completion-coordinator.test.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts index ed370b84864..d69d1858c1f 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts @@ -1510,6 +1510,43 @@ describe('agent completion coordinator', () => { expect(dispatchAttention).not.toHaveBeenCalled() }) + it('still dispatches completion on done after an intervening waiting state in the same turn', () => { + const dispatchCompletion = vi.fn() + const dispatchAttention = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + dispatchAttention, + isLive: () => true + }) + + const turn = { + prompt: 'fix the bug', + agentType: 'cursor' as const + } + + // Realistic flow: the agent pauses for a permission prompt mid-turn, resumes, + // then genuinely finishes. The intervening attention state must surface as + // attention only and must not suppress the final completion. This fails if + // 'waiting' is treated as a completion state (issue #5698). + coordinator.observeHookStatus({ state: 'working', ...turn }) + coordinator.observeHookStatus({ + state: 'waiting', + ...turn, + toolName: 'Shell', + toolInput: 'pnpm test' + }) + coordinator.observeHookStatus({ state: 'working', ...turn }) + coordinator.observeHookStatus({ state: 'done', ...turn, lastAssistantMessage: 'Done.' }) + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) + + expect(dispatchAttention).toHaveBeenCalledTimes(1) + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + }) + it('keeps a generic title completion pending long enough for the first remote inspection', async () => { const inspection = createDeferred() const dispatchCompletion = vi.fn() From 37c8ff4935c9ce70ced5b994aa12a41ba3a1a4da Mon Sep 17 00:00:00 2001 From: PP <128917720+huhuhulululu@users.noreply.github.com> Date: Fri, 3 Jul 2026 15:44:31 -0400 Subject: [PATCH 34/48] fix: block resuming host-stored AI Vault sessions into SSH workspaces (#7130) * fix: block resuming host-stored AI Vault sessions into SSH workspaces AI Vault scans session history from this machine's disk only (host home dirs plus local WSL homes), but since #6685 resume actions accepted any SSH workspace as a target. Resuming a host-stored session there queues cd '' && --resume into a remote shell where neither the cwd nor the session file exists, so it always fails. Gate resume targets on where the session file lives: local targets stay unrestricted, SSH targets now require a WSL-stored session (the SSH-to-local-WSL setup #6685 was built for). Applies to the sidebar resume buttons, direct resume, and pane drag-and-drop, with a toast explaining the host mismatch. Catalog sync also picked up two pre-existing drifts (missing failedUnnestWorkspace fallback, two unreferenced SSH-timeout keys). * i18n: translate host-session SSH-resume block message (es/ja/ko/zh) Co-authored-by: Orca --------- Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> Co-authored-by: Orca --- .../components/right-sidebar/AiVaultPanel.tsx | 2 + .../right-sidebar/AiVaultSessionRow.tsx | 3 +- .../ai-vault-session-launch-actions.ts | 43 +++++++---- .../ai-vault-session-resume.test.ts | 74 +++++++++++++++++++ .../right-sidebar/ai-vault-session-resume.ts | 12 ++- .../tab-group/AiVaultSessionDropLayer.tsx | 19 ++++- src/renderer/src/i18n/locales/en.json | 6 +- src/renderer/src/i18n/locales/es.json | 6 +- src/renderer/src/i18n/locales/ja.json | 6 +- src/renderer/src/i18n/locales/ko.json | 6 +- src/renderer/src/i18n/locales/zh.json | 6 +- .../src/lib/ai-vault-resume-target.test.ts | 54 +++++++++++++- .../src/lib/ai-vault-resume-target.ts | 24 ++++++ .../src/lib/ai-vault-session-drag.test.ts | 19 +++++ src/renderer/src/lib/ai-vault-session-drag.ts | 7 +- 15 files changed, 256 insertions(+), 31 deletions(-) diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx index 7905e030639..f67c7d82a3a 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx @@ -210,6 +210,7 @@ export default function AiVaultPanel(): React.JSX.Element { const getSessionResumeState = useCallback( (session: AiVaultSession) => resolveAiVaultSessionResumeState({ + sessionFilePath: session.filePath, worktreeInfo: sessionWorktreeById.get(session.id) ?? null, activeWorktreeId: activeWorktreeId ?? activeWorktree?.id ?? null, worktrees: allWorktrees, @@ -229,6 +230,7 @@ export default function AiVaultPanel(): React.JSX.Element { const getSessionResumeActions = useCallback( (session: AiVaultSession) => resolveAiVaultSessionResumeActions({ + sessionFilePath: session.filePath, worktreeInfo: sessionWorktreeById.get(session.id) ?? null, activeWorktreeId: activeWorktreeId ?? activeWorktree?.id ?? null, worktrees: allWorktrees, diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx index fe2e07bc338..5ea6bd9d307 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx @@ -101,12 +101,13 @@ export function VaultSessionRow({ sessionId: session.sessionId, title: session.title, command: resumeStartup.command, + sessionFilePath: session.filePath, ...(resumeStartup.env ? { env: resumeStartup.env } : {}), ...(resumeStartup.launchConfig ? { launchConfig: resumeStartup.launchConfig } : {}) }) window.dispatchEvent(new Event(AI_VAULT_SESSION_DRAG_START_EVENT)) }, - [resumeDisabled, session.agent, session.sessionId, session.title, resumeStartup] + [resumeDisabled, session, resumeStartup] ) return ( diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts index ac5c07645fa..dc2ed601f83 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts @@ -12,8 +12,8 @@ import { } from '@/lib/worktree-activation' import { useAppStore } from '@/store' import { - getAiVaultResumeWorkspaceTargetStatus, - isSupportedAiVaultResumeTargetStatus + canResumeAiVaultSessionOnTarget, + getAiVaultResumeWorkspaceTargetStatus } from '@/lib/ai-vault-resume-target' import type { AiVaultAgent, AiVaultSession } from '../../../../shared/ai-vault-types' import type { Worktree } from '../../../../shared/types' @@ -78,6 +78,7 @@ export function useAiVaultSessionLaunchActions({ const handleResume = useCallback( (session: AiVaultSession, targetWorktreeId?: string): void => { const targetId = resolveAiVaultSessionLaunchTarget({ + sessionFilePath: session.filePath, activeWorktreeId: activeWorktreeId ?? activeWorktree?.id ?? null, targetWorktreeId, targetState @@ -93,17 +94,7 @@ export function useAiVaultSessionLaunchActions({ } if (targetId.status === 'unsupported') { - toast.error( - targetId.targetStatus === 'runtime' - ? translate( - 'auto.components.right.sidebar.AiVaultPanel.runtimeWorkspacesUnsupported', - 'Resume from history is not available in runtime-hosted workspaces.' - ) - : translate( - 'auto.components.right.sidebar.AiVaultPanel.openSupportedWorkspace', - 'Open a local or SSH workspace before resuming a session.' - ) - ) + toast.error(aiVaultResumeUnsupportedMessage(targetId.targetStatus)) return } @@ -138,6 +129,7 @@ export type AiVaultSessionLaunchTarget = | { status: 'ready'; worktreeId: string } export function resolveAiVaultSessionLaunchTarget(args: { + sessionFilePath: string | null activeWorktreeId: string | null targetWorktreeId?: string targetState: AiVaultSessionResumeTargetState @@ -151,13 +143,36 @@ export function resolveAiVaultSessionLaunchTarget(args: { } const targetStatus = getAiVaultResumeWorkspaceTargetStatus(args.targetState, targetWorktreeId) - if (!isSupportedAiVaultResumeTargetStatus(targetStatus)) { + if (!canResumeAiVaultSessionOnTarget({ sessionFilePath: args.sessionFilePath, targetStatus })) { return { status: 'unsupported', targetStatus } } return { status: 'ready', worktreeId: targetWorktreeId } } +function aiVaultResumeUnsupportedMessage( + targetStatus: ReturnType +): string { + if (targetStatus === 'runtime') { + return translate( + 'auto.components.right.sidebar.AiVaultPanel.runtimeWorkspacesUnsupported', + 'Resume from history is not available in runtime-hosted workspaces.' + ) + } + // Why: 'ssh' only reaches the unsupported branch when the session file lives + // on this machine, so the message explains the host mismatch, not SSH itself. + if (targetStatus === 'ssh') { + return translate( + 'auto.components.right.sidebar.AiVaultPanel.localSessionSshWorkspaceUnsupported', + "This session's history is stored on this machine, so it can't resume in an SSH workspace. Open a local workspace instead." + ) + } + return translate( + 'auto.components.right.sidebar.AiVaultPanel.openSupportedWorkspace', + 'Open a local or SSH workspace before resuming a session.' + ) +} + function activateAiVaultResumeWorkspace(workspaceId: string): void { const workspaceScope = parseWorkspaceKey(workspaceId) if (workspaceScope?.type === 'folder') { diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts index a3cf98dd7f5..342aed8b015 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts @@ -106,10 +106,14 @@ function makeWorktreeInfo( } } +const HOST_SESSION_FILE = '/Users/ada/.claude/projects/-repo-orca/session-1.jsonl' +const WSL_SESSION_FILE = '\\\\wsl$\\Ubuntu\\home\\ada\\.claude\\projects\\-repo-orca\\session-1.jsonl' + describe('resolveAiVaultSessionResumeState', () => { it('prefers the session worktree over the active workspace', () => { expect( resolveAiVaultSessionResumeState({ + sessionFilePath: HOST_SESSION_FILE, worktreeInfo: makeWorktreeInfo('active'), activeWorktreeId: 'repo-1::/repo/other', worktrees: [ @@ -128,6 +132,7 @@ describe('resolveAiVaultSessionResumeState', () => { it('falls back to the active workspace when the session worktree is unavailable', () => { expect( resolveAiVaultSessionResumeState({ + sessionFilePath: HOST_SESSION_FILE, worktreeInfo: makeWorktreeInfo('archived'), activeWorktreeId: 'repo-1::/repo/orca', worktrees: [makeWorktree()], @@ -143,6 +148,7 @@ describe('resolveAiVaultSessionResumeState', () => { it('blocks missing worktrees even when the repo is SSH-owned', () => { expect( resolveAiVaultSessionResumeState({ + sessionFilePath: HOST_SESSION_FILE, worktreeInfo: makeWorktreeInfo('active'), activeWorktreeId: null, worktrees: [], @@ -158,6 +164,7 @@ describe('resolveAiVaultSessionResumeState', () => { it('allows SSH-owned session worktrees', () => { expect( resolveAiVaultSessionResumeState({ + sessionFilePath: WSL_SESSION_FILE, worktreeInfo: makeWorktreeInfo('active'), activeWorktreeId: null, worktrees: [makeWorktree()], @@ -173,6 +180,7 @@ describe('resolveAiVaultSessionResumeState', () => { it('allows SSH-stamped worktrees even when the repo owner is runtime', () => { expect( resolveAiVaultSessionResumeState({ + sessionFilePath: WSL_SESSION_FILE, worktreeInfo: makeWorktreeInfo('active'), activeWorktreeId: null, worktrees: [makeWorktree({ hostId: 'ssh:ssh-1' })], @@ -188,6 +196,7 @@ describe('resolveAiVaultSessionResumeState', () => { it('blocks runtime-owned targets even when they have no SSH connection', () => { expect( resolveAiVaultSessionResumeState({ + sessionFilePath: HOST_SESSION_FILE, worktreeInfo: makeWorktreeInfo('active'), activeWorktreeId: null, worktrees: [makeWorktree()], @@ -208,6 +217,7 @@ describe('resolveAiVaultSessionResumeState', () => { it('blocks runtime-stamped worktrees even when the repo owner is local', () => { expect( resolveAiVaultSessionResumeState({ + sessionFilePath: HOST_SESSION_FILE, worktreeInfo: makeWorktreeInfo('active'), activeWorktreeId: null, worktrees: [makeWorktree({ hostId: 'runtime:env-1' })], @@ -223,6 +233,7 @@ describe('resolveAiVaultSessionResumeState', () => { it('prefers the session worktree when the active workspace is SSH-owned', () => { expect( resolveAiVaultSessionResumeState({ + sessionFilePath: HOST_SESSION_FILE, worktreeInfo: makeWorktreeInfo('active'), activeWorktreeId: 'repo-2::/remote/orca', worktrees: [ @@ -245,6 +256,7 @@ describe('resolveAiVaultSessionResumeState', () => { it('falls back to the active workspace when the session worktree is runtime-owned', () => { expect( resolveAiVaultSessionResumeState({ + sessionFilePath: HOST_SESSION_FILE, worktreeInfo: makeWorktreeInfo('active'), activeWorktreeId: 'repo-2::/repo/other', worktrees: [ @@ -275,6 +287,7 @@ describe('resolveAiVaultSessionResumeState', () => { it('falls back to an active SSH folder workspace', () => { expect( resolveAiVaultSessionResumeState({ + sessionFilePath: WSL_SESSION_FILE, worktreeInfo: makeWorktreeInfo('archived'), activeWorktreeId: folderWorkspaceKey('folder-1'), worktrees: [], @@ -288,9 +301,27 @@ describe('resolveAiVaultSessionResumeState', () => { }) }) + it('blocks host-stored sessions when only an SSH workspace is open', () => { + expect( + resolveAiVaultSessionResumeState({ + sessionFilePath: HOST_SESSION_FILE, + worktreeInfo: makeWorktreeInfo('archived'), + activeWorktreeId: folderWorkspaceKey('folder-1'), + worktrees: [], + repos: [], + targetState: makeFolderTargetState({ id: 'group-1', connectionId: 'ssh-1' }) + }) + ).toEqual({ + blocked: true, + worktreeId: null, + usesSessionWorktree: false + }) + }) + it('blocks an active runtime folder workspace', () => { expect( resolveAiVaultSessionResumeState({ + sessionFilePath: HOST_SESSION_FILE, worktreeInfo: makeWorktreeInfo('archived'), activeWorktreeId: folderWorkspaceKey('folder-1'), worktrees: [], @@ -309,6 +340,7 @@ describe('resolveAiVaultSessionResumeActions', () => { it('exposes separate session-worktree and active-workspace targets', () => { expect( resolveAiVaultSessionResumeActions({ + sessionFilePath: HOST_SESSION_FILE, worktreeInfo: makeWorktreeInfo('active'), activeWorktreeId: 'repo-1::/repo/other', worktrees: [ @@ -326,6 +358,7 @@ describe('resolveAiVaultSessionResumeActions', () => { it('enables an SSH active-workspace action when the session worktree is local', () => { expect( resolveAiVaultSessionResumeActions({ + sessionFilePath: WSL_SESSION_FILE, worktreeInfo: makeWorktreeInfo('active'), activeWorktreeId: 'repo-2::/remote/orca', worktrees: [ @@ -344,9 +377,32 @@ describe('resolveAiVaultSessionResumeActions', () => { }) }) + it('disables the SSH active-workspace action for host-stored sessions', () => { + expect( + resolveAiVaultSessionResumeActions({ + sessionFilePath: HOST_SESSION_FILE, + worktreeInfo: makeWorktreeInfo('active'), + activeWorktreeId: 'repo-2::/remote/orca', + worktrees: [ + makeWorktree(), + makeWorktree({ + id: 'repo-2::/remote/orca', + repoId: 'repo-2', + path: '/remote/orca' + }) + ], + repos: [{ id: 'repo-1' } as Repo, { id: 'repo-2', connectionId: 'ssh-1' } as Repo] + }) + ).toEqual({ + worktree: { worktreeId: 'repo-1::/repo/orca', disabled: false }, + newTab: { worktreeId: 'repo-2::/remote/orca', disabled: true } + }) + }) + it('disables runtime-owned targets without disabling local targets', () => { expect( resolveAiVaultSessionResumeActions({ + sessionFilePath: HOST_SESSION_FILE, worktreeInfo: makeWorktreeInfo('active'), activeWorktreeId: 'repo-2::/repo/other', worktrees: [ @@ -376,6 +432,7 @@ describe('resolveAiVaultSessionResumeActions', () => { it('does not expose the active workspace as a duplicate new-tab target', () => { expect( resolveAiVaultSessionResumeActions({ + sessionFilePath: HOST_SESSION_FILE, worktreeInfo: makeWorktreeInfo('current'), activeWorktreeId: 'repo-1::/repo/orca', worktrees: [makeWorktree()], @@ -390,6 +447,7 @@ describe('resolveAiVaultSessionResumeActions', () => { it('enables the active folder workspace action when it is local or SSH-owned', () => { expect( resolveAiVaultSessionResumeActions({ + sessionFilePath: WSL_SESSION_FILE, worktreeInfo: makeWorktreeInfo('archived'), activeWorktreeId: folderWorkspaceKey('folder-1'), worktrees: [], @@ -405,6 +463,7 @@ describe('resolveAiVaultSessionResumeActions', () => { it('disables the active folder workspace action when it is runtime-owned', () => { expect( resolveAiVaultSessionResumeActions({ + sessionFilePath: HOST_SESSION_FILE, worktreeInfo: makeWorktreeInfo('archived'), activeWorktreeId: folderWorkspaceKey('folder-1'), worktrees: [], @@ -422,6 +481,7 @@ describe('resolveAiVaultSessionLaunchTarget', () => { it('allows direct resume into an active SSH folder workspace', () => { expect( resolveAiVaultSessionLaunchTarget({ + sessionFilePath: WSL_SESSION_FILE, activeWorktreeId: folderWorkspaceKey('folder-1'), targetState: makeFolderTargetState({ id: 'group-1', connectionId: 'ssh-1' }) }) @@ -431,9 +491,23 @@ describe('resolveAiVaultSessionLaunchTarget', () => { }) }) + it('blocks direct resume of a host-stored session into an SSH folder workspace', () => { + expect( + resolveAiVaultSessionLaunchTarget({ + sessionFilePath: HOST_SESSION_FILE, + activeWorktreeId: folderWorkspaceKey('folder-1'), + targetState: makeFolderTargetState({ id: 'group-1', connectionId: 'ssh-1' }) + }) + ).toEqual({ + status: 'unsupported', + targetStatus: 'ssh' + }) + }) + it('blocks direct resume into a runtime folder workspace', () => { expect( resolveAiVaultSessionLaunchTarget({ + sessionFilePath: WSL_SESSION_FILE, activeWorktreeId: folderWorkspaceKey('folder-1'), targetState: makeFolderTargetState({ id: 'group-1', executionHostId: 'runtime:env-1' }) }) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts index 07091909d94..b8a428cd0c0 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts @@ -1,7 +1,7 @@ import type { Repo, Worktree } from '../../../../shared/types' import { - getAiVaultResumeWorkspaceTargetStatus, - isSupportedAiVaultResumeTargetStatus + canResumeAiVaultSessionOnTarget, + getAiVaultResumeWorkspaceTargetStatus } from '@/lib/ai-vault-resume-target' import type { AppState } from '@/store/types' import { translate } from '@/i18n/i18n' @@ -33,6 +33,7 @@ export type AiVaultSessionResumeActions = { } export function resolveAiVaultSessionResumeState(args: { + sessionFilePath: string | null worktreeInfo: AiVaultSessionWorktreeInfo | null activeWorktreeId: string | null worktrees: readonly Worktree[] @@ -54,6 +55,7 @@ export function resolveAiVaultSessionResumeState(args: { for (const worktreeId of candidateWorktreeIds) { const targetId = resolveSupportedResumeWorktreeId({ + sessionFilePath: args.sessionFilePath, worktreeId, targetState }) @@ -75,6 +77,7 @@ export function resolveAiVaultSessionResumeState(args: { } export function resolveAiVaultSessionResumeActions(args: { + sessionFilePath: string | null worktreeInfo: AiVaultSessionWorktreeInfo | null activeWorktreeId: string | null worktrees: readonly Worktree[] @@ -88,10 +91,12 @@ export function resolveAiVaultSessionResumeActions(args: { const targetState = resolveAiVaultResumeTargetState(args) const sessionTargetId = resolveSupportedResumeWorktreeId({ + sessionFilePath: args.sessionFilePath, worktreeId: sessionWorktreeId, targetState }) const activeTargetId = resolveSupportedResumeWorktreeId({ + sessionFilePath: args.sessionFilePath, worktreeId: args.activeWorktreeId && args.activeWorktreeId !== sessionWorktreeId ? args.activeWorktreeId @@ -136,6 +141,7 @@ export function isKnownAiVaultResumeWorkspaceTarget( } function resolveSupportedResumeWorktreeId(args: { + sessionFilePath: string | null worktreeId: string | null targetState: AiVaultSessionResumeTargetState }): string | null { @@ -148,7 +154,7 @@ function resolveSupportedResumeWorktreeId(args: { } const targetStatus = getAiVaultResumeWorkspaceTargetStatus(args.targetState, args.worktreeId) - if (!isSupportedAiVaultResumeTargetStatus(targetStatus)) { + if (!canResumeAiVaultSessionOnTarget({ sessionFilePath: args.sessionFilePath, targetStatus })) { return null } diff --git a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx index c0362c59c68..ee41d5c6e81 100644 --- a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx +++ b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx @@ -1,6 +1,9 @@ import { useCallback, useEffect, useRef, useState, type CSSProperties } from 'react' import { toast } from 'sonner' -import { getAiVaultResumeWorkspaceTargetStatus } from '@/lib/ai-vault-resume-target' +import { + canResumeAiVaultSessionOnTarget, + getAiVaultResumeWorkspaceTargetStatus +} from '@/lib/ai-vault-resume-target' import { AI_VAULT_SESSION_DRAG_END_EVENT, AI_VAULT_SESSION_DRAG_START_EVENT, @@ -185,6 +188,20 @@ export default function AiVaultSessionDropLayer({ ) return true } + if ( + !canResumeAiVaultSessionOnTarget({ + sessionFilePath: payload.sessionFilePath ?? null, + targetStatus + }) + ) { + toast.error( + translate( + 'auto.components.tab.group.AiVaultSessionDropLayer.localSessionSshWorkspaceUnsupported', + "This session's history is stored on this machine, so it can't resume in an SSH workspace. Drop it onto a local workspace instead." + ) + ) + return true + } launchAiVaultSessionInNewTab({ agent: payload.agent, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index b9113f064cd..5611d8d73ce 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -2586,7 +2586,8 @@ "openLocalWorkspace": "Open a local workspace before resuming a session.", "sessionQueued": "Session queued", "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", - "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session." + "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.", + "localSessionSshWorkspaceUnsupported": "This session's history is stored on this machine, so it can't resume in an SSH workspace. Drop it onto a local workspace instead." }, "TabGroupDropOverlay": { "paneColumnLabel": "New split" @@ -9572,7 +9573,8 @@ "worktreeUnavailable": "Worktree is no longer available.", "runtimeBrowseLocalHistory": "Runtime-hosted workspaces can browse local history. Resume actions are available in local and SSH workspaces.", "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", - "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session." + "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.", + "localSessionSshWorkspaceUnsupported": "This session's history is stored on this machine, so it can't resume in an SSH workspace. Open a local workspace instead." }, "AiVaultPanelControls": { "scanningSessions": "Scanning sessions", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index ea71f964f4b..ebca92c506e 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -2586,7 +2586,8 @@ "openLocalWorkspace": "Abre un espacio de trabajo local antes de reanudar una sesión.", "sessionQueued": "Sesión en cola", "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", - "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session." + "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.", + "localSessionSshWorkspaceUnsupported": "El historial de esta sesión está almacenado en este equipo, por lo que no se puede reanudar en un espacio de trabajo SSH. En su lugar, suéltala en un espacio de trabajo local." }, "TabGroupDropOverlay": { "paneColumnLabel": "Nueva división" @@ -9572,7 +9573,8 @@ "worktreeUnavailable": "Worktree is no longer available.", "runtimeBrowseLocalHistory": "Runtime-hosted workspaces can browse local history. Resume actions are available in local and SSH workspaces.", "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", - "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session." + "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.", + "localSessionSshWorkspaceUnsupported": "El historial de esta sesión está almacenado en este equipo, por lo que no se puede reanudar en un espacio de trabajo SSH. En su lugar, abre un espacio de trabajo local." }, "AiVaultPanelControls": { "scanningSessions": "Scanning sessions", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 177d52f50b2..c02e6f709d8 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -2586,7 +2586,8 @@ "openLocalWorkspace": "セッションを再開する前にローカルワークスペースを開いてください。", "sessionQueued": "セッションをキューに追加しました", "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", - "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session." + "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.", + "localSessionSshWorkspaceUnsupported": "このセッションの履歴はこのマシンに保存されているため、SSH ワークスペースでは再開できません。代わりにローカルワークスペースにドロップしてください。" }, "TabGroupDropOverlay": { "paneColumnLabel": "新しい分割" @@ -9572,7 +9573,8 @@ "worktreeUnavailable": "ワークツリーは利用できなくなりました。", "runtimeBrowseLocalHistory": "Runtime-hosted workspaces can browse local history. Resume actions are available in local and SSH workspaces.", "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", - "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session." + "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.", + "localSessionSshWorkspaceUnsupported": "このセッションの履歴はこのマシンに保存されているため、SSH ワークスペースでは再開できません。代わりにローカルワークスペースを開いてください。" }, "AiVaultPanelControls": { "scanningSessions": "Scanning sessions", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index ab7493ba20b..ce2b4283fcc 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -2586,7 +2586,8 @@ "openLocalWorkspace": "세션을 재개하기 전에 로컬 워크스페이스를 여세요.", "sessionQueued": "세션이 대기열에 추가됨", "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", - "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session." + "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.", + "localSessionSshWorkspaceUnsupported": "이 세션의 기록은 이 컴퓨터에 저장되어 있어 SSH 워크스페이스에서는 재개할 수 없습니다. 대신 로컬 워크스페이스에 놓으세요." }, "TabGroupDropOverlay": { "paneColumnLabel": "새 분할" @@ -9572,7 +9573,8 @@ "worktreeUnavailable": "워크트리는 더 이상 사용할 수 없습니다.", "runtimeBrowseLocalHistory": "Runtime-hosted workspaces can browse local history. Resume actions are available in local and SSH workspaces.", "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", - "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session." + "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.", + "localSessionSshWorkspaceUnsupported": "이 세션의 기록은 이 컴퓨터에 저장되어 있어 SSH 워크스페이스에서는 재개할 수 없습니다. 대신 로컬 워크스페이스를 여세요." }, "AiVaultPanelControls": { "scanningSessions": "세션 스캔 중", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 78f552eef4a..719e1e9856b 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -2586,7 +2586,8 @@ "openLocalWorkspace": "恢复会话前请先打开一个本地工作区。", "sessionQueued": "会话已排队", "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", - "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session." + "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.", + "localSessionSshWorkspaceUnsupported": "此会话的历史记录存储在本机上,因此无法在 SSH 工作区中恢复。请改为将其拖放到本地工作区。" }, "TabGroupDropOverlay": { "paneColumnLabel": "新建拆分" @@ -9572,7 +9573,8 @@ "worktreeUnavailable": "Worktree is no longer available.", "runtimeBrowseLocalHistory": "Runtime-hosted workspaces can browse local history. Resume actions are available in local and SSH workspaces.", "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", - "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session." + "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.", + "localSessionSshWorkspaceUnsupported": "此会话的历史记录存储在本机上,因此无法在 SSH 工作区中恢复。请改为打开一个本地工作区。" }, "AiVaultPanelControls": { "scanningSessions": "正在扫描会话", diff --git a/src/renderer/src/lib/ai-vault-resume-target.test.ts b/src/renderer/src/lib/ai-vault-resume-target.test.ts index 2bbd2f0bb32..a2c827cf99b 100644 --- a/src/renderer/src/lib/ai-vault-resume-target.test.ts +++ b/src/renderer/src/lib/ai-vault-resume-target.test.ts @@ -1,11 +1,13 @@ import { describe, expect, it } from 'vitest' import type { AppState } from '@/store/types' import { + canResumeAiVaultSessionOnTarget, getAiVaultResumeRepoTargetStatus, getAiVaultResumeWorktreeTargetStatus, getAiVaultResumeWorkspaceTargetStatus, isSupportedAiVaultResumeRepo, - isUnsupportedAiVaultResumeRepo + isUnsupportedAiVaultResumeRepo, + isWslStoredAiVaultSessionFile } from './ai-vault-resume-target' import { folderWorkspaceKey } from '../../../shared/workspace-scope' @@ -26,6 +28,56 @@ function makeState( } as unknown as ResumeTargetState } +describe('ai vault session storage compatibility', () => { + const hostSessionFile = '/Users/ada/.claude/projects/-Users-ada-repo/session-1.jsonl' + const windowsHostSessionFile = 'C:\\Users\\ada\\.claude\\projects\\C--repo\\session-1.jsonl' + const wslSessionFile = '\\\\wsl$\\Ubuntu\\home\\ada\\.claude\\projects\\-home-ada-repo\\s-1.jsonl' + const wslLocalhostSessionFile = + '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.claude\\projects\\-home-ada-repo\\s-1.jsonl' + + it('detects WSL-stored session files', () => { + expect(isWslStoredAiVaultSessionFile(wslSessionFile)).toBe(true) + expect(isWslStoredAiVaultSessionFile(wslLocalhostSessionFile)).toBe(true) + expect(isWslStoredAiVaultSessionFile(hostSessionFile)).toBe(false) + expect(isWslStoredAiVaultSessionFile(windowsHostSessionFile)).toBe(false) + expect(isWslStoredAiVaultSessionFile(null)).toBe(false) + expect(isWslStoredAiVaultSessionFile(undefined)).toBe(false) + }) + + it('allows host-stored sessions on local targets only', () => { + expect( + canResumeAiVaultSessionOnTarget({ sessionFilePath: hostSessionFile, targetStatus: 'local' }) + ).toBe(true) + expect( + canResumeAiVaultSessionOnTarget({ sessionFilePath: hostSessionFile, targetStatus: 'ssh' }) + ).toBe(false) + expect( + canResumeAiVaultSessionOnTarget({ + sessionFilePath: windowsHostSessionFile, + targetStatus: 'ssh' + }) + ).toBe(false) + }) + + it('allows WSL-stored sessions on local and SSH targets', () => { + expect( + canResumeAiVaultSessionOnTarget({ sessionFilePath: wslSessionFile, targetStatus: 'local' }) + ).toBe(true) + expect( + canResumeAiVaultSessionOnTarget({ sessionFilePath: wslSessionFile, targetStatus: 'ssh' }) + ).toBe(true) + }) + + it('never allows runtime or unknown targets', () => { + expect( + canResumeAiVaultSessionOnTarget({ sessionFilePath: wslSessionFile, targetStatus: 'runtime' }) + ).toBe(false) + expect( + canResumeAiVaultSessionOnTarget({ sessionFilePath: hostSessionFile, targetStatus: 'unknown' }) + ).toBe(false) + }) +}) + describe('ai vault resume target ownership', () => { it('classifies local, SSH, runtime, and unknown repo owners', () => { expect(getAiVaultResumeRepoTargetStatus({ connectionId: null, executionHostId: 'local' })).toBe( diff --git a/src/renderer/src/lib/ai-vault-resume-target.ts b/src/renderer/src/lib/ai-vault-resume-target.ts index 0e2afff8f34..d4fae026978 100644 --- a/src/renderer/src/lib/ai-vault-resume-target.ts +++ b/src/renderer/src/lib/ai-vault-resume-target.ts @@ -8,6 +8,7 @@ import { import type { Repo } from '../../../shared/types' import { getRepoIdFromWorktreeId } from '../../../shared/worktree-id' import { parseWorkspaceKey } from '../../../shared/workspace-scope' +import { isWslUncPath } from '../../../shared/wsl-paths' import type { AppState } from '@/store/types' import { getFolderWorkspaceCandidateRepos } from './folder-workspace-connection' @@ -36,6 +37,29 @@ export function isSupportedAiVaultResumeTargetStatus(status: AiVaultResumeTarget return status === 'local' || status === 'ssh' } +export function isWslStoredAiVaultSessionFile( + sessionFilePath: string | null | undefined +): boolean { + return Boolean(sessionFilePath && isWslUncPath(sessionFilePath)) +} + +export function canResumeAiVaultSessionOnTarget(args: { + sessionFilePath: string | null | undefined + targetStatus: AiVaultResumeTargetStatus +}): boolean { + if (!isSupportedAiVaultResumeTargetStatus(args.targetStatus)) { + return false + } + // Why: vault sessions are scanned from this machine's disk (host home dirs + // plus local WSL homes). An SSH shell can only reach the WSL-stored ones + // (SSH-to-local-WSL setups, #6270); host-stored session files do not exist + // on a remote filesystem, so queuing a resume there is guaranteed to fail. + if (args.targetStatus === 'ssh') { + return isWslStoredAiVaultSessionFile(args.sessionFilePath) + } + return true +} + export function isUnsupportedAiVaultResumeRepo( repo: AiVaultResumeRepoOwner | null | undefined ): boolean { diff --git a/src/renderer/src/lib/ai-vault-session-drag.test.ts b/src/renderer/src/lib/ai-vault-session-drag.test.ts index 6e1a93f763f..e51a0c78efd 100644 --- a/src/renderer/src/lib/ai-vault-session-drag.test.ts +++ b/src/renderer/src/lib/ai-vault-session-drag.test.ts @@ -48,6 +48,7 @@ describe('Session History session drag data', () => { sessionId: 'session-1', title: 'Fix terminal split', command: "cd '/repo' && claude --resume session-1", + sessionFilePath: '/Users/ada/.claude/projects/-repo/session-1.jsonl', env: { ANTHROPIC_BASE_URL: 'https://claude.example.test' }, launchConfig: { agentCommand: 'claude --dangerously-skip-permissions', @@ -63,6 +64,24 @@ describe('Session History session drag data', () => { expect(readAiVaultSessionDragData(transfer)).toEqual(payload) }) + it('rejects blank session file paths', () => { + const transfer = createTransfer() + transfer.setData( + AI_VAULT_SESSION_DRAG_TYPE, + JSON.stringify({ + kind: 'ai-vault-session', + version: 1, + agent: 'claude', + sessionId: 'session-1', + title: 'Blank session file path', + command: 'claude --resume session-1', + sessionFilePath: ' ' + }) + ) + + expect(readAiVaultSessionDragData(transfer)).toBeNull() + }) + it('rejects malformed payloads', () => { const transfer = createTransfer() transfer.setData( diff --git a/src/renderer/src/lib/ai-vault-session-drag.ts b/src/renderer/src/lib/ai-vault-session-drag.ts index 1dfede058ec..2b52359e534 100644 --- a/src/renderer/src/lib/ai-vault-session-drag.ts +++ b/src/renderer/src/lib/ai-vault-session-drag.ts @@ -12,6 +12,9 @@ export type AiVaultSessionDragPayload = { sessionId: string title: string command: string + // Why: drop targets must know where the session file lives (host vs local + // WSL) to reject SSH panes that cannot reach it. + sessionFilePath?: string // Why: drag/drop resume must preserve planned env/default args, not just the shell command. env?: Record launchConfig?: SleepingAgentLaunchConfig @@ -63,6 +66,7 @@ function isSerializedPayload(value: unknown): value is SerializedAiVaultSessionD isNonEmptyString(payload.sessionId) && isNonEmptyString(payload.title) && isNonEmptyString(payload.command) && + (payload.sessionFilePath === undefined || isNonEmptyString(payload.sessionFilePath)) && (payload.env === undefined || isStringRecord(payload.env)) && (payload.launchConfig === undefined || isLaunchConfig(payload.launchConfig)) ) @@ -110,12 +114,13 @@ export function readAiVaultSessionDragData( if (!isSerializedPayload(parsed)) { return null } - const { agent, sessionId, title, command, env, launchConfig } = parsed + const { agent, sessionId, title, command, sessionFilePath, env, launchConfig } = parsed return { agent, sessionId, title, command, + ...(sessionFilePath ? { sessionFilePath } : {}), ...(env ? { env } : {}), ...(launchConfig ? { launchConfig } : {}) } From 492306f223e9ca1ae1e92e3a2ba45237a229927d Mon Sep 17 00:00:00 2001 From: Daniel Naxon Date: Fri, 3 Jul 2026 22:44:34 +0300 Subject: [PATCH 35/48] fix(sidebar): keep independent same-host checkouts of one repo as separate projects (#7237) * fix(sidebar): keep independent same-host checkouts of one repo as separate projects Re-regression of #5374: PR #6320 removed the per-host-surface separation added by #5375, so independent local clones of the same repo (shared origin => same github:owner/repo identity) collapse into one sidebar project again. Restore the surface-key separation in worktree-list-groups.ts, scoped by ProjectHostSetup.setupMethod: 'provisioned' (ephemeral-VM runtime copies) still nest under the project header (preserving #6320), while real user checkouts (legacy-repo / imported-existing-folder / cloned) each get their own header when duplicated on one host surface. Revert the two tests #6320 inverted and add coverage for a provisioned copy nesting alongside a same-host user checkout. * test(cmd-j): update palette grouping tests for same-host checkout split The Cmd+J palette shares getProjectGroupingForRepo (via getProjectHeaderRevealTarget) with the sidebar, so restoring same-host checkout separation also splits independent `cloned` checkouts into per-setup palette entries. Update the multi-setup test to assert the split and add coverage that a `provisioned` runtime copy still nests under one project key. * test(sidebar): cover split + provisioned-nest on one host together Guard the intersection of #5374 and #6320: when a project has two real same-host user checkouts AND a provisioned copy, the real checkouts each get their own per-setup header while the provisioned copy stays under the plain project key (never a path-scoped ::setup: header). Locks the two features' combined behavior so a future change can't silently regress either half. * refactor(sidebar): reuse shared Windows-path helper, single-pass surface count Take-over polish of #7237: - Use shared isWindowsAbsolutePathLike instead of an inline drive-letter regex, deduplicating the pattern and also bucketing UNC \\server\share paths as a Windows surface instead of falling through to default. - Collapse buildProjectGroupingIndex's two passes over projectHostSetups into one, computing each surface key once and flagging ambiguous projects from the aggregated counts. - Tighten why-comments to AGENTS.md's two-line rule. No behavior change to the fix; all 123 sidebar/palette grouping tests pass. Co-authored-by: Orca --------- Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> Co-authored-by: Orca --- .../components/cmd-j/palette-results.test.ts | 26 ++- .../sidebar/worktree-list-groups.test.ts | 189 +++++++++++++++++- .../sidebar/worktree-list-groups.ts | 64 +++++- 3 files changed, 264 insertions(+), 15 deletions(-) diff --git a/src/renderer/src/components/cmd-j/palette-results.test.ts b/src/renderer/src/components/cmd-j/palette-results.test.ts index 820fa577de9..73049bcc149 100644 --- a/src/renderer/src/components/cmd-j/palette-results.test.ts +++ b/src/renderer/src/components/cmd-j/palette-results.test.ts @@ -357,7 +357,10 @@ describe('Cmd+J project and repo-group search', () => { ]) }) - it('uses project header keys for multi-setup projects on one host', () => { + it('splits independent same-host checkouts of one project into per-setup keys', () => { + // Why: two `cloned` checkouts share the project's remote identity but are + // distinct user clones; the palette follows the sidebar and surfaces each as + // its own jump target rather than collapsing them. See #5374. const results = searchCmdJProjectResults({ query: 'platform', projectGroups: [], @@ -369,6 +372,27 @@ describe('Cmd+J project and repo-group search', () => { ] }) + expect(results.map((result) => result.rowKey)).toEqual([ + 'project:project-1::setup:repo-1', + 'project:project-1::setup:repo-2' + ]) + }) + + it('keeps a provisioned runtime copy under one project key alongside a same-host checkout', () => { + // Why: a `provisioned` (recipe-created ephemeral) copy shares the project's + // remote identity but must not split the user's real checkout; it nests + // under the single project key. Mirrors the sidebar grouping. See #6320 / #5374. + const results = searchCmdJProjectResults({ + query: 'platform', + projectGroups: [], + repos: [repo('repo-1', 'platform-a'), repo('repo-2', 'platform-runtime')], + projects: [project('project-1', 'Platform')], + projectHostSetups: [ + setup('setup-1', 'project-1', 'local', 'repo-1'), + { ...setup('setup-2', 'project-1', 'local', 'repo-2'), setupMethod: 'provisioned' } + ] + }) + expect(results.map((result) => result.rowKey)).toEqual(['project:project-1']) }) diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts index f0ef24b9ef6..1ab12104567 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts @@ -629,7 +629,7 @@ describe('buildRows with pinned worktrees', () => { }) }) - it('keeps same-host checkouts of one project under the project header', () => { + it('splits same-host checkouts of one project into separate per-setup groups', () => { const repoB: Repo = { ...repo, id: 'repo-2', path: '/tmp/orca-2', displayName: 'orca-2' } const worktreeB: Worktree = { ...worktree, @@ -676,15 +676,22 @@ describe('buildRows with pinned worktrees', () => { ) const headers = rows.filter((row) => row.type === 'header') - expect(headers).toHaveLength(1) - expect(headers[0]).toMatchObject({ - key: 'project:github:stablyai/orca', - label: 'Orca', - count: 2 - }) + expect(headers).toHaveLength(2) + expect(headers).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: 'project:github:stablyai/orca::setup:repo-1', + label: 'orca' + }), + expect.objectContaining({ + key: 'project:github:stablyai/orca::setup:repo-2', + label: 'orca-2' + }) + ]) + ) }) - it('keeps runtime copies under the project header when one host has duplicate checkouts', () => { + it('splits all project setups when one host has duplicate checkouts', () => { const localRepoB: Repo = { ...repo, id: 'repo-local-b', @@ -738,8 +745,170 @@ describe('buildRows with pinned worktrees', () => { ) const headers = rows.filter((row) => row.type === 'header') - expect(headers.map((row) => row.key)).toEqual(['project:github:stablyai/orca']) - expect(headers[0]).toMatchObject({ label: 'Orca', count: 3 }) + expect(headers.map((row) => row.key)).toEqual([ + 'project:github:stablyai/orca::setup:repo-1', + 'project:github:stablyai/orca::setup:repo-local-b', + 'project:github:stablyai/orca::setup:repo-remote' + ]) + }) + + it('keeps a provisioned runtime copy under the project header alongside a same-host checkout', () => { + const runtimeRepoB: Repo = { + ...repo, + id: 'repo-runtime-b', + path: '/tmp/orca-runtime-b', + displayName: 'orca-runtime-b' + } + const runtimeWorktreeB: Worktree = { + ...worktree, + id: 'wt-runtime-b', + repoId: runtimeRepoB.id, + path: '/tmp/orca-runtime-b-feature', + displayName: 'feature-runtime-b' + } + // Why: a `provisioned` (recipe-created ephemeral) copy shares the project's + // remote identity but must not split the user's real checkout into two + // headers; it nests under the project. See #6320 / #5374. + const runtimeSetupB: ProjectHostSetup = { + ...projectHostSetups[0]!, + id: runtimeRepoB.id, + repoId: runtimeRepoB.id, + path: runtimeRepoB.path, + displayName: runtimeRepoB.displayName, + setupMethod: 'provisioned' + } + const rows = buildRows( + 'repo', + [worktree, runtimeWorktreeB], + new Map([ + [repo.id, repo], + [runtimeRepoB.id, runtimeRepoB] + ]), + null, + new Set(), + undefined, + undefined, + undefined, + {}, + new Map([ + [worktree.id, worktree], + [runtimeWorktreeB.id, runtimeWorktreeB] + ]), + false, + undefined, + [], + new Set(), + new Map(), + new Map(), + [], + { + projects: [{ ...project, sourceRepoIds: [repo.id, runtimeRepoB.id] }], + projectHostSetups: [projectHostSetups[0]!, runtimeSetupB] + } + ) + + const headers = rows.filter((row) => row.type === 'header') + expect(headers).toHaveLength(1) + expect(headers[0]).toMatchObject({ + key: 'project:github:stablyai/orca', + label: 'Orca', + count: 2 + }) + }) + + it('splits duplicate user checkouts while a provisioned copy nests, on one host', () => { + // Why: guards the intersection of #5374 (real same-host checkouts split) and + // #6320 (provisioned copies nest). Two legacy checkouts must each get their own + // header while a provisioned copy of the same project stays under the plain + // project header — all on one host surface, simultaneously. + const localRepoB: Repo = { + ...repo, + id: 'repo-local-b', + path: '/tmp/orca-b', + displayName: 'orca-b' + } + const localWorktreeB: Worktree = { + ...worktree, + id: 'wt-local-b', + repoId: localRepoB.id, + path: '/tmp/orca-b-feature', + displayName: 'feature-b' + } + const localSetupB: ProjectHostSetup = { + ...projectHostSetups[0]!, + id: localRepoB.id, + repoId: localRepoB.id, + path: localRepoB.path, + displayName: localRepoB.displayName + } + const runtimeRepoB: Repo = { + ...repo, + id: 'repo-runtime-b', + path: '/tmp/orca-runtime-b', + displayName: 'orca-runtime-b' + } + const runtimeWorktreeB: Worktree = { + ...worktree, + id: 'wt-runtime-b', + repoId: runtimeRepoB.id, + path: '/tmp/orca-runtime-b-feature', + displayName: 'feature-runtime-b' + } + const runtimeSetupB: ProjectHostSetup = { + ...projectHostSetups[0]!, + id: runtimeRepoB.id, + repoId: runtimeRepoB.id, + path: runtimeRepoB.path, + displayName: runtimeRepoB.displayName, + setupMethod: 'provisioned' + } + const rows = buildRows( + 'repo', + [worktree, localWorktreeB, runtimeWorktreeB], + new Map([ + [repo.id, repo], + [localRepoB.id, localRepoB], + [runtimeRepoB.id, runtimeRepoB] + ]), + null, + new Set(), + undefined, + undefined, + undefined, + {}, + new Map([ + [worktree.id, worktree], + [localWorktreeB.id, localWorktreeB], + [runtimeWorktreeB.id, runtimeWorktreeB] + ]), + false, + undefined, + [], + new Set(), + new Map(), + new Map(), + [], + { + projects: [{ ...project, sourceRepoIds: [repo.id, localRepoB.id, runtimeRepoB.id] }], + projectHostSetups: [projectHostSetups[0]!, localSetupB, runtimeSetupB] + } + ) + + const headers = rows.filter((row) => row.type === 'header') + expect(headers.map((row) => row.key).sort()).toEqual([ + 'project:github:stablyai/orca', + 'project:github:stablyai/orca::setup:repo-1', + 'project:github:stablyai/orca::setup:repo-local-b' + ]) + // The provisioned copy nests under the plain project key with only its own + // worktree; it never gets a path-scoped `::setup:` header like the real + // checkouts do. (buildRows disambiguates its visible label to the repo name.) + expect( + headers.some((row) => row.key === 'project:github:stablyai/orca::setup:repo-runtime-b') + ).toBe(false) + expect(headers.find((row) => row.key === 'project:github:stablyai/orca')).toMatchObject({ + count: 1 + }) }) it('groups Windows host and WSL setups on the same runtime host', () => { diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.ts b/src/renderer/src/components/sidebar/worktree-list-groups.ts index 82a3301d82b..e7fb1749ed4 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.ts @@ -35,6 +35,8 @@ import { getGitHubPRCacheKey, getLegacyGitHubPRCacheKey } from '../../store/slic import { getRepoDisplayLabelsByPath } from '@/lib/repo-display-labels' import { translate } from '@/i18n/i18n' import { getExecutionHostLabel, getRepoExecutionHostId } from '../../../../shared/execution-host' +import { parseWslUncPath } from '../../../../shared/wsl-paths' +import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path' export { branchName } @@ -152,10 +154,30 @@ type WorktreeGroupEntry = { type ProjectGroupingIndex = { projectById: Map setupByRepoId: Map + projectIdsRequiringSetupGroups: Set } const projectGroupingIndexCache = new WeakMap() +// Why: `provisioned` setups are ephemeral recipe-created runtime copies that +// nest under the project header; every other method is a real user checkout. See #5374. +function isDistinctUserCheckout(setup: ProjectHostSetup): boolean { + return setup.setupMethod !== 'provisioned' +} + +function getProjectSetupSurfaceKey(setup: ProjectHostSetup): string { + const wslPath = parseWslUncPath(setup.path) + if (wslPath) { + // Why: Windows host and WSL on one machine are separate execution surfaces; + // only duplicate checkouts within one surface make project grouping ambiguous. + return `${setup.projectId}::${setup.hostId}::wsl:${wslPath.distro.toLowerCase()}` + } + if (isWindowsAbsolutePathLike(setup.path)) { + return `${setup.projectId}::${setup.hostId}::windows-host` + } + return `${setup.projectId}::${setup.hostId}::default` +} + function buildProjectGroupingIndex(model?: ProjectGroupingModel): ProjectGroupingIndex | null { if (!model) { return null @@ -170,9 +192,31 @@ function buildProjectGroupingIndex(model?: ProjectGroupingModel): ProjectGroupin projectGroupingIndexCache.set(model, null) return null } + // Count real user checkouts per host surface (provisioned copies excluded so + // they keep nesting); more than one on a surface makes that project ambiguous. + const checkoutsByProjectSurface = new Map() + for (const setup of projectHostSetups) { + if (!isDistinctUserCheckout(setup)) { + continue + } + const key = getProjectSetupSurfaceKey(setup) + const existing = checkoutsByProjectSurface.get(key) + if (existing) { + existing.count += 1 + } else { + checkoutsByProjectSurface.set(key, { projectId: setup.projectId, count: 1 }) + } + } + const projectIdsRequiringSetupGroups = new Set() + for (const { projectId, count } of checkoutsByProjectSurface.values()) { + if (count > 1) { + projectIdsRequiringSetupGroups.add(projectId) + } + } const index = { projectById: new Map(projects.map((project) => [project.id, project])), - setupByRepoId: new Map(projectHostSetups.map((setup) => [setup.repoId, setup])) + setupByRepoId: new Map(projectHostSetups.map((setup) => [setup.repoId, setup])), + projectIdsRequiringSetupGroups } projectGroupingIndexCache.set(model, index) return index @@ -200,9 +244,21 @@ function getProjectGroupingForRepo( repo } } - // Why: recipe-created runtimes and local worktrees can be separate checkouts - // of the same Git project; the sidebar should follow project identity rather - // than path-scoped setup identity so those workspaces stay in one project. + if ( + projectIndex?.projectIdsRequiringSetupGroups.has(setup.projectId) && + isDistinctUserCheckout(setup) + ) { + // Why: independent user checkouts of one project on the same host surface + // can't be safely merged, so each keeps its own sidebar entry. See #5374. + return { + key: `project:${project.id}::setup:${repoId}`, + label: repo?.displayName ?? setup.displayName, + repo, + projectId: project.id + } + } + // Why: provisioned runtime copies and non-ambiguous checkouts follow project + // identity rather than path-scoped setup identity, so they stay in one project. return { key: `project:${project.id}`, label: project.displayName, From 2153ef94566588d77efcf57fc0df56f2e34cca04 Mon Sep 17 00:00:00 2001 From: e0_7 <134906805+Daily-AC@users.noreply.github.com> Date: Sat, 4 Jul 2026 03:52:55 +0800 Subject: [PATCH 36/48] Accept any hostname (and optional :port) in manual network address entry (#7223) * Allow arbitrary hostnames in manual network address entry parseManualNetworkAddress only accepted an IPv4 address or a Tailscale MagicDNS (*.ts.net) hostname, so users behind a dynamic residential IP who rely on a DDNS domain or self-hosted relay had no way to enter it in the desktop UI short of bypassing validation via DevTools/IPC. The main process already resolves any host: resolvePairingEndpoint and parsePairingAddressOverride in src/main/runtime/runtime-rpc.ts accept an arbitrary hostname and an optional host:port. This change brings the renderer-side validation in line with what pairing already supports: any RFC 1123 hostname (a superset that still covers *.ts.net), optionally suffixed with :port (1-65535). IPv4 validation is unchanged, including still rejecting malformed dotted-numeric input instead of silently treating it as an all-digit hostname. Updates the custom-address dialog copy in NetworkInterfacePicker.tsx to describe the wider grammar. * Polish manual-address takeover: fix bare-numeric guard, sync 5 locales, lint - Require a dot in the IPv4-typo guard so a bare numeric label (`123`) validates as a legal RFC 1123 hostname, matching the code's own comment and the main-process resolver; add coverage. - Update en.json + es/ja/ko/zh placeholder/hint to the broadened grammar (translate() reads en.json before the TSX fallback, so the copy change was previously inert; the other locales described the old ts.net-only rule). - Replace indexOf(...)!==-1 with includes() to satisfy oxlint. Co-authored-by: Orca * Keep validator a strict subset of the backend resolver Review surfaced two ways the renderer could accept an address the main process handles differently: - All-numeric hosts (bare `123` and dotted `256.0.0.1`) are now rejected. The WHATWG URL host parser downstream reinterprets a numeric host as IPv4 (`123` -> `0.0.0.123`), so accepting one would validate an address the pairing resolver silently dials as a different host. - Ports with leading zeros are rejected. `^[0-9]+$` let an arbitrarily long zero-padded string past the range check and inflate the returned address beyond the hostname length cap that the old whole-string check enforced. Co-authored-by: Orca * Reject any numeric final label, not just fully-numeric hosts WHATWG URL host parsing treats a host whose last label is numeric (`foo.123`, `foo.0x1`) as an IPv4 signal, so the pairing resolver would fail to parse it and silently dial a fallback host. Widen the ambiguous-IP guard to a single last-label check that subsumes the earlier all-numeric case, keeping the renderer a strict subset of what the backend resolves correctly. Normal hostnames whose last label merely contains digits (`host2.example.com`) are unaffected. Co-authored-by: Orca --------- Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> Co-authored-by: Orca --- .../mobile/NetworkInterfacePicker.tsx | 13 +- src/renderer/src/i18n/locales/en.json | 4 +- src/renderer/src/i18n/locales/es.json | 4 +- src/renderer/src/i18n/locales/ja.json | 4 +- src/renderer/src/i18n/locales/ko.json | 4 +- src/renderer/src/i18n/locales/zh.json | 4 +- src/shared/network/manual-address.test.ts | 115 +++++++++++++++++- src/shared/network/manual-address.ts | 71 +++++++++-- 8 files changed, 187 insertions(+), 32 deletions(-) diff --git a/src/renderer/src/components/mobile/NetworkInterfacePicker.tsx b/src/renderer/src/components/mobile/NetworkInterfacePicker.tsx index a668ccbc555..af45e066a3c 100644 --- a/src/renderer/src/components/mobile/NetworkInterfacePicker.tsx +++ b/src/renderer/src/components/mobile/NetworkInterfacePicker.tsx @@ -6,10 +6,11 @@ import type { MobileNetworkInterface } from '../settings/mobile-network-interfac // Why: MobileHero (mobile pairing screen) and MobileNetworkInterfaceSection // (Settings → Mobile) both need the same network selector. This wraps the -// generic AddressPicker with the mobile grammar (IPv4 / Tailscale *.ts.net) -// and copy. Discovered interfaces come from the OS; "Add custom address…" -// opens a dialog for an address the OS didn't surface — the only way to pair -// across networks. +// generic AddressPicker with the mobile grammar (IPv4, any RFC 1123 +// hostname — including Tailscale *.ts.net and DDNS domains — optionally +// with :port) and copy. Discovered interfaces come from the OS; "Add custom +// address…" opens a dialog for an address the OS didn't surface — the only +// way to pair across networks. export type NetworkInterfacePickerProps = { networkInterfaces: readonly MobileNetworkInterface[] @@ -81,11 +82,11 @@ export function NetworkInterfacePicker({ inputLabel: translate('auto.components.mobile.CustomNetworkAddressDialog.label', 'Address'), placeholder: translate( 'auto.components.mobile.CustomNetworkAddressDialog.placeholder', - 'my-mac.ts.net or 192.168.1.50' + 'my-mac.ts.net, home.example.com, or 192.168.1.50' ), hint: translate( 'auto.components.mobile.CustomNetworkAddressDialog.hint', - 'Enter an IP address or a Tailscale hostname (ends in .ts.net).' + 'Enter an IP address or a hostname — a Tailscale MagicDNS name, a DDNS domain, or a LAN hostname — optionally with :port.' ), cancel: translate('auto.components.mobile.CustomNetworkAddressDialog.cancel', 'Cancel'), confirm: translate('auto.components.mobile.CustomNetworkAddressDialog.use', 'Use address') diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 5611d8d73ce..32395a56253 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -10216,8 +10216,8 @@ "title": "Custom network address", "description": "Advertise an address your phone can reach when it is not on the same Wi-Fi — for example a Tailscale hostname or a static IP.", "label": "Address", - "placeholder": "my-mac.ts.net or 192.168.1.50", - "hint": "Enter an IP address or a Tailscale hostname (ends in .ts.net).", + "placeholder": "my-mac.ts.net, home.example.com, or 192.168.1.50", + "hint": "Enter an IP address or a hostname — a Tailscale MagicDNS name, a DDNS domain, or a LAN hostname — optionally with :port.", "cancel": "Cancel", "use": "Use address" }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index ebca92c506e..ea305d3abb7 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -10216,8 +10216,8 @@ "title": "Dirección de red personalizada", "description": "Anuncia una dirección que tu teléfono pueda alcanzar cuando no esté en la misma red Wi-Fi: por ejemplo, un nombre de host de Tailscale o una IP estática.", "label": "Dirección", - "placeholder": "my-mac.ts.net o 192.168.1.50", - "hint": "Introduce una dirección IP o un nombre de host de Tailscale (termina en .ts.net).", + "placeholder": "my-mac.ts.net, home.example.com o 192.168.1.50", + "hint": "Introduce una dirección IP o un nombre de host (un nombre MagicDNS de Tailscale, un dominio DDNS o un nombre de host de LAN), opcionalmente con :puerto.", "cancel": "Cancelar", "use": "Usar dirección" }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index c02e6f709d8..fc9795ca8c0 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -10216,8 +10216,8 @@ "title": "カスタムネットワークアドレス", "description": "同じ Wi-Fi に接続していないときにスマートフォンから到達できるアドレスを指定します。たとえば Tailscale のホスト名や静的 IP です。", "label": "アドレス", - "placeholder": "my-mac.ts.net または 192.168.1.50", - "hint": "IP アドレスまたは Tailscale のホスト名(.ts.net で終わる)を入力してください。", + "placeholder": "my-mac.ts.net、home.example.com、または 192.168.1.50", + "hint": "IP アドレスまたはホスト名(Tailscale の MagicDNS 名、DDNS ドメイン、LAN のホスト名など)を入力してください。必要に応じて :ポート を付けられます。", "cancel": "キャンセル", "use": "アドレスを使用" }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index ce2b4283fcc..5ab1eade73a 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -10216,8 +10216,8 @@ "title": "사용자 지정 네트워크 주소", "description": "같은 Wi-Fi에 있지 않을 때 휴대폰이 연결할 수 있는 주소를 알립니다. 예: Tailscale 호스트 이름 또는 고정 IP.", "label": "주소", - "placeholder": "my-mac.ts.net 또는 192.168.1.50", - "hint": "IP 주소 또는 Tailscale 호스트 이름(.ts.net으로 끝남)을 입력하세요.", + "placeholder": "my-mac.ts.net, home.example.com 또는 192.168.1.50", + "hint": "IP 주소 또는 호스트 이름(Tailscale MagicDNS 이름, DDNS 도메인 또는 LAN 호스트 이름)을 입력하세요. 필요하면 :포트를 붙일 수 있습니다.", "cancel": "취소", "use": "주소 사용" }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 719e1e9856b..5f9fff50376 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -10216,8 +10216,8 @@ "title": "自定义网络地址", "description": "指定当手机不在同一 Wi-Fi 时可以访问的地址,例如 Tailscale 主机名或静态 IP。", "label": "地址", - "placeholder": "my-mac.ts.net 或 192.168.1.50", - "hint": "请输入 IP 地址或 Tailscale 主机名(以 .ts.net 结尾)。", + "placeholder": "my-mac.ts.net、home.example.com 或 192.168.1.50", + "hint": "请输入 IP 地址或主机名(Tailscale MagicDNS 名称、DDNS 域名或 LAN 主机名),可选择附加 :端口。", "cancel": "取消", "use": "使用地址" }, diff --git a/src/shared/network/manual-address.test.ts b/src/shared/network/manual-address.test.ts index 3d1b48b43b2..fe6c1950f68 100644 --- a/src/shared/network/manual-address.test.ts +++ b/src/shared/network/manual-address.test.ts @@ -19,11 +19,18 @@ describe('parseManualNetworkAddress', () => { expect(parseManualNetworkAddress('255.255.255.255').ok).toBe(true) }) + it('accepts an IPv4 address with a port suffix', () => { + expect(parseManualNetworkAddress('192.168.1.24:8080')).toEqual({ + ok: true, + address: '192.168.1.24:8080' + }) + }) + it('rejects malformed IPv4', () => { for (const bad of ['', ' ', '1.2.3', '1.2.3.4.5', '256.0.0.1']) { expect(parseManualNetworkAddress(bad)).toEqual({ ok: false, - error: 'Enter an IPv4 address or Tailscale MagicDNS hostname' + error: 'Enter an IPv4 address or hostname, optionally with a :port suffix' }) } }) @@ -31,7 +38,7 @@ describe('parseManualNetworkAddress', () => { it('rejects leading zeros in octets', () => { expect(parseManualNetworkAddress('01.02.03.04')).toEqual({ ok: false, - error: 'Enter an IPv4 address or Tailscale MagicDNS hostname' + error: 'Enter an IPv4 address or hostname, optionally with a :port suffix' }) expect(parseManualNetworkAddress('0.0.0.0').ok).toBe(true) }) @@ -57,11 +64,107 @@ describe('parseManualNetworkAddress', () => { expect(parseManualNetworkAddress('MY-MAC.TS.NET').ok).toBe(true) }) - it('rejects non-Tailscale hostnames', () => { - for (const bad of ['my-mac', 'my-mac.ts.com', '-foo.ts.net', 'my-mac.com']) { + it('rejects a MagicDNS-shaped hostname with a malformed label', () => { + // Leading/trailing hyphens are invalid in any RFC 1123 label, + // regardless of the `.ts.net` suffix. + expect(parseManualNetworkAddress('-foo.ts.net').ok).toBe(false) + }) + }) + + describe('arbitrary hostnames (DDNS / self-hosted domains)', () => { + it('accepts a bare single-label hostname', () => { + expect(parseManualNetworkAddress('my-nas')).toEqual({ + ok: true, + address: 'my-nas' + }) + }) + + it('accepts a multi-label domain, e.g. a DDNS hostname', () => { + expect(parseManualNetworkAddress('home.example.com')).toEqual({ + ok: true, + address: 'home.example.com' + }) + expect(parseManualNetworkAddress('my-house.ddns.net').ok).toBe(true) + }) + + it('accepts a hostname with a port suffix', () => { + expect(parseManualNetworkAddress('home.example.com:8443')).toEqual({ + ok: true, + address: 'home.example.com:8443' + }) + }) + + it('is case-insensitive', () => { + expect(parseManualNetworkAddress('HOME.EXAMPLE.COM').ok).toBe(true) + }) + + it('rejects labels with leading or trailing hyphens', () => { + for (const bad of ['-foo.example.com', 'foo-.example.com', 'foo..com']) { expect(parseManualNetworkAddress(bad).ok).toBe(false) } }) + + it('rejects hostnames with characters outside the RFC 1123 grammar', () => { + for (const bad of ['my_host.example.com', 'my host.example.com', 'example.com/path']) { + expect(parseManualNetworkAddress(bad).ok).toBe(false) + } + }) + + it('rejects a host whose last label is numeric as an ambiguous IPv4', () => { + // The WHATWG URL parser downstream treats "ends in a number" as an IPv4 + // signal and would reinterpret or fail to resolve these (`123` -> + // `0.0.0.123`; `foo.123` -> no-op fallback), so accepting one here would + // validate an address the main process dials differently. + const bad = [ + '123', + '123:8080', + '1.2.3', + '1.2.3.4.5', + '256.0.0.1', + '999.999.999.999', + 'foo.123', + 'foo.123:8080', + 'foo.0x1' + ] + for (const value of bad) { + expect(parseManualNetworkAddress(value).ok).toBe(false) + } + }) + + it('still accepts a hostname whose last label merely contains digits', () => { + // Only a fully-numeric (or hex) final label is ambiguous; a label like + // `com` or `nas1` is a normal hostname. + expect(parseManualNetworkAddress('host2.example.com').ok).toBe(true) + expect(parseManualNetworkAddress('my-nas1').ok).toBe(true) + }) + }) + + describe('port suffix', () => { + it('accepts boundary port values', () => { + expect(parseManualNetworkAddress('example.com:1').ok).toBe(true) + expect(parseManualNetworkAddress('example.com:65535').ok).toBe(true) + }) + + it('rejects out-of-range ports', () => { + expect(parseManualNetworkAddress('example.com:0').ok).toBe(false) + expect(parseManualNetworkAddress('example.com:65536').ok).toBe(false) + expect(parseManualNetworkAddress('example.com:99999').ok).toBe(false) + }) + + it('rejects a non-numeric or empty port', () => { + expect(parseManualNetworkAddress('example.com:abc').ok).toBe(false) + expect(parseManualNetworkAddress('example.com:').ok).toBe(false) + }) + + it('rejects a leading-zero (non-canonical, unbounded-length) port', () => { + expect(parseManualNetworkAddress('example.com:0080').ok).toBe(false) + expect(parseManualNetworkAddress(`example.com:${'0'.repeat(1000)}8080`).ok).toBe(false) + }) + + it('rejects addresses with more than one colon (e.g. IPv6-shaped input)', () => { + expect(parseManualNetworkAddress('example.com:80:90').ok).toBe(false) + expect(parseManualNetworkAddress('::1').ok).toBe(false) + }) }) describe('length and whitespace', () => { @@ -77,5 +180,9 @@ describe('parseManualNetworkAddress', () => { address: '192.168.1.24' }) }) + + it('rejects whitespace inside the address', () => { + expect(parseManualNetworkAddress('my host.example.com').ok).toBe(false) + }) }) }) diff --git a/src/shared/network/manual-address.ts b/src/shared/network/manual-address.ts index 9f6a8421f78..81a8a28a26b 100644 --- a/src/shared/network/manual-address.ts +++ b/src/shared/network/manual-address.ts @@ -1,36 +1,83 @@ // Why: pure shared helper so the same validation runs in renderer // today and in any future CLI/main-process caller without duplicating -// the IPv4 + Tailscale MagicDNS grammar. +// the IPv4 + hostname + optional-port grammar. const IPV4_OCTET = '(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])' const IPV4 = `(?:${IPV4_OCTET}\\.){3}${IPV4_OCTET}` -// MagicDNS hostname: lowercase letters/digits/hyphens, dot-separated, ending in .ts.net. -// Labels may not start or end with a hyphen; max 63 chars per label (DNS limit). -const MAGICDNS_LABEL = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?' -const MAGICDNS = `(?:${MAGICDNS_LABEL}\\.)+ts\\.net` +const IPV4_REGEX = new RegExp(`^${IPV4}$`) + +// RFC 1123 hostname label: letters/digits/hyphens, 1-63 chars, may not +// start or end with a hyphen. This covers plain LAN hostnames, DDNS domains +// (e.g. `home.example.com`), and Tailscale MagicDNS names (`*.ts.net`) as a +// special case of the same grammar — no separate MagicDNS-only pattern needed. +const HOSTNAME_LABEL = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?' +const HOSTNAME = `(?:${HOSTNAME_LABEL}\\.)*${HOSTNAME_LABEL}` +const HOSTNAME_REGEX = new RegExp(`^${HOSTNAME}$`, 'i') const HOSTNAME_MAX_LENGTH = 253 -const ERROR_MESSAGE = 'Enter an IPv4 address or Tailscale MagicDNS hostname' +const MIN_PORT = 1 +const MAX_PORT = 65535 +const ERROR_MESSAGE = 'Enter an IPv4 address or hostname, optionally with a :port suffix' export type ParseManualAddressResult = { ok: true; address: string } | { ok: false; error: string } export function parseManualNetworkAddress(input: string): ParseManualAddressResult { const trimmed = input.trim() - if (trimmed === '' || trimmed.length > HOSTNAME_MAX_LENGTH) { + if (trimmed === '') { return { ok: false, error: ERROR_MESSAGE } } if (/\s/.test(trimmed)) { return { ok: false, error: ERROR_MESSAGE } } - const ipv4Regex = new RegExp(`^${IPV4}$`) - if (ipv4Regex.test(trimmed)) { - return { ok: true, address: trimmed } + const { host, port } = splitHostPort(trimmed) + if (host === '' || host.length > HOSTNAME_MAX_LENGTH) { + return { ok: false, error: ERROR_MESSAGE } + } + if (port !== null && !isValidPort(port)) { + return { ok: false, error: ERROR_MESSAGE } } - const magicRegex = new RegExp(`^(?:${MAGICDNS})$`, 'i') - if (magicRegex.test(trimmed)) { + if (IPV4_REGEX.test(host)) { + return { ok: true, address: trimmed } + } + // Why: reject a host whose last label is numeric — a bare `123`, a dotted + // `256.0.0.1`, or a hostname with a numeric final label (`foo.123`, `foo.0x1`). + // The WHATWG URL host parser that `resolvePairingEndpoint` feeds this into + // treats "ends in a number" as an IPv4 signal and tries to parse the whole + // host as IPv4. A real IPv4 was already accepted above, so anything reaching + // here would fail that parse and the main process would silently dial a + // different host — so treat these as mistyped IPs, not hostnames. + const lastLabel = host.split('.').at(-1) ?? '' + if (/^[0-9]+$/.test(lastLabel) || /^0x[0-9a-f]*$/i.test(lastLabel)) { + return { ok: false, error: ERROR_MESSAGE } + } + if (HOSTNAME_REGEX.test(host)) { return { ok: true, address: trimmed } } return { ok: false, error: ERROR_MESSAGE } } + +// Why: mirrors `parsePairingAddressOverride` in src/main/runtime/runtime-rpc.ts +// so the UI only accepts what the main process's pairing endpoint resolution +// can already handle. IPv6 stays out of scope (same as that function), so a +// second colon is left in `host` and fails the grammar checks below instead +// of being misparsed as a port. +function splitHostPort(value: string): { host: string; port: string | null } { + const firstColon = value.indexOf(':') + if (firstColon === -1 || value.includes(':', firstColon + 1)) { + return { host: value, port: null } + } + return { host: value.slice(0, firstColon), port: value.slice(firstColon + 1) } +} + +function isValidPort(port: string): boolean { + // Reject leading zeros: a canonical port has none, and allowing them lets an + // arbitrarily long zero-padded string (`00…08080`) slip past the range check + // and inflate the returned address past the hostname length cap. + if (!/^[1-9][0-9]*$/.test(port)) { + return false + } + const value = Number(port) + return value >= MIN_PORT && value <= MAX_PORT +} From 9aeaf0728f6e6173b969e473c63a785b37dda182 Mon Sep 17 00:00:00 2001 From: yukei_lee Date: Sat, 4 Jul 2026 03:56:38 +0800 Subject: [PATCH 37/48] Fix git worktree list fallback for older Git (#7235) * fix git worktree list fallback for localized errors * Polish worktree -z fallback: clarify comment, add localized + negative tests Co-authored-by: Orca * Extend -z fallback to relay/SSH worktree path; tighten comments; add relay tests Co-authored-by: Orca * Isolate regex-fallback path in relay test Co-authored-by: Orca --------- Co-authored-by: yuqili03 Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> Co-authored-by: Orca --- src/main/git/worktree.test.ts | 98 +++++++++++++++++++++++++++++ src/main/git/worktree.ts | 10 ++- src/relay/git-handler-utils.test.ts | 30 +++++++++ src/relay/git-handler-utils.ts | 16 ++++- 4 files changed, 148 insertions(+), 6 deletions(-) diff --git a/src/main/git/worktree.test.ts b/src/main/git/worktree.test.ts index 44679c14ed3..69ce1ed46c5 100644 --- a/src/main/git/worktree.test.ts +++ b/src/main/git/worktree.test.ts @@ -237,6 +237,12 @@ bare }) describe('listWorktreeGraph', () => { + beforeEach(() => { + gitExecFileAsyncMock.mockReset() + gitExecFileSyncMock.mockReset() + translateWslOutputPathsMock.mockClear() + }) + it('returns the worktree graph without sparse-checkout annotation probes', async () => { gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: `worktree /repo @@ -272,6 +278,98 @@ branch refs/heads/feature/test }) }) + it('falls back when older Git rejects worktree list -z with usage exit code', async () => { + gitExecFileAsyncMock + .mockRejectedValueOnce( + Object.assign(new Error('git usage error'), { + code: 129, + stderr: 'usage: git worktree list []\n' + }) + ) + .mockResolvedValueOnce({ + stdout: `worktree /repo +HEAD abc123 +branch refs/heads/main + +worktree /repo-main-2 +HEAD def456 +branch refs/heads/main-2 +` + }) + + await expect(listWorktreeGraph('/repo')).resolves.toEqual([ + { + path: '/repo', + head: 'abc123', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + }, + { + path: '/repo-main-2', + head: 'def456', + branch: 'refs/heads/main-2', + isBare: false, + isMainWorktree: false + } + ]) + + expect(gitExecFileAsyncMock.mock.calls).toEqual([ + [['worktree', 'list', '--porcelain', '-z'], { cwd: '/repo' }], + [['worktree', 'list', '--porcelain'], { cwd: '/repo' }] + ]) + }) + + it('falls back on a localized (non-English) older-Git usage error via exit code 129', async () => { + // Git under a non-English locale translates the usage text, so stderr + // matching alone misses it; the 129 exit code must still trigger fallback. + gitExecFileAsyncMock + .mockRejectedValueOnce( + Object.assign(new Error('git worktree list --porcelain -z'), { + code: 129, + stderr: 'Fehler: Unbekannter Schalter »z«\nAufruf: git worktree list []\n' + }) + ) + .mockResolvedValueOnce({ + stdout: `worktree /repo +HEAD abc123 +branch refs/heads/main +` + }) + + await expect(listWorktreeGraph('/repo')).resolves.toEqual([ + { + path: '/repo', + head: 'abc123', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + + expect(gitExecFileAsyncMock.mock.calls).toEqual([ + [['worktree', 'list', '--porcelain', '-z'], { cwd: '/repo' }], + [['worktree', 'list', '--porcelain'], { cwd: '/repo' }] + ]) + }) + + it('does not retry without -z for non-usage Git failures', async () => { + // A fatal error (exit 128) is not an unsupported-flag signal, so the -z + // command must not be silently re-run without it. + gitExecFileAsyncMock.mockRejectedValueOnce( + Object.assign(new Error('git worktree list --porcelain -z'), { + code: 128, + stderr: 'fatal: unable to read tree\n' + }) + ) + + await expect(listWorktreeGraph('/repo')).resolves.toEqual([]) + + expect(gitExecFileAsyncMock.mock.calls).toEqual([ + [['worktree', 'list', '--porcelain', '-z'], { cwd: '/repo' }] + ]) + }) + it('returns an empty graph for paths Git reports as non-repositories', async () => { gitExecFileAsyncMock.mockRejectedValueOnce(new Error('fatal: not a git repository')) diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts index ed830d06d70..1dce3fff2ae 100644 --- a/src/main/git/worktree.ts +++ b/src/main/git/worktree.ts @@ -104,9 +104,13 @@ function isNotGitRepositoryError(error: unknown): boolean { } function isUnsupportedWorktreeListZError(error: unknown): boolean { - return /(?:unknown|invalid) (?:switch|option).*`?-z'?|(?:unknown|invalid) (?:switch|option).*`?z'?/i.test( - getErrorText(error) - ) + // `-z` is this command's only flag older Git (<2.36) lacks, so its usage + // exit 129 signals the rejection in any locale; stderr match is a fallback. + if (getErrorCode(error) === '129') { + return true + } + + return /(?:unknown|invalid|unrecognized) (?:switch|option).*`?-?z'?/i.test(getErrorText(error)) } function isUnsupportedRevParsePathFormatError(error: unknown): boolean { diff --git a/src/relay/git-handler-utils.test.ts b/src/relay/git-handler-utils.test.ts index 9dca8777834..03d0fe14aed 100644 --- a/src/relay/git-handler-utils.test.ts +++ b/src/relay/git-handler-utils.test.ts @@ -1,5 +1,35 @@ import { describe, expect, it } from 'vitest' import { parseStatusOutput } from './git-status-output-parser' +import { isUnsupportedWorktreeListZError } from './git-handler-utils' + +describe('isUnsupportedWorktreeListZError', () => { + it('detects an unknown-switch usage error from stderr when the exit code is absent', () => { + // Isolates the regex fallback: no numeric code, so only the stderr text + // (a runner that dropped the exit code) can classify the rejection. + const error = Object.assign(new Error('worktree list -z'), { + stderr: "error: unknown switch `z'\nusage: git worktree list []\n" + }) + expect(isUnsupportedWorktreeListZError(error)).toBe(true) + }) + + it('detects a localized (non-English) usage error via exit code 129', () => { + // The SSH remote may run under a non-English locale where the stderr text is + // translated; the numeric exit code must still classify the -z rejection. + const error = Object.assign(new Error('worktree list -z'), { + code: 129, + stderr: 'Fehler: Unbekannter Schalter »z«\nAufruf: git worktree list []\n' + }) + expect(isUnsupportedWorktreeListZError(error)).toBe(true) + }) + + it('does not classify a fatal (exit 128) error as an unsupported -z rejection', () => { + const error = Object.assign(new Error('fatal'), { + code: 128, + stderr: 'fatal: unable to read tree\n' + }) + expect(isUnsupportedWorktreeListZError(error)).toBe(false) + }) +}) describe('parseStatusOutput', () => { it('parses upstream ahead/behind from porcelain v2 branch headers', () => { diff --git a/src/relay/git-handler-utils.ts b/src/relay/git-handler-utils.ts index 5ddd15fd896..f0a395da27b 100644 --- a/src/relay/git-handler-utils.ts +++ b/src/relay/git-handler-utils.ts @@ -147,10 +147,20 @@ function getErrorText(error: unknown): string { return String(error) } +function getErrorCode(error: unknown): string | undefined { + return typeof error === 'object' && error !== null && 'code' in error + ? String((error as { code?: unknown }).code) + : undefined +} + export function isUnsupportedWorktreeListZError(error: unknown): boolean { - return /(?:unknown|invalid) (?:switch|option).*`?-z'?|(?:unknown|invalid) (?:switch|option).*`?z'?/i.test( - getErrorText(error) - ) + // `-z` is this command's only flag older Git (<2.36) lacks, so its usage exit + // 129 signals the rejection in any locale; key for SSH remotes on old Git. + if (getErrorCode(error) === '129') { + return true + } + + return /(?:unknown|invalid|unrecognized) (?:switch|option).*`?-?z'?/i.test(getErrorText(error)) } export function parseWorktreeList( From 273fee79fa3b37e1938e5f3a0ec1ffcad0b61184 Mon Sep 17 00:00:00 2001 From: Avichal Dwivedi <150160561+AvichalDwivedi2205@users.noreply.github.com> Date: Sat, 4 Jul 2026 01:36:43 +0530 Subject: [PATCH 38/48] Fix CDP Page.printToPDF support (#7044) * Fix CDP printToPDF proxy * Harden CDP PDF stream handling * Fix PDF margin units * refactor(cdp): extract printToPDF param mapping + stream store into cdp-print-to-pdf Move the CDP Page.printToPDF option translation and the ReturnAsStream handle store out of cdp-ws-proxy into a focused cdp-print-to-pdf module, mirroring the existing cdp-screenshot sibling. Keeps the over-budget proxy from growing further and gives the PDF logic direct unit coverage. No behavior change: all existing proxy tests pass unmodified. Co-authored-by: Orca * fix(cdp): drop print result if client disconnected during printToPDF A ReturnAsStream print that resolved after the requesting client disconnected (or was replaced) registered its buffer in pdfStreams after the disconnect's clear() already ran, orphaning a potentially large PDF until the 5-minute TTL. Re-check isActiveClient after the await, mirroring navigateWithLifecycle/reloadWithLifecycle. Co-authored-by: Orca --------- Co-authored-by: Neil Co-authored-by: Orca --- src/main/browser/cdp-print-to-pdf.test.ts | 172 +++++++++++++++++ src/main/browser/cdp-print-to-pdf.ts | 177 ++++++++++++++++++ src/main/browser/cdp-ws-proxy.test.ts | 213 ++++++++++++++++++++++ src/main/browser/cdp-ws-proxy.ts | 79 ++++++++ 4 files changed, 641 insertions(+) create mode 100644 src/main/browser/cdp-print-to-pdf.test.ts create mode 100644 src/main/browser/cdp-print-to-pdf.ts diff --git a/src/main/browser/cdp-print-to-pdf.test.ts b/src/main/browser/cdp-print-to-pdf.test.ts new file mode 100644 index 00000000000..e6f2163b5dd --- /dev/null +++ b/src/main/browser/cdp-print-to-pdf.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { buildPrintToPdfOptions, CdpPdfStreamStore } from './cdp-print-to-pdf' + +const DEFAULT_MARGIN_INCHES = 1 / 2.54 +const TTL_MS = 5 * 60 * 1000 + +describe('buildPrintToPdfOptions', () => { + it('returns empty options for empty params', () => { + expect(buildPrintToPdfOptions({})).toEqual({}) + }) + + it('maps every well-formed field', () => { + expect( + buildPrintToPdfOptions({ + landscape: true, + displayHeaderFooter: true, + printBackground: true, + preferCSSPageSize: true, + generateTaggedPDF: true, + generateDocumentOutline: false, + scale: 1.5, + paperWidth: 8.5, + paperHeight: 11, + marginTop: 0.25, + marginBottom: 0.5, + marginLeft: 0.75, + marginRight: 1, + pageRanges: '1-2', + headerTemplate: '', + footerTemplate: '' + }) + ).toEqual({ + landscape: true, + displayHeaderFooter: true, + printBackground: true, + preferCSSPageSize: true, + generateTaggedPDF: true, + generateDocumentOutline: false, + scale: 1.5, + pageSize: { width: 8.5, height: 11 }, + margins: { marginType: 'custom', top: 0.25, bottom: 0.5, left: 0.75, right: 1 }, + pageRanges: '1-2', + headerTemplate: '', + footerTemplate: '' + }) + }) + + it('defaults omitted margin sides to 1cm when any side is set', () => { + expect(buildPrintToPdfOptions({ marginTop: 0.25 }).margins).toEqual({ + marginType: 'custom', + top: 0.25, + bottom: DEFAULT_MARGIN_INCHES, + left: DEFAULT_MARGIN_INCHES, + right: DEFAULT_MARGIN_INCHES + }) + }) + + it('drops non-finite and out-of-range numeric options', () => { + const options = buildPrintToPdfOptions({ + scale: 0, + paperWidth: Number.POSITIVE_INFINITY, + paperHeight: 11, + marginTop: Number.NaN + }) + expect(options.scale).toBeUndefined() + expect(options.pageSize).toBeUndefined() + expect(options.margins).toBeUndefined() + }) + + it('requires both paper dimensions for pageSize', () => { + expect(buildPrintToPdfOptions({ paperWidth: 8.5 }).pageSize).toBeUndefined() + }) + + it('ignores wrong-typed options', () => { + expect(buildPrintToPdfOptions({ landscape: 'yes', pageRanges: 7 })).toEqual({}) + }) +}) + +describe('CdpPdfStreamStore', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('claims only its own handles', () => { + const store = new CdpPdfStreamStore() + const handle = store.create(Buffer.from('pdf')) + + expect(store.ownsHandle({ handle })).toBe(true) + expect(handle).toMatch(/^orca-pdf-[\da-f-]{36}-\d+$/) + expect(store.ownsHandle({ handle: 'trace-stream' })).toBe(false) + expect(store.ownsHandle({ handle: 'orca-pdf-forged-1' })).toBe(false) + expect(store.ownsHandle({})).toBe(false) + }) + + it('mints distinct handles per stream', () => { + const store = new CdpPdfStreamStore() + expect(store.create(Buffer.from('a'))).not.toBe(store.create(Buffer.from('b'))) + }) + + it('reads sequential chunks and reports eof', () => { + const store = new CdpPdfStreamStore() + const handle = store.create(Buffer.from('abcdef')) + + expect(store.read({ handle, size: 2 })).toEqual({ + data: Buffer.from('ab').toString('base64'), + eof: false + }) + expect(store.read({ handle })).toEqual({ + data: Buffer.from('cdef').toString('base64'), + eof: true + }) + }) + + it('honors an explicit read offset', () => { + const store = new CdpPdfStreamStore() + const handle = store.create(Buffer.from('abcdef')) + + expect(store.read({ handle, offset: 4 })).toEqual({ + data: Buffer.from('ef').toString('base64'), + eof: true + }) + }) + + it('returns an empty eof chunk when reading past the end', () => { + const store = new CdpPdfStreamStore() + const handle = store.create(Buffer.from('abc')) + + expect(store.read({ handle, offset: 99 })).toEqual({ data: '', eof: true }) + }) + + it('returns null for unknown handles', () => { + const store = new CdpPdfStreamStore() + expect(store.read({ handle: 'nope' })).toBeNull() + expect(store.read({})).toBeNull() + }) + + it('drops a stream on close', () => { + const store = new CdpPdfStreamStore() + const handle = store.create(Buffer.from('abc')) + + store.close({ handle }) + expect(store.read({ handle })).toBeNull() + }) + + it('drops all streams on clear', () => { + const store = new CdpPdfStreamStore() + const handle = store.create(Buffer.from('abc')) + + store.clear() + expect(store.read({ handle })).toBeNull() + }) + + it('evicts an abandoned stream after the TTL', () => { + vi.useFakeTimers() + const store = new CdpPdfStreamStore() + const handle = store.create(Buffer.from('abc')) + + vi.advanceTimersByTime(TTL_MS + 1) + expect(store.read({ handle })).toBeNull() + }) + + it('refreshes the TTL on each read', () => { + vi.useFakeTimers() + const store = new CdpPdfStreamStore() + const handle = store.create(Buffer.from('abcdef')) + + vi.advanceTimersByTime(TTL_MS - 1) + expect(store.read({ handle, size: 1 })).not.toBeNull() + vi.advanceTimersByTime(TTL_MS - 1) + expect(store.read({ handle, size: 1 })).not.toBeNull() + }) +}) diff --git a/src/main/browser/cdp-print-to-pdf.ts b/src/main/browser/cdp-print-to-pdf.ts new file mode 100644 index 00000000000..643013377d8 --- /dev/null +++ b/src/main/browser/cdp-print-to-pdf.ts @@ -0,0 +1,177 @@ +import { randomUUID } from 'node:crypto' +import type { PrintToPDFOptions } from 'electron' + +const PDF_DEFAULT_MARGIN_INCHES = 1 / 2.54 +const PDF_STREAM_CHUNK_BYTES = 1024 * 1024 +const PDF_STREAM_HANDLE_PREFIX = 'orca-pdf-' +const PDF_STREAM_TTL_MS = 5 * 60 * 1000 + +function finiteNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +/** + * Translate CDP `Page.printToPDF` params into Electron `printToPDF` options. + * Only well-formed values are forwarded so a malformed param can never smuggle + * NaN/Infinity into Electron; omitted margin sides default to CDP's 1cm. + */ +export function buildPrintToPdfOptions(params: Record): PrintToPDFOptions { + const options: PrintToPDFOptions = {} + + if (typeof params.landscape === 'boolean') { + options.landscape = params.landscape + } + if (typeof params.displayHeaderFooter === 'boolean') { + options.displayHeaderFooter = params.displayHeaderFooter + } + if (typeof params.printBackground === 'boolean') { + options.printBackground = params.printBackground + } + if (typeof params.preferCSSPageSize === 'boolean') { + options.preferCSSPageSize = params.preferCSSPageSize + } + if (typeof params.generateTaggedPDF === 'boolean') { + options.generateTaggedPDF = params.generateTaggedPDF + } + if (typeof params.generateDocumentOutline === 'boolean') { + options.generateDocumentOutline = params.generateDocumentOutline + } + + const scale = finiteNumber(params.scale) + if (scale !== null && scale > 0) { + options.scale = scale + } + + const paperWidth = finiteNumber(params.paperWidth) + const paperHeight = finiteNumber(params.paperHeight) + if (paperWidth !== null && paperHeight !== null && paperWidth > 0 && paperHeight > 0) { + options.pageSize = { width: paperWidth, height: paperHeight } + } + + const marginTop = finiteNumber(params.marginTop) + const marginBottom = finiteNumber(params.marginBottom) + const marginLeft = finiteNumber(params.marginLeft) + const marginRight = finiteNumber(params.marginRight) + if ([marginTop, marginBottom, marginLeft, marginRight].some((margin) => margin !== null)) { + // CDP and Electron printToPDF both use inches; omitted CDP sides default to 1cm. + options.margins = { + marginType: 'custom', + top: marginTop ?? PDF_DEFAULT_MARGIN_INCHES, + bottom: marginBottom ?? PDF_DEFAULT_MARGIN_INCHES, + left: marginLeft ?? PDF_DEFAULT_MARGIN_INCHES, + right: marginRight ?? PDF_DEFAULT_MARGIN_INCHES + } + } + + if (typeof params.pageRanges === 'string') { + options.pageRanges = params.pageRanges + } + if (typeof params.headerTemplate === 'string') { + options.headerTemplate = params.headerTemplate + } + if (typeof params.footerTemplate === 'string') { + options.footerTemplate = params.footerTemplate + } + + return options +} + +type PdfStream = { + data: Buffer + offset: number + cleanupTimer: ReturnType +} + +export type PdfStreamChunk = { + data: string + eof: boolean +} + +/** + * Holds the PDF buffers produced for CDP `transferMode: "ReturnAsStream"` and + * serves them back through `IO.read` / `IO.close`, the path Playwright's + * `page.pdf()` uses. Handles carry a per-instance random prefix so a CDP client + * cannot forge one or collide with a real Chromium IO stream, and each buffer is + * evicted after a TTL so an abandoned stream can never leak memory. + */ +export class CdpPdfStreamStore { + private readonly streams = new Map() + private readonly handlePrefix = `${PDF_STREAM_HANDLE_PREFIX}${randomUUID()}-` + private nextId = 0 + + /** True when `params.handle` names one of this store's streams. */ + ownsHandle(params: Record): boolean { + return typeof params.handle === 'string' && params.handle.startsWith(this.handlePrefix) + } + + create(data: Buffer): string { + const handle = `${this.handlePrefix}${++this.nextId}` + this.streams.set(handle, { + data, + offset: 0, + cleanupTimer: this.scheduleCleanup(handle) + }) + return handle + } + + /** Read the next chunk, or `null` if the handle is unknown/expired. */ + read(params: Record): PdfStreamChunk | null { + const handle = typeof params.handle === 'string' ? params.handle : '' + const stream = this.streams.get(handle) + if (!stream) { + return null + } + this.refreshCleanup(handle, stream) + + const offset = finiteNumber(params.offset) + if (offset !== null) { + stream.offset = Math.max(0, Math.floor(offset)) + } + const requestedSize = finiteNumber(params.size) + const size = + requestedSize !== null && requestedSize > 0 + ? Math.floor(requestedSize) + : PDF_STREAM_CHUNK_BYTES + const start = Math.min(stream.offset, stream.data.length) + const end = Math.min(start + size, stream.data.length) + const chunk = stream.data.subarray(start, end) + stream.offset = end + + return { data: chunk.toString('base64'), eof: end >= stream.data.length } + } + + close(params: Record): void { + const handle = typeof params.handle === 'string' ? params.handle : '' + this.delete(handle) + } + + clear(): void { + for (const stream of this.streams.values()) { + clearTimeout(stream.cleanupTimer) + } + this.streams.clear() + } + + private scheduleCleanup(handle: string): ReturnType { + const cleanupTimer = setTimeout(() => { + this.delete(handle) + }, PDF_STREAM_TTL_MS) + const maybeNodeTimer = cleanupTimer as { unref?: () => void } + maybeNodeTimer.unref?.() + return cleanupTimer + } + + private refreshCleanup(handle: string, stream: PdfStream): void { + clearTimeout(stream.cleanupTimer) + stream.cleanupTimer = this.scheduleCleanup(handle) + } + + private delete(handle: string): void { + const stream = this.streams.get(handle) + if (!stream) { + return + } + clearTimeout(stream.cleanupTimer) + this.streams.delete(handle) + } +} diff --git a/src/main/browser/cdp-ws-proxy.test.ts b/src/main/browser/cdp-ws-proxy.test.ts index 11fbcd772cf..15976e9c729 100644 --- a/src/main/browser/cdp-ws-proxy.test.ts +++ b/src/main/browser/cdp-ws-proxy.test.ts @@ -43,6 +43,7 @@ function createMockWebContents() { debugger: debuggerObj, isDestroyed: () => destroyed, focus: vi.fn(), + printToPDF: vi.fn(async () => Buffer.from('%PDF-test')), reload: vi.fn(), reloadIgnoringCache: vi.fn(), getTitle: vi.fn(() => 'Example'), @@ -109,6 +110,15 @@ describe('CdpWsProxy', () => { return calls.map((call) => call[0]) } + function expectPdfStreamHandle(response: Record): string { + const result = response.result as Record + expect(result.data).toBe('') + expect(result.stream).toEqual(expect.stringMatching(/^orca-pdf-[\da-f-]{36}-\d+$/)) + return result.stream as string + } + + const defaultPdfMarginInches = 1 / 2.54 + it('starts on a random port and returns ws:// URL', () => { expect(endpoint).toMatch(/^ws:\/\/127\.0\.0\.1:\d+$/) expect(proxy.getPort()).toBeGreaterThan(0) @@ -493,6 +503,209 @@ describe('CdpWsProxy', () => { client.close() }) + it('prints PDF data through native webContents printToPDF', async () => { + const client = await connect() + + const response = await sendAndReceive(client, { + id: 19, + method: 'Page.printToPDF', + params: { + landscape: true, + printBackground: true, + paperWidth: 8.5, + paperHeight: 11, + marginTop: 0.25, + marginBottom: 0.5, + marginLeft: 0.75, + marginRight: 1, + pageRanges: '1-2', + preferCSSPageSize: true + } + }) + + expect(response).toEqual({ + id: 19, + result: { data: Buffer.from('%PDF-test').toString('base64') } + }) + expect(mock.webContents.printToPDF).toHaveBeenCalledWith({ + landscape: true, + printBackground: true, + pageSize: { width: 8.5, height: 11 }, + margins: { + marginType: 'custom', + top: 0.25, + bottom: 0.5, + left: 0.75, + right: 1 + }, + pageRanges: '1-2', + preferCSSPageSize: true + }) + expect(getSendCommandMethods()).not.toContain('Page.printToPDF') + client.close() + }) + + it('keeps default PDF margins for omitted sides', async () => { + const client = await connect() + + await sendAndReceive(client, { + id: 20, + method: 'Page.printToPDF', + params: { + marginTop: 0.25 + } + }) + + expect(mock.webContents.printToPDF).toHaveBeenCalledWith({ + margins: { + marginType: 'custom', + top: 0.25, + bottom: defaultPdfMarginInches, + left: defaultPdfMarginInches, + right: defaultPdfMarginInches + } + }) + client.close() + }) + + it('supports streamed Page.printToPDF results for Playwright page.pdf', async () => { + mock.webContents.printToPDF.mockResolvedValueOnce(Buffer.from('abcdef')) + const client = await connect() + + const printResponse = await sendAndReceive(client, { + id: 21, + method: 'Page.printToPDF', + params: { transferMode: 'ReturnAsStream' } + }) + const handle = expectPdfStreamHandle(printResponse) + + const firstRead = await sendAndReceive(client, { + id: 22, + method: 'IO.read', + params: { handle, size: 2 } + }) + const secondRead = await sendAndReceive(client, { + id: 23, + method: 'IO.read', + params: { handle } + }) + const closeResponse = await sendAndReceive(client, { + id: 24, + method: 'IO.close', + params: { handle } + }) + const readAfterClose = await sendAndReceive(client, { + id: 25, + method: 'IO.read', + params: { handle } + }) + + expect(printResponse.id).toBe(21) + expect(firstRead).toEqual({ + id: 22, + result: { base64Encoded: true, data: Buffer.from('ab').toString('base64'), eof: false } + }) + expect(secondRead).toEqual({ + id: 23, + result: { base64Encoded: true, data: Buffer.from('cdef').toString('base64'), eof: true } + }) + expect(closeResponse).toEqual({ id: 24, result: {} }) + expect(readAfterClose).toEqual({ + id: 25, + error: { code: -32000, message: 'Invalid stream handle' } + }) + client.close() + }) + + it('clears streamed PDF data when the client disconnects', async () => { + mock.webContents.printToPDF.mockResolvedValueOnce(Buffer.from('abcdef')) + const client = await connect() + + const printResponse = await sendAndReceive(client, { + id: 26, + method: 'Page.printToPDF', + params: { transferMode: 'ReturnAsStream' } + }) + const handle = expectPdfStreamHandle(printResponse) + + expect(printResponse.id).toBe(26) + client.close() + await new Promise((resolve) => setTimeout(resolve, 10)) + + const nextClient = await connect() + const staleRead = await sendAndReceive(nextClient, { + id: 27, + method: 'IO.read', + params: { handle } + }) + + expect(staleRead).toEqual({ + id: 27, + error: { code: -32000, message: 'Invalid stream handle' } + }) + nextClient.close() + }) + + it('does not register a PDF stream when the client disconnects mid-print', async () => { + let resolvePrint: (buf: Buffer) => void = () => {} + mock.webContents.printToPDF.mockImplementationOnce( + () => + new Promise((resolve) => { + resolvePrint = resolve + }) + ) + const store = (proxy as unknown as { pdfStreams: { create: (b: Buffer) => string } }).pdfStreams + const createSpy = vi.spyOn(store, 'create') + + const client = await connect() + client.send( + JSON.stringify({ + id: 30, + method: 'Page.printToPDF', + params: { transferMode: 'ReturnAsStream' } + }) + ) + await new Promise((resolve) => setTimeout(resolve, 10)) + client.close() + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Print resolves only after the client is gone: no stream must be created. + resolvePrint(Buffer.from('%PDF-late')) + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(createSpy).not.toHaveBeenCalled() + createSpy.mockRestore() + }) + + it('forwards non-PDF IO streams to the debugger', async () => { + mock.webContents.debugger.sendCommand + .mockResolvedValueOnce({ data: 'trace-data', eof: false }) + .mockResolvedValueOnce({}) + const client = await connect() + + const readResponse = await sendAndReceive(client, { + id: 28, + method: 'IO.read', + params: { handle: 'trace-stream', size: 64 } + }) + const closeResponse = await sendAndReceive(client, { + id: 29, + method: 'IO.close', + params: { handle: 'trace-stream' } + }) + + expect(readResponse).toEqual({ id: 28, result: { data: 'trace-data', eof: false } }) + expect(closeResponse).toEqual({ id: 29, result: {} }) + expect(mock.webContents.debugger.sendCommand).toHaveBeenCalledWith('IO.read', { + handle: 'trace-stream', + size: 64 + }) + expect(mock.webContents.debugger.sendCommand).toHaveBeenCalledWith('IO.close', { + handle: 'trace-stream' + }) + client.close() + }) + // ── Page.frameNavigated interception ── // ── Cleanup ── diff --git a/src/main/browser/cdp-ws-proxy.ts b/src/main/browser/cdp-ws-proxy.ts index ec8de5be176..6c3a401d5b7 100644 --- a/src/main/browser/cdp-ws-proxy.ts +++ b/src/main/browser/cdp-ws-proxy.ts @@ -3,6 +3,7 @@ import { WebSocketServer, WebSocket } from 'ws' import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'node:http' import type { WebContents } from 'electron' import { captureScreenshot } from './cdp-screenshot' +import { buildPrintToPdfOptions, CdpPdfStreamStore } from './cdp-print-to-pdf' import { ANTI_DETECTION_SCRIPT } from './anti-detection' import { acquireElectronDebugger, type ElectronDebuggerLease } from './electron-debugger-lease' @@ -20,6 +21,7 @@ export class CdpWsProxy { private attached = false // Why: agent-browser filters events by sessionId from Target.attachToTarget. private clientSessionId: string | undefined = undefined + private readonly pdfStreams = new CdpPdfStreamStore() constructor(private readonly webContents: WebContents) {} @@ -51,6 +53,7 @@ export class CdpWsProxy { const onClose = (): void => { detach() if (this.client === ws) { + this.pdfStreams.clear() this.client = null } } @@ -101,6 +104,7 @@ export class CdpWsProxy { this.detachClientListeners?.() this.detachClientListeners = null this.client = null + this.pdfStreams.clear() client?.close() } @@ -290,6 +294,30 @@ export class CdpWsProxy { this.handleScreenshot(client, clientId, msg.params) return } + // Why: CDP Page.printToPDF is not available for Electron webview guests. + // Electron's native printToPDF path is the reliable equivalent. + if (msg.method === 'Page.printToPDF') { + void this.handlePrintToPdf(client, clientId, msg.params ?? {}) + return + } + if (msg.method === 'IO.read') { + const params = msg.params ?? {} + if (this.pdfStreams.ownsHandle(params)) { + this.handleStreamRead(client, clientId, params) + return + } + this.forwardCommand(client, clientId, msg.method, params, msg.sessionId) + return + } + if (msg.method === 'IO.close') { + const params = msg.params ?? {} + if (this.pdfStreams.ownsHandle(params)) { + this.handleStreamClose(client, clientId, params) + return + } + this.forwardCommand(client, clientId, msg.method, params, msg.sessionId) + return + } // Why: Input.insertText can still require native focus in Electron webviews. // Do not auto-focus generic Runtime.evaluate/callFunctionOn traffic: wait // polling and read-only JS probes use those methods heavily, and focusing on @@ -439,6 +467,57 @@ export class CdpWsProxy { } } + private async handlePrintToPdf( + client: WebSocket, + clientId: number, + params: Record + ): Promise { + if (this.webContents.isDestroyed()) { + this.sendError(clientId, 'Browser tab is no longer available', client) + return + } + try { + const pdf = await this.webContents.printToPDF(buildPrintToPdfOptions(params)) + // Why: printToPDF can resolve after the client disconnected (or was + // replaced). Bail before registering a stream so its buffer isn't + // orphaned in pdfStreams past the disconnect's clear() until the TTL. + if (!this.isActiveClient(client)) { + return + } + const buffer = Buffer.isBuffer(pdf) ? pdf : Buffer.from(pdf) + if (params.transferMode === 'ReturnAsStream') { + const handle = this.pdfStreams.create(buffer) + this.sendResult(clientId, { data: '', stream: handle }, client) + return + } + this.sendResult(clientId, { data: buffer.toString('base64') }, client) + } catch (err) { + this.sendError(clientId, err instanceof Error ? err.message : String(err), client) + } + } + + private handleStreamRead( + client: WebSocket, + clientId: number, + params: Record + ): void { + const chunk = this.pdfStreams.read(params) + if (!chunk) { + this.sendError(clientId, 'Invalid stream handle', client) + return + } + this.sendResult(clientId, { base64Encoded: true, data: chunk.data, eof: chunk.eof }, client) + } + + private handleStreamClose( + client: WebSocket, + clientId: number, + params: Record + ): void { + this.pdfStreams.close(params) + this.sendResult(clientId, {}, client) + } + private handleScreenshot( client: WebSocket, clientId: number, From 02dfb4b334c5090a89f3e19b438493893d3ce327 Mon Sep 17 00:00:00 2001 From: Parker Rex Date: Fri, 3 Jul 2026 16:12:14 -0400 Subject: [PATCH 39/48] Fix Discord icon and CreateWorktree label spacing (#6505) * Fix landing create worktree label * Update Discord help menu icon --- src/renderer/src/components/Landing.tsx | 4 +- .../sidebar/SidebarSettingsHelpMenu.test.tsx | 67 ++++++++++++++++++- .../sidebar/SidebarSettingsHelpMenu.tsx | 4 +- 3 files changed, 69 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/components/Landing.tsx b/src/renderer/src/components/Landing.tsx index 05d609f6d35..9e79671f25a 100644 --- a/src/renderer/src/components/Landing.tsx +++ b/src/renderer/src/components/Landing.tsx @@ -362,8 +362,8 @@ export default function Landing(): React.JSX.Element { onClick={() => openModal('new-workspace-composer', { telemetrySource: 'unknown' })} > - {translate('auto.components.Landing.76a95f7f47', 'Create')} - {createTargetLabel} + {translate('auto.components.Landing.76a95f7f47', 'Create')}{' '} + {createTargetLabel.toLowerCase()}
diff --git a/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.test.tsx b/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.test.tsx index 8fba33ed07d..6f6ea7809ef 100644 --- a/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.test.tsx +++ b/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.test.tsx @@ -1,6 +1,9 @@ +// @vitest-environment happy-dom + +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' import { renderToStaticMarkup } from 'react-dom/server' -import type { ReactNode } from 'react' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { SidebarSettingsHelpMenu } from './SidebarSettingsHelpMenu' const mocks = vi.hoisted(() => ({ @@ -20,6 +23,7 @@ const mocks = vi.hoisted(() => ({ })) let updateStatus = { state: 'idle' } as const +const roots: Root[] = [] vi.mock('@/store', () => ({ useAppStore: (selector: (state: unknown) => unknown) => @@ -96,9 +100,48 @@ vi.mock('./SidebarFeedbackDialog', () => ({ SidebarFeedbackDialog: () =>
})) +function installWindowApi(): void { + Object.assign(window, { + api: { + app: { + restart: mocks.appRestart + }, + shell: { + openUrl: mocks.shellOpenUrl + }, + updater: { + check: mocks.updaterCheck + } + } + }) +} + +async function renderMenu(): Promise { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + roots.push(root) + + await act(async () => { + root.render() + }) + + return container +} + +function findMenuItem(container: HTMLElement, label: string): HTMLButtonElement { + const button = Array.from( + container.querySelectorAll('[data-testid="menu-item"]') + ).find((element) => element.textContent?.includes(label)) + expect(button).toBeDefined() + return button as HTMLButtonElement +} + describe('SidebarSettingsHelpMenu', () => { beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true vi.clearAllMocks() + installWindowApi() mocks.useShortcutKeyDetails.mockReturnValue({ keys: ['⌘', ','], doubleTap: false }) updateStatus = { state: 'idle' } mocks.setupProgress = { @@ -109,6 +152,13 @@ describe('SidebarSettingsHelpMenu', () => { } }) + afterEach(() => { + roots.splice(0).forEach((root) => { + act(() => root.unmount()) + }) + document.body.replaceChildren() + }) + it('renders the help button with correct aria-label', () => { const html = renderToStaticMarkup() expect(html).toContain('Help') @@ -177,6 +227,19 @@ describe('SidebarSettingsHelpMenu', () => { it('renders Discord link', () => { const html = renderToStaticMarkup() expect(html).toContain('Discord') + expect(html).toContain('viewBox="0 0 20 20"') + expect(html).toContain('M16.0742 4.45014C14.9244 3.92097 13.7106 3.54556 12.4638 3.3335') + }) + + it('opens Discord invite through the shell bridge', async () => { + const container = await renderMenu() + const discordButton = findMenuItem(container, 'Discord') + + await act(async () => { + discordButton.click() + }) + + expect(mocks.shellOpenUrl).toHaveBeenCalledWith('https://discord.gg/fzjDKHxv8Q') }) it('renders X link', () => { diff --git a/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx b/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx index f5bf3250c6f..d74fcfb11aa 100644 --- a/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx +++ b/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx @@ -46,8 +46,8 @@ function openExternalUrl(url: string): void { function DiscordIcon(): React.JSX.Element { return ( -
+ {noRepositoriesSelected ? ( +

+ {translate( + 'auto.components.sidebar.AddRepoNestedImportStep.6149d5203f', + 'No repositories are selected. Open the parent folder instead to use editor, terminal, and search without Git features.' + )} +

+ ) : null}
+ {noRepositoriesSelected ? ( + + ) : null}

Z-%z0d!Y%55-k{&pyjb7>(oG?j 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 Z`+t-TKbBqUhHwA?002ovPDHLkV1i0z#=QUl literal 62128 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 Date: Fri, 3 Jul 2026 01:32:22 -0700 Subject: [PATCH 25/48] Restore desktop terminal URL modifier clicks Restore desktop terminal URL and OSC link activation to require Cmd/Ctrl while preserving mobile tap routing. --- .../terminal-pane/terminal-link-activation.ts | 6 ++ .../terminal-link-handlers.test.ts | 80 +++++++++++++++++-- .../terminal-pane/terminal-link-handlers.ts | 12 +-- .../terminal-pane/terminal-link-open-hints.ts | 4 +- .../terminal-osc-link-routing.ts | 26 +++--- .../terminal-url-link-hit-testing.ts | 16 ++-- .../use-terminal-pane-lifecycle.ts | 12 ++- .../pane-manager/pane-dom-creation.test.ts | 44 +++++++++- .../src/lib/pane-manager/pane-dom-creation.ts | 4 +- 9 files changed, 162 insertions(+), 42 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/terminal-link-activation.ts diff --git a/src/renderer/src/components/terminal-pane/terminal-link-activation.ts b/src/renderer/src/components/terminal-pane/terminal-link-activation.ts new file mode 100644 index 00000000000..e85da903224 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-link-activation.ts @@ -0,0 +1,6 @@ +export function isTerminalLinkActivation( + event: Pick | undefined +): boolean { + const isMac = navigator.userAgent.includes('Mac') + return isMac ? Boolean(event?.metaKey) : Boolean(event?.ctrlKey) +} diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts index 5cbc01a0903..c75dd3943e7 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts @@ -7,6 +7,7 @@ import { createFilePathLinkProvider, getTerminalFileOpenHint, getTerminalHtmlFileOpenHint, + getTerminalUrlOpenHint, installFilePathLinkClickFallback, isTerminalLinkActivation, openFilePathLinkAtBufferPosition, @@ -156,12 +157,28 @@ describe('isTerminalLinkActivation', () => { }) describe('handleOscLink', () => { - it('routes http links on ordinary click', () => { + it('ignores http links without the platform modifier on desktop', () => { setPlatform('Macintosh') storeState.settings = { openLinksInApp: true } const preventDefault = vi.fn() - handleOscLink('https://example.com', { metaKey: false, ctrlKey: false, preventDefault }, deps) + expect( + handleOscLink('https://example.com', { metaKey: false, ctrlKey: false, preventDefault }, deps) + ).toBe(false) + + expect(openUrlMock).not.toHaveBeenCalled() + expect(createBrowserTabMock).not.toHaveBeenCalled() + expect(preventDefault).not.toHaveBeenCalled() + }) + + it('routes http links with the platform modifier on desktop', () => { + setPlatform('Macintosh') + storeState.settings = { openLinksInApp: true } + const preventDefault = vi.fn() + + expect( + handleOscLink('https://example.com', { metaKey: true, ctrlKey: false, preventDefault }, deps) + ).toBe(true) expect(openUrlMock).not.toHaveBeenCalled() expect(createBrowserTabMock).toHaveBeenCalledWith('wt-1', 'https://example.com/', { @@ -438,18 +455,38 @@ describe('handleOscLink', () => { setPlatform('Macintosh') expect(getTerminalFileOpenHint()).toBe('⌘+click to open or ⇧⌘+click for default app') expect(getTerminalHtmlFileOpenHint()).toBe('⌘+click to open or ⇧⌘+click for default browser') + expect(getTerminalUrlOpenHint()).toBe('⌘+click to open or ⇧⌘+click for system browser') setPlatform('Windows') expect(getTerminalFileOpenHint()).toBe('Ctrl+click to open or Shift+Ctrl+click for default app') expect(getTerminalHtmlFileOpenHint()).toBe( 'Ctrl+click to open or Shift+Ctrl+click for default browser' ) + expect(getTerminalUrlOpenHint()).toBe( + 'Ctrl+click to open or Shift+Ctrl+click for system browser' + ) }) - it('opens local file URL links in Orca on ordinary click', async () => { + it('ignores local file URL links without the platform modifier on desktop', async () => { setPlatform('Windows') - handleOscLink('file:///tmp/test.txt', { metaKey: false, ctrlKey: false }, deps) + expect(handleOscLink('file:///tmp/test.txt', { metaKey: false, ctrlKey: false }, deps)).toBe( + false + ) + + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(authorizeExternalPathMock).not.toHaveBeenCalled() + expect(openFileMock).not.toHaveBeenCalled() + expect(openFilePathMock).not.toHaveBeenCalled() + }) + + it('opens local file URL links in Orca with the platform modifier on desktop', async () => { + setPlatform('Windows') + + expect(handleOscLink('file:///tmp/test.txt', { metaKey: false, ctrlKey: true }, deps)).toBe( + true + ) // openDetectedFilePath is async (fire-and-forget), so flush the microtask queue // before asserting on positive behavior. @@ -1708,7 +1745,7 @@ describe('createFilePathLinkProvider range bounds', () => { disposable.dispose() }) - it('opens regular URLs from a direct ordinary-click fallback when xterm did not handle them', async () => { + it('ignores regular URLs from a direct ordinary-click fallback on desktop', async () => { setPlatform('Macintosh') storeState.settings = { openLinksInApp: false } const rows = [ @@ -1732,6 +1769,39 @@ describe('createFilePathLinkProvider range bounds', () => { stopPropagation } as unknown as MouseEvent) + expect(openUrlMock).not.toHaveBeenCalled() + expect(preventDefault).not.toHaveBeenCalled() + expect(stopPropagation).not.toHaveBeenCalled() + expect(terminal.clearSelection).not.toHaveBeenCalled() + + disposable.dispose() + expect(element.removeEventListener).toHaveBeenCalledWith('mouseup', mouseUp) + }) + + it('opens regular URLs from a direct modifier-click fallback when xterm did not handle them', async () => { + setPlatform('Macintosh') + storeState.settings = { openLinksInApp: false } + const rows = [ + makeBufferLine('PR opened: https://github.com/stablyai/orca-marketing-website/pull/82') + ] + const { terminal, element } = makeFallbackTerminal(rows) + const disposable = installHttpLinkClickFallback(terminal, { worktreeId: 'wt-1' }) + const mouseUp = getRegisteredBubbleMouseUpHandler(element) + const preventDefault = vi.fn() + const stopPropagation = vi.fn() + + mouseUp({ + button: 0, + metaKey: true, + ctrlKey: false, + shiftKey: false, + defaultPrevented: false, + clientX: 230, + clientY: 25, + preventDefault, + stopPropagation + } as unknown as MouseEvent) + expect(openUrlMock).toHaveBeenCalledWith( 'https://github.com/stablyai/orca-marketing-website/pull/82' ) diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts index 0e111027e37..462dbdeeedc 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts @@ -33,14 +33,15 @@ import { getTerminalOrcaFileOpenHint, getTerminalWorktreePathOpenHint, getTerminalFileOpenHint, - getTerminalUrlOpenHint, - isMacPlatform + getTerminalUrlOpenHint } from './terminal-link-open-hints' import { resolveKnownWorktreeRootPathLink } from './terminal-worktree-path-link' +import { isTerminalLinkActivation } from './terminal-link-activation' export { openDetectedFilePath } from './terminal-file-open-routing' export { openFilePathLinkAtBufferPosition } from './terminal-file-link-hit-testing' export { getTerminalFileOpenHint, getTerminalHtmlFileOpenHint, getTerminalUrlOpenHint } +export { isTerminalLinkActivation } from './terminal-link-activation' export type LinkHandlerDeps = { worktreeId: string @@ -315,10 +316,3 @@ export function installFilePathLinkClickFallback( } } } - -export function isTerminalLinkActivation( - event: Pick | undefined -): boolean { - const isMac = isMacPlatform() - return isMac ? Boolean(event?.metaKey) : Boolean(event?.ctrlKey) -} diff --git a/src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts b/src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts index 246333c7cbd..0d51669a6c6 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts @@ -22,8 +22,8 @@ export function getTerminalHtmlFileOpenHint(): string { export function getTerminalUrlOpenHint(): string { return isMacPlatform() - ? 'click to open or ⇧+click for system browser' - : 'click to open or Shift+click for system browser' + ? '⌘+click to open or ⇧⌘+click for system browser' + : 'Ctrl+click to open or Shift+Ctrl+click for system browser' } export function getTerminalUrlSystemBrowserHint(): string { diff --git a/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts b/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts index 9d588b7857a..2b043d648cf 100644 --- a/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts +++ b/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts @@ -3,6 +3,7 @@ import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-pat import type { LinkHandlerDeps } from './terminal-link-handlers' import { resolveTerminalFileUrlTarget } from './terminal-file-url-target' import { openDetectedFilePath } from './terminal-file-open-routing' +import { isTerminalLinkActivation } from './terminal-link-activation' import { openTerminalHttpLink, type TerminalLinkRoutingPreferenceRequester @@ -11,16 +12,16 @@ import { type TerminalLinkEvent = Pick & Partial> -function isPrimaryOscLinkActivation(event: TerminalLinkEvent | undefined): boolean { +function isDesktopOscLinkActivation(event: TerminalLinkEvent | undefined): boolean { if (!event) { return false } if ('button' in event && event.button !== undefined && event.button !== 0) { return false } - // Why: macOS Ctrl-click is a context-menu gesture even when Chromium reports - // it as button 0; ordinary OSC links should not steal that secondary action. - return !(navigator.userAgent.includes('Mac') && event.ctrlKey && !event.metaKey) + // Why: desktop xterm links must not open while the user is just placing the + // cursor or selecting text. Mobile URL taps use a separate WebView path. + return isTerminalLinkActivation(event) } export function handleOscLink( @@ -30,9 +31,9 @@ export function handleOscLink( Partial> & { requestOpenLinksInAppPreference?: TerminalLinkRoutingPreferenceRequester } -): void { - if (!isPrimaryOscLinkActivation(event)) { - return +): boolean { + if (!isDesktopOscLinkActivation(event)) { + return false } // Why: xterm renders OSC 8 links as clickable anchors. Orca must suppress // default anchor navigation so link-routing settings can choose the target. @@ -67,15 +68,14 @@ export function handleOscLink( ) { // Why: `new URL("C:\\path\\file.ts")` succeeds with protocol `c:`; // Windows OSC links need file-path routing before generic URL parsing. - return + return true } let parsed: URL try { parsed = new URL(rawText) } catch { - openDetectedPathLink() - return + return openDetectedPathLink() } if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { @@ -84,7 +84,7 @@ export function handleOscLink( forceSystemBrowser: Boolean(event?.shiftKey), requestOpenLinksInAppPreference: deps.requestOpenLinksInAppPreference }) - return + return true } if (parsed.protocol === 'file:') { @@ -99,11 +99,13 @@ export function handleOscLink( !deps.runtimeEnvironmentId const resolved = resolveTerminalFileUrlTarget(parsed, { allowUncHost }) if (!resolved) { - return + return false } openDetectedFilePath(resolved.filePath, resolved.line, resolved.column, { ...deps, openWithSystemDefault: Boolean(event?.shiftKey) }) + return true } + return false } diff --git a/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts b/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts index 96ceafcce38..67505d5f622 100644 --- a/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts +++ b/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts @@ -1,6 +1,7 @@ import type { IBufferLine, IBufferRange, IDisposable, Terminal } from '@xterm/xterm' import { openHttpLink } from '@/lib/http-link-routing' import { buildCandidateLogicalLinesForBufferPosition } from './terminal-file-link-hit-testing' +import { isTerminalLinkActivation } from './terminal-link-activation' import { rangeForParsedFileLink } from './wrapped-terminal-link-ranges' type UrlLinkHitTestDeps = { @@ -48,13 +49,14 @@ export function extractTerminalHttpLinks(lineText: string): ParsedTerminalHttpLi return links } -function isPrimaryHttpLinkFallbackActivation(event: MouseEvent): boolean { +function isDesktopHttpLinkFallbackActivation(event: MouseEvent): boolean { if (event.defaultPrevented || event.button !== 0) { return false } - // Why: URL links now open on ordinary clicks, but macOS Ctrl-click must stay - // available for context menus even when Chromium reports it as button 0. - return !(navigator.userAgent.includes('Mac') && event.ctrlKey && !event.metaKey) + // Why: desktop terminal links require an intentional Cmd/Ctrl gesture so + // plain clicks remain available for cursor placement and selection. Mobile + // tap routing is handled separately under mobile/src/terminal. + return isTerminalLinkActivation(event) } function* iterateTerminalHttpUrlCandidates( @@ -220,7 +222,7 @@ export function installHttpLinkClickFallback( deps: UrlLinkClickFallbackDeps ): IDisposable { const handleMouseUp = (event: MouseEvent): void => { - if (!isPrimaryHttpLinkFallbackActivation(event)) { + if (!isDesktopHttpLinkFallbackActivation(event)) { return } @@ -230,8 +232,8 @@ export function installHttpLinkClickFallback( } // Why: xterm's WebLinksAddon only activates after hover state exists. This - // direct mouseup fallback preserves ordinary link clicks when the hover link - // was never established, while defaultPrevented avoids duplicate opens. + // direct mouseup fallback preserves modifier-clicks when the hover link was + // never established, while defaultPrevented avoids duplicate opens. const opened = openHttpLinkAtBufferPosition(terminal.buffer.active, position, terminal.cols, { worktreeId: deps.worktreeId, forceSystemBrowser: event.shiftKey, diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index a5d9172855b..1674e150fcd 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -1017,7 +1017,7 @@ export function useTerminalPaneLifecycle({ pane.terminal.options.linkHandler = { allowNonHttpProtocols: true, activate: (event, text) => { - handleOscLink(text, event as MouseEvent | undefined, { + const handled = handleOscLink(text, event as MouseEvent | undefined, { ...linkDeps, startupCwd: getPaneLinkCwd(pane.id), runtimeEnvironmentId: linkDeps.getRuntimeEnvironmentIdForPane?.(pane.id) ?? null, @@ -1031,7 +1031,9 @@ export function useTerminalPaneLifecycle({ // moving the mouse extends a selection until the next click/Esc. // clearSelection() explicitly detaches those listeners (see // SelectionService._removeMouseDownListeners). - pane.terminal.clearSelection() + if (handled) { + pane.terminal.clearSelection() + } }, // Show bottom-left tooltip on hover for OSC 8 hyperlinks (e.g. // GitHub owner/repo#issue references emitted by CLI tools) — same @@ -1344,7 +1346,7 @@ export function useTerminalPaneLifecycle({ return } const activePane = managerRef.current?.getActivePane() - void handleOscLink(url, event, { + const handled = handleOscLink(url, event, { ...linkDeps, startupCwd: activePane ? getPaneLinkCwd(activePane.id) : startupCwd, runtimeEnvironmentId: activePane @@ -1359,7 +1361,9 @@ export function useTerminalPaneLifecycle({ // phantom selection until the next click/Esc. Explicitly clearing the // selection also detaches those listeners (see // SelectionService._removeMouseDownListeners). - managerRef.current?.getActivePane()?.terminal.clearSelection() + if (handled) { + managerRef.current?.getActivePane()?.terminal.clearSelection() + } }, formatLinkTooltip: (url, openLinkHint) => formatTerminalUrlTooltip(url, openLinkHint), // Why: TerminalPane instances stay mounted for hidden visited worktrees diff --git a/src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts b/src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts index ec1783654d5..7f256d4304a 100644 --- a/src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts @@ -1,5 +1,5 @@ // @vitest-environment happy-dom -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { TerminalLeafId } from '../../../../shared/stable-pane-id' import { createPaneDOM } from './pane-dom-creation' @@ -48,7 +48,49 @@ vi.mock('@xterm/xterm', () => ({ }) })) +function setPlatform(userAgent: string): void { + vi.stubGlobal('navigator', { userAgent }) +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + describe('createPaneDOM link tooltips', () => { + it('uses desktop modifier-click text for WebLinks hover hints', () => { + const leafId = '11111111-1111-4111-8111-111111111111' as TerminalLeafId + + setPlatform('Macintosh') + const macPane = createPaneDOM( + 1, + leafId, + {}, + { active: null } as never, + {} as never, + vi.fn(), + vi.fn() + ) + webLinksAddonMock.options?.hover?.({} as MouseEvent, 'http://localhost:5180/') + expect(macPane.linkTooltip.textContent).toBe( + 'http://localhost:5180/ (⌘+click to open or ⇧⌘+click for system browser)' + ) + + setPlatform('Windows') + const windowsPane = createPaneDOM( + 2, + leafId, + {}, + { active: null } as never, + {} as never, + vi.fn(), + vi.fn() + ) + webLinksAddonMock.options?.hover?.({} as MouseEvent, 'http://localhost:5180/') + expect(windowsPane.linkTooltip.textContent).toBe( + 'http://localhost:5180/ (Ctrl+click to open or Shift+Ctrl+click for system browser)' + ) + }) + it('lets callers replace WebLinks hover text for display-only labels', async () => { const labeledText = 'http://main.orca.localhost:60016/ (localhost:5180; click to open)' const leafId = '11111111-1111-4111-8111-111111111111' as TerminalLeafId diff --git a/src/renderer/src/lib/pane-manager/pane-dom-creation.ts b/src/renderer/src/lib/pane-manager/pane-dom-creation.ts index 8c1c1e289ff..2e556f44400 100644 --- a/src/renderer/src/lib/pane-manager/pane-dom-creation.ts +++ b/src/renderer/src/lib/pane-manager/pane-dom-creation.ts @@ -16,8 +16,8 @@ import { installGuardedLinkProviderRegistration } from './terminal-link-provider function getTerminalUrlOpenHint(): string { return navigator.userAgent.includes('Mac') - ? 'click to open or ⇧+click for system browser' - : 'click to open or Shift+click for system browser' + ? '⌘+click to open or ⇧⌘+click for system browser' + : 'Ctrl+click to open or Shift+Ctrl+click for system browser' } function defaultLinkTooltipText(uri: string, openLinkHint: string): string { From a84f737cf79e6dece80c7071e450f6facfe8bd67 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 3 Jul 2026 01:45:15 -0700 Subject: [PATCH 26/48] fix(terminals): restore pane detach tab placement (#7215) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(tabs): restore deferred tab activation so dragging a terminal tab doesn't switch panes mid-gesture PR #5927 shipped terminal-pane drag (reorder tabs, move a tab into another pane, edge-split into a new column). It deliberately DEFERRED tab activation to pointer-up and suppressed it during a drag, so pressing a tab to drag it never switched the active tab or stole terminal focus mid-gesture. PR #6395 (d32d62a395) deleted tab-strip-pointer-activation.ts and made SortableTab/EditorFileTab/BrowserTab activate eagerly on pointerdown — fixing click-to-switch-after-reorder but regressing the drag: grabbing a tab now flips the active tab + focused pane and yanks xterm keyboard focus before the drag is even recognized (aggravated by PR #6210 raising the dnd-kit threshold 5px->12px). The move itself still lands, but the gesture feels broken. Restore the deferred-activation hook, but gate it on measured pointer DISPLACEMENT instead of the drag-active context ref the old hook used — that ref clears asynchronously relative to the drop's pointerup, which is exactly what made #6395's click-after-reorder misfire. Displacement mirrors dnd-kit's own activation threshold: a release within TAB_DRAG_ACTIVATION_DISTANCE_PX is a click (activate); crossing it is a drag (suppress). Because each press measures its own gesture, a click after a reorder always activates. - Recreate src/renderer/src/components/tab-bar/tab-strip-pointer-activation.ts (displacement-gated) + unit tests, incl. an explicit #6395 regression case. - Rewire onPointerDown in SortableTab, EditorFileTab, BrowserTab to use it. - BrowserTab.test.tsx shallow-renders via mocked React hooks; add useRef/ useCallback stubs so the new hook works under that harness. Verified end-to-end in a dev build via CDP: pressing a tab no longer activates/steals focus on pointerdown; a click still activates on release; dragging a tab into another pane still moves it; clicking tabs after a drag still switches (the #6395 guard in tests/e2e/tabs.spec.ts stays green because it uses zero-displacement clicks). 233 unit tests pass; oxlint + renderer typecheck clean. * fix: restore pane detach tab placement --- src/renderer/src/assets/terminal.css | 6 + .../components/tab-bar/BrowserTab.test.tsx | 5 + .../src/components/tab-bar/BrowserTab.tsx | 14 +- .../src/components/tab-bar/EditorFileTab.tsx | 16 +- .../src/components/tab-bar/SortableTab.tsx | 20 +- .../tab-strip-pointer-activation.test.tsx | 111 +++++ .../tab-bar/tab-strip-pointer-activation.ts | 91 +++++ .../components/tab-group/TabGroupPanel.tsx | 2 + .../components/terminal-pane/TerminalPane.tsx | 63 ++- .../terminal-layout-leaf-detach.test.ts | 119 ++++++ .../terminal-layout-leaf-detach.ts | 119 ++++++ .../terminal-pane-tab-detach.test.ts | 383 ++++++++++++++++++ .../terminal-pane/terminal-pane-tab-detach.ts | 306 ++++++++++++++ .../use-terminal-pane-lifecycle.ts | 55 ++- .../src/lib/pane-manager/pane-drag-pointer.ts | 50 ++- .../pane-manager/pane-drag-reorder.test.ts | 74 ++++ .../src/lib/pane-manager/pane-drag-reorder.ts | 16 +- .../lib/pane-manager/pane-manager-types.ts | 20 + .../src/lib/pane-manager/pane-manager.ts | 41 +- .../src/lib/pane-manager/pane-split-close.ts | 31 +- .../src/store/slices/store-cascades.test.ts | 51 +++ src/renderer/src/store/slices/terminals.ts | 84 ++++ 22 files changed, 1624 insertions(+), 53 deletions(-) create mode 100644 src/renderer/src/components/tab-bar/tab-strip-pointer-activation.test.tsx create mode 100644 src/renderer/src/components/tab-bar/tab-strip-pointer-activation.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts diff --git a/src/renderer/src/assets/terminal.css b/src/renderer/src/assets/terminal.css index 97d3cbb4744..d9763b3e341 100644 --- a/src/renderer/src/assets/terminal.css +++ b/src/renderer/src/assets/terminal.css @@ -219,6 +219,12 @@ pointer-events: none; } +.pane-drop-overlay[data-pane-drop-overlay-kind='insertion'] { + background: var(--chart-2); + border: 0; + border-radius: 0; +} + .tab-drop-overlay__label { font-size: 11px; color: var(--primary-foreground); diff --git a/src/renderer/src/components/tab-bar/BrowserTab.test.tsx b/src/renderer/src/components/tab-bar/BrowserTab.test.tsx index a53e80e0117..f4dc863f8e8 100644 --- a/src/renderer/src/components/tab-bar/BrowserTab.test.tsx +++ b/src/renderer/src/components/tab-bar/BrowserTab.test.tsx @@ -11,6 +11,11 @@ vi.mock('react', async () => { return { ...actual, useEffect: () => {}, + // Why: this shallow harness calls the component as a plain function (no React + // render), so ref/callback hooks must be stubbed like useState/useEffect. The + // favicon tests never fire pointer events, so non-persistent refs are fine. + useRef: (initial: T) => ({ current: initial }), + useCallback: (fn: T) => fn, useState(initial: T | (() => T)) { const stateIndex = reactHookRuntime.index++ if (!(stateIndex in reactHookRuntime.states)) { diff --git a/src/renderer/src/components/tab-bar/BrowserTab.tsx b/src/renderer/src/components/tab-bar/BrowserTab.tsx index 83ad2c5f45f..0dfb97a9af6 100644 --- a/src/renderer/src/components/tab-bar/BrowserTab.tsx +++ b/src/renderer/src/components/tab-bar/BrowserTab.tsx @@ -26,6 +26,7 @@ import { preventMiddleButtonDefault } from './middle-button-default-guard' import { translate } from '@/i18n/i18n' import { TAB_CONTAINER_WIDTH_CLASSES, TAB_LABEL_WIDTH_CLASSES } from './tab-width-rules' import { TabWorkspaceLayoutMenuSection } from './TabWorkspaceLayoutMenuSection' +import { useTabStripPointerActivation } from './tab-strip-pointer-activation' function formatBrowserTabUrlLabel(url: string): string { if (url === ORCA_BROWSER_BLANK_URL || url === 'about:blank') { @@ -169,6 +170,10 @@ export default function BrowserTab({ return () => window.removeEventListener('blur', dismiss) }, [menuOpen]) + // Why: defer activation to pointer-up so dragging the tab (reorder / move into + // another pane / split) does not switch the active tab mid-gesture. + const { onPointerDown: onTabPointerDown } = useTabStripPointerActivation({ onActivate }) + const tabRoot = (