diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 47bf6b83a40..1e3fb4e6bdb 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -15,6 +15,11 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + # Why: the freshness registry is derived from immutable release tags, + # so shallow PR checkouts cannot verify historical official identities. + fetch-depth: 0 + persist-credentials: false - name: Install native build tools run: sudo apt-get update && sudo apt-get install -y build-essential python3 zlib1g-dev zsh @@ -82,6 +87,9 @@ jobs: - name: Verify bundled skill guides run: pnpm run verify:bundled-skill-guides + - name: Verify skill freshness manifest + run: pnpm run verify:skill-bundle-manifest + # Why: project-owned type declarations must live in .ts so tsc # actually checks them. TypeScript's skipLibCheck: true (inherited # from @electron-toolkit/tsconfig) silently widens unresolved names diff --git a/.github/workflows/skill-update-roundtrip.yml b/.github/workflows/skill-update-roundtrip.yml new file mode 100644 index 00000000000..6c9418e1f64 --- /dev/null +++ b/.github/workflows/skill-update-roundtrip.yml @@ -0,0 +1,50 @@ +name: Skill update round trip + +on: + pull_request: + paths: + - 'skills/**' + - 'resources/skills/**' + - 'config/scripts/verify-skill-update-roundtrip.mjs' + - 'src/main/skills/skill-freshness-eligibility.ts' + - 'src/shared/skill-freshness.ts' + - '.github/workflows/skill-update-roundtrip.yml' + merge_group: + push: + branches: + - main + +jobs: + roundtrip: + strategy: + fail-fast: false + matrix: + os: [macos-latest, ubuntu-latest, windows-latest] + shape: [symlink, copy] + autocrlf: ['false', 'true'] + skills-cli: ['1.5.17'] + include: + - os: ubuntu-latest + shape: symlink + autocrlf: 'false' + skills-cli: latest + continue-on-error: ${{ matrix.skills-cli == 'latest' }} + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-node@v6 + with: + node-version-file: package.json + - name: Verify targeted update convergence and copy behavior + env: + GITHUB_TOKEN: ${{ github.token }} + SKILL_UPDATE_SOURCE: ${{ github.event.pull_request.head.repo.full_name || github.repository }} + SKILL_UPDATE_REF: ${{ github.head_ref || github.ref_name }} + run: >- + node config/scripts/verify-skill-update-roundtrip.mjs + --cli=${{ matrix.skills-cli }} + --autocrlf=${{ matrix.autocrlf }} + --shape=${{ matrix.shape }} diff --git a/README.md b/README.md index 81b9a5c5aea..d07692e1edd 100644 --- a/README.md +++ b/README.md @@ -238,10 +238,9 @@ Pair with your desktop app to monitor and steer your agents from your phone. - **Discord:** Join the community on **[Discord](https://discord.gg/fzjDKHxv8Q)**. - **Twitter / X:** Follow **[@orca_build](https://x.com/orca_build)** for updates and announcements. -- **WeChat:** Scan the QR code to join the community. If the first group is full, use the backup group. +- **WeChat:** First WeChat group is full, now you can join the second one. - WeChat QR code for the Orca community - Backup WeChat QR code for the Orca community + WeChat QR code for the Orca community - **Feedback & Ideas:** We ship fast. Missing something? [Request a new feature](https://github.com/stablyai/orca/issues). - **Privacy:** See the [privacy & telemetry docs](https://www.onorca.dev/docs/telemetry) for what anonymous usage data Orca collects and how to opt out. diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 0dd6a3d85c7..98beb0c1c1f 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -18,6 +18,12 @@ const featureWallResources = { from: 'resources/onboarding/feature-wall', to: 'onboarding/feature-wall' } +// Why: freshness detection needs immutable identity metadata from this exact +// app build, but never needs the skill package bytes or a runtime network read. +const skillFreshnessResources = { + from: 'resources/skills', + to: 'skills' +} // Why: SSH relay deploy resolves bundles from process.resourcesPath in packaged // apps. Keeping relay assets as extraResources makes them real directories // instead of paths hidden inside app.asar. @@ -31,7 +37,11 @@ const relayExtraResource = { // do not fall through to a developer checkout's node_modules. const packagedRuntimeNodeModuleResources = createPackagedRuntimeNodeModuleResources() -const commonExtraResources = [relayExtraResource, ...packagedRuntimeNodeModuleResources] +const commonExtraResources = [ + relayExtraResource, + ...packagedRuntimeNodeModuleResources, + skillFreshnessResources +] const macSpeechNativeResource = { from: 'node_modules/sherpa-onnx-darwin-${arch}', to: 'node_modules/sherpa-onnx-darwin-${arch}' @@ -66,6 +76,10 @@ module.exports = { // authoring sources too would duplicate content without a runtime consumer. '!skill-guides{,/**/*}', '!tests{,/**/*}', + // Why: pr-evidence/ is a local e2e screenshot output (ORCA_CAPTURE_EVIDENCE); + // it is gitignored, but exclude it defensively so a stray local capture at + // package time never bloats app.asar. + '!pr-evidence{,/**/*}', '!Casks{,/**/*}', '!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md}', '!out/**/*.test.js', @@ -75,7 +89,8 @@ module.exports = { '!tsconfig.json', // Why: feature-wall media is copied via extraResources so runtime can read // it from process.resourcesPath; exclude the source copy from app.asar. - '!resources/onboarding/feature-wall/**' + '!resources/onboarding/feature-wall/**', + '!resources/skills/**' ], // Why: the CLI entry-point lives in out/cli/ but imports shared modules // from out/shared/ and local hook mutators from out/main/. These paths must be diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index ec1cd450561..448b4beac63 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1950,7 +1950,6 @@ "daemon stream", "main PTY batching", "runtime path provenance", - "runtime serve-sim detection", "runtime terminal wait detection", "renderer ACK", "xterm scheduler", @@ -1971,7 +1970,7 @@ "macos" ], "coveredProviders": [], - "coverageNotes": "Local macOS evidence covers the existing main-process pending-output caps plus deterministic runtime path-provenance history reuse, idle-path serve-sim detection, and saturated-tail wait detection. Daemon stream write(false)/drain contracts, cross-session drain priority, and bounded queued tails arrive with the pending perf slice; live flood/latency artifacts remain gaps.", + "coverageNotes": "Local macOS evidence covers the existing main-process pending-output caps plus deterministic runtime path-provenance history reuse and saturated-tail wait detection. Daemon stream write(false)/drain contracts, cross-session drain priority, and bounded queued tails arrive with the pending perf slice; live flood/latency artifacts remain gaps.", "motivatingLinks": [ "https://github.com/stablyai/orca/pull/6836", "https://github.com/stablyai/orca/pull/6858", @@ -1979,17 +1978,15 @@ "https://github.com/stablyai/orca/pull/7054" ], "invariant": "High-volume terminal output must stay bounded across daemon socket writes, main runtime metadata, detectors, and tail checks, main-to-renderer in-flight bytes, renderer scheduler queues, and hidden-output restore without starving focused input.", - "oracle": "The current executable slice injects daemon socket backpressure and main-process renderer backlog pressure, then asserts write(false)/drain ordering, bounded queued daemon bytes, unchanged path-provenance history reuse for pathless output, idle serve-sim detection with split-JSON continuity, ordinary terminal-wait detection without a joined-tail allocation and with full prompt-family continuity, per-PTY and total pending-output caps, preserved sequenced-tail metadata, active-pending protection ahead of background trimming, and ACK-gated in-flight bounds. The live Electron perf oracle adds hidden-output floods, renderer scheduler queue depth, dropped-output-zero normal scenarios, and active key latency budgets before promotion.", + "oracle": "The current executable slice injects main-process renderer backlog pressure, then asserts unchanged path-provenance history reuse for pathless output, ordinary terminal-wait detection without a joined-tail allocation and with full prompt-family continuity, per-PTY and total pending-output caps, preserved sequenced-tail metadata, active-pending protection ahead of background trimming, and ACK-gated in-flight bounds. The live Electron perf oracle adds hidden-output floods, renderer scheduler queue depth, dropped-output-zero normal scenarios, and active key latency budgets before promotion.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-path-candidate-history.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/main/emulator/serve-sim-state-watcher.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/orca-runtime-tail-wait-memo.test.ts" ], "testFiles": [ "src/main/ipc/pty.test.ts", "src/main/runtime/orca-runtime-path-candidate-history.test.ts", - "src/main/emulator/serve-sim-state-watcher.test.ts", "src/main/runtime/orca-runtime.test.ts", "src/main/runtime/orca-runtime-tail-wait-memo.test.ts" ], @@ -2009,13 +2006,6 @@ "copies history when new output adds a path candidate" ] }, - { - "file": "src/main/emulator/serve-sim-state-watcher.test.ts", - "assertions": [ - "does not buffer repeated brace-free PTY output while waiting for metadata", - "detects serve-sim metadata split across PTY chunks and releases the partial object" - ] - }, { "file": "src/main/runtime/orca-runtime-tail-wait-memo.test.ts", "assertions": [ @@ -2053,15 +2043,6 @@ "durationSeconds": 3.49, "summary": "1 test file passed, 2 tests passed on the exact rebased tree based on main@dc468f0ded." }, - { - "date": "2026-07-10", - "runner": "local", - "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/emulator/serve-sim-state-watcher.test.ts", - "result": "passed", - "durationSeconds": 0.37, - "summary": "1 test file passed, 6 tests passed on pushed commit f0e3a185593b." - }, { "date": "2026-07-10", "runner": "local", @@ -2078,11 +2059,11 @@ }, "flakeHistory": { "status": "unknown", - "evidence": "Daemon stream backpressure and focused main/renderer backlog tests are deterministic unit slices. The combined perf soak still needs runtime history before promotion." + "evidence": "Focused main-process backlog tests are deterministic unit slices. The combined perf soak still needs runtime history before promotion." }, "redGreenEvidence": { "status": "partial", - "evidence": "Tests assert daemon stream writes pause after socket write(false), later stream data queues behind the pressured socket, flush-immediate output from another session is prioritized ahead of unrelated queued background backlog on drain while preserving per-session order, queued lines resume on drain, global cleanup clears clients that only have backpressured writes, queued daemon stream bytes are bounded by keeping priority output and the newest tail under sustained pressure, pathless runtime output reuses unchanged path-provenance history with zero old-candidate byte scans, ordinary output leaves the niche serve-sim detector idle while split JSON still emits and releases its partial buffer, ordinary saturated terminal tails retain no rebuilt wait text or repeated phrase scans while every blocked/ready prompt family remains live, main pending renderer output is capped per PTY and in total, total-pressure trimming prefers background pending output before active pending output, trimmed pending tails preserve seq/rawLength metadata, and ACK-gated in-flight output remains bounded. Intentionally restoring the old path-history shape failed the focused scale test after 774ms and 4,096 replacements; restoring the old serve-sim buffer failed its scale test after 180ms with 4,096 buffer writes; disabling the ordinary-tail branch failed with a retained 253,999-character waitText. The fixed shapes passed their focused suites. Existing renderer tests cover replay/backlog slices. Needs broader hidden-output/input-latency perf artifacts before promotion." + "evidence": "Tests assert pathless runtime output reuses unchanged path-provenance history with zero old-candidate byte scans, ordinary saturated terminal tails retain no rebuilt wait text or repeated phrase scans while every blocked/ready prompt family remains live, main pending renderer output is capped per PTY and in total, total-pressure trimming prefers background pending output before active pending output, trimmed pending tails preserve seq/rawLength metadata, and ACK-gated in-flight output remains bounded. Intentionally restoring the old path-history shape failed the focused scale test after 774ms and 4,096 replacements; disabling the ordinary-tail branch failed with a retained 253,999-character waitText. The fixed shapes passed their focused suites. Existing renderer tests cover replay/backlog slices. Needs daemon-stream contract coverage and broader hidden-output/input-latency perf artifacts before promotion." }, "performanceBudget": { "required": true, @@ -2094,9 +2075,8 @@ "Keep stress variants non-blocking until stable runtime history exists." ], "knownGaps": [ - "Current command asserts daemon stream write(false)/drain/cleanup at the batcher contract layer and main pending renderer caps at the IPC contract layer.", + "Daemon stream write(false)/drain/cleanup and bounded queued-tail contracts are not registered in the current command.", "Runtime provenance coverage is deterministic and does not include a live high-throughput provider artifact.", - "Serve-sim detector coverage is deterministic and does not include a live external simulator helper.", "Terminal-wait scale evidence is deterministic and does not yet include a live saturated-tail event-loop artifact.", "Current command does not prove renderer parse pressure, scheduler queue depth, event-loop delay, or active key latency.", "Live hidden-output pressure, active input latency, and full Electron perf artifacts remain unproved." @@ -3896,6 +3876,7 @@ "layer": "runtime-rpc-contract", "surfaces": [ "mobile terminal", + "native-chat terminal overlay", "runtime RPC", "binary multiplex", "JSON subscribe fallback", @@ -3917,21 +3898,25 @@ "macos" ], "coveredProviders": [], - "coverageNotes": "Local macOS evidence over the runtime-RPC stream budgets on main@1282f5c2d. The pending stack adds byte-exact 512KB/2MB/256KB/48KB budget assertions; legacy JSON subscribe parity remains undecided.", + "coverageNotes": "Local macOS evidence over the runtime-RPC stream budgets on main@1282f5c2d. PR #5824 adds a platform-neutral mobile decision gate proving chat-covered terminal streams pause and resume only after the mounted WebView is ready; live Android restore evidence remains required. The pending stack adds byte-exact 512KB/2MB/256KB/48KB budget assertions; legacy JSON subscribe parity remains undecided.", "motivatingLinks": [ "https://github.com/stablyai/orca/pull/6951", "https://github.com/stablyai/orca/pull/6955", "https://github.com/stablyai/orca/pull/7009" ], - "invariant": "Runtime and mobile terminal subscriptions must cap initial snapshots, live output buffered while snapshots load, chunk sizes, and batch sizes, while preserving output order, input locks, resize/driver events, and fallback parity or explicit fallback deprecation.", - "oracle": "The current executable slice asserts mobile initial snapshots downgrade until they fit <=512KB, requested binary snapshots downgrade until they fit <=2MB, binary live output queued while the initial snapshot loads stays <=256KB while preserving the newest tail, large binary output is split into <=48KB frames, output bursts are coalesced before emit, aborts do not register stale listeners, and stale mobile resize re-stream completions are dropped. JSON fallback parity/deprecation remains an explicit gap.", + "invariant": "Runtime and mobile terminal subscriptions must cap initial snapshots, live output buffered while snapshots load, chunk sizes, and batch sizes; a terminal covered by native chat must have no live output subscription and must restore from fresh scrollback when revealed, while preserving output order, input locks, resize/driver events, and fallback parity or explicit fallback deprecation.", + "oracle": "The current executable slice asserts mobile initial snapshots downgrade until they fit <=512KB, requested binary snapshots downgrade until they fit <=2MB, binary live output queued while the initial snapshot loads stays <=256KB while preserving the newest tail, large binary output is split into <=48KB frames, output bursts are coalesced before emit, aborts do not register stale listeners, and stale mobile resize re-stream completions are dropped. The mobile native-chat decision test asserts an active stream pauses while covered and resumes only for a ready active terminal. JSON fallback parity and live Android scrollback restoration remain explicit gaps.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/main/runtime/rpc/terminal-output-batching.test.ts src/main/runtime/rpc/terminal-multiplex.test.ts" + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/main/runtime/rpc/terminal-output-batching.test.ts src/main/runtime/rpc/terminal-multiplex.test.ts", + "pnpm --dir mobile exec vitest run --root .. mobile/src/session/mobile-native-chat-terminal-stream.test.ts", + "pnpm --dir mobile exec vitest run --root .. mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts" ], "testFiles": [ "src/main/runtime/rpc/terminal-subscribe-buffer.test.ts", "src/main/runtime/rpc/terminal-output-batching.test.ts", - "src/main/runtime/rpc/terminal-multiplex.test.ts" + "src/main/runtime/rpc/terminal-multiplex.test.ts", + "mobile/src/session/mobile-native-chat-terminal-stream.test.ts", + "mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts" ], "assertionRefs": [ { @@ -3956,6 +3941,21 @@ "oversized live output frames are bounded for subscribed binary streams", "multibyte live output flushes when encoded bytes reach the batch budget" ] + }, + { + "file": "mobile/src/session/mobile-native-chat-terminal-stream.test.ts", + "assertions": [ + "an active terminal stream pauses while native chat covers its WebView", + "the stream resumes only for the ready active terminal after chat closes", + "non-terminal and already-settled stream states perform no lifecycle work" + ] + }, + { + "file": "mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts", + "assertions": [ + "a late WebView-ready transition converts a cold-start lease-only stream back to output", + "ordinary WebView readiness does not rerender the mobile session route" + ] } ], "evidenceRuns": [ @@ -3967,6 +3967,15 @@ "result": "passed", "durationSeconds": 3.8, "summary": "3 test file(s) passed, 28 tests passed on main@1282f5c2d in a clean checkout." + }, + { + "date": "2026-07-12", + "runner": "local", + "platform": "macos", + "command": "pnpm --dir mobile exec vitest run --root .. mobile/src/session/mobile-native-chat-terminal-stream.test.ts", + "result": "passed", + "durationSeconds": 0.2, + "summary": "The focused mobile native-chat suite passed with 3 terminal-stream lifecycle assertions in the staged PR #5824 worktree." } ], "runtimeBudget": { @@ -3991,7 +4000,7 @@ "Record byte counts and order/cursor artifacts on failure." ], "knownGaps": [ - "Current command covers binary runtime/mobile stream contracts only.", + "The pure mobile decision gate does not yet prove live Android WebView scrollback restore after a chat toggle.", "Legacy JSON subscribe parity is undecided." ], "demotionRule": "Cannot promote while a supported stream path has uncapped snapshot or live-output buffering." diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index 7d4fd9af56b..d5671498468 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -29,7 +29,9 @@ describe('electron-builder config', () => { '!native{,/**/*}', '!skills{,/**/*}', '!skill-guides{,/**/*}', + '!resources/skills/**', '!tests{,/**/*}', + '!pr-evidence{,/**/*}', '!Casks{,/**/*}', '!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md}', '!out/**/*.test.js' @@ -38,6 +40,12 @@ describe('electron-builder config', () => { }) it('keeps runtime resources available through extraResources', () => { + for (const platform of ['mac', 'linux', 'win']) { + expect(electronBuilderConfig[platform].extraResources).toContainEqual({ + from: 'resources/skills', + to: 'skills' + }) + } expect(electronBuilderConfig.mac.extraResources).toEqual( expect.arrayContaining([ expect.objectContaining({ diff --git a/config/scripts/generate-skill-bundle-manifest.mjs b/config/scripts/generate-skill-bundle-manifest.mjs new file mode 100644 index 00000000000..c3542d6f0e2 --- /dev/null +++ b/config/scripts/generate-skill-bundle-manifest.mjs @@ -0,0 +1,522 @@ +import { createHash } from 'node:crypto' +import { execFileSync } from 'node:child_process' +import { constants } from 'node:fs' +import { access, lstat, mkdir, readFile, readdir, writeFile } from 'node:fs/promises' +import path from 'node:path' +import process from 'node:process' +import { isDeepStrictEqual } from 'node:util' + +const SCHEMA_VERSION = 1 +const SCRIPT_DIR = import.meta.dirname +const REPO_ROOT = path.resolve(SCRIPT_DIR, '..', '..') +const SKILLS_ROOT = path.join(REPO_ROOT, 'skills') +const OUTPUT_ROOT = path.join(REPO_ROOT, 'resources', 'skills') +const CURRENT_MANIFEST_PATH = path.join(OUTPUT_ROOT, 'current-manifest.json') +const SNAPSHOT_REGISTRY_PATH = path.join(OUTPUT_ROOT, 'snapshot-registry.json') +const RELEASE_MAPPING_PATH = path.join(OUTPUT_ROOT, 'release-mapping.json') + +function sha256(bytes) { + return createHash('sha256').update(bytes).digest('hex') +} + +function compareCodeUnits(left, right) { + return left === right ? 0 : left < right ? -1 : 1 +} + +function gitObjectSha(kind, bytes) { + return createHash('sha1').update(`${kind} ${bytes.length}\0`).update(bytes).digest() +} + +function normalizeText(bytes) { + const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes) + return Buffer.from(text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'), 'utf8') +} + +function classifyFile(bytes) { + if (bytes.includes(0)) { + return 'binary' + } + try { + normalizeText(bytes) + return 'text' + } catch { + return 'binary' + } +} + +function assertSafeRelativePath(relativePath) { + if ( + path.isAbsolute(relativePath) || + relativePath === '..' || + relativePath.startsWith(`..${path.sep}`) + ) { + throw new Error(`Unsafe skill package path: ${relativePath}`) + } +} + +function describeFile(manifestPath, bytes, executable) { + const classification = classifyFile(bytes) + const exactSha256 = sha256(bytes) + const textNormalizedSha256 = classification === 'text' ? sha256(normalizeText(bytes)) : null + return { + path: manifestPath, + size: bytes.length, + executable, + classification, + exactSha256, + textNormalizedSha256, + identitySha256: classification === 'text' && !executable ? textNormalizedSha256 : exactSha256, + gitBlobSha: gitObjectSha('blob', bytes).toString('hex') + } +} + +function gitTreeSha(entries) { + const root = { directories: new Map(), files: [] } + for (const entry of entries) { + const parts = entry.path.split('/') + const filename = parts.pop() + let directory = root + for (const part of parts) { + let child = directory.directories.get(part) + if (!child) { + child = { directories: new Map(), files: [] } + directory.directories.set(part, child) + } + directory = child + } + directory.files.push({ filename, ...entry }) + } + + function hashDirectory(directory) { + const children = [ + ...[...directory.directories].map(([name, child]) => ({ + mode: '40000', + name, + hash: hashDirectory(child) + })), + ...directory.files.map((file) => ({ + mode: file.executable ? '100755' : '100644', + name: file.filename, + hash: Buffer.from(file.gitBlobSha, 'hex') + })) + ].sort((left, right) => { + const leftName = left.mode === '40000' ? `${left.name}/` : left.name + const rightName = right.mode === '40000' ? `${right.name}/` : right.name + return Buffer.from(leftName).compare(Buffer.from(rightName)) + }) + const body = Buffer.concat( + children.map(({ mode, name, hash }) => + Buffer.concat([Buffer.from(`${mode} ${name}\0`, 'utf8'), hash]) + ) + ) + return gitObjectSha('tree', body) + } + + return hashDirectory(root).toString('hex') +} + +async function collectPackageFiles(packageRoot) { + const files = [] + const caseFoldedPaths = new Map() + + async function visit(directory) { + const entries = await readdir(directory, { withFileTypes: true }) + // Why: build-time Node and packaged Electron may ship different ICU data; + // package identity order must use the same locale-independent comparison. + entries.sort((left, right) => compareCodeUnits(left.name, right.name)) + for (const entry of entries) { + const absolutePath = path.join(directory, entry.name) + const relativePath = path.relative(packageRoot, absolutePath) + assertSafeRelativePath(relativePath) + const manifestPath = relativePath.split(path.sep).join('/') + const foldedPath = manifestPath.toLocaleLowerCase('en-US') + const collision = caseFoldedPaths.get(foldedPath) + if (collision && collision !== manifestPath) { + throw new Error(`Case-colliding skill paths: ${collision} and ${manifestPath}`) + } + caseFoldedPaths.set(foldedPath, manifestPath) + const fileStat = await lstat(absolutePath) + if (fileStat.isSymbolicLink()) { + throw new Error(`Symlink is not allowed in a shipped skill: ${manifestPath}`) + } + if (fileStat.isDirectory()) { + await visit(absolutePath) + continue + } + if (!fileStat.isFile()) { + throw new Error(`Special file is not allowed in a shipped skill: ${manifestPath}`) + } + // Why: Windows observation cannot see execute bits, so an executable file in + // a shipped skill would misclassify every pristine Windows install as unrecognized. + if ((fileStat.mode & 0o111) !== 0) { + throw new Error(`Executable file is not allowed in a shipped skill: ${manifestPath}`) + } + files.push(describeFile(manifestPath, await readFile(absolutePath), false)) + } + } + + await visit(packageRoot) + return sortManifestFiles(files) +} + +function collectGitSkillTreeEntries(treeSha) { + const output = execFileSync('git', ['ls-tree', '-r', '-z', treeSha]) + .toString('utf8') + .split('\0') + .filter(Boolean) + const packages = new Map() + for (const line of output) { + const match = /^(\d+) (\w+) ([a-f0-9]+)\t(.+)$/.exec(line) + if (!match) { + throw new Error(`Unexpected git tree entry in ${treeSha}: ${line}`) + } + const [, mode, type, objectSha, sourcePath] = match + const separator = sourcePath.indexOf('/') + if (separator <= 0 || separator === sourcePath.length - 1) { + throw new Error(`Unsupported shipped skill path in ${treeSha}: ${sourcePath}`) + } + const name = sourcePath.slice(0, separator) + const manifestPath = sourcePath.slice(separator + 1) + const entries = packages.get(name) ?? [] + entries.push({ mode, type, objectSha, manifestPath }) + packages.set(name, entries) + } + return packages +} + +function readGitBlobs(objectShas) { + const uniqueShas = [...new Set(objectShas)] + if (uniqueShas.length === 0) { + return new Map() + } + // Why: released history spans hundreds of tags. Batch mode avoids a Git + // subprocess per historical file while remaining available on Git 2.25. + const output = execFileSync('git', ['cat-file', '--batch'], { + input: `${uniqueShas.join('\n')}\n`, + maxBuffer: 64 * 1024 * 1024 + }) + const blobs = new Map() + let offset = 0 + for (const requestedSha of uniqueShas) { + const headerEnd = output.indexOf(10, offset) + if (headerEnd < 0) { + throw new Error(`Missing git cat-file header for ${requestedSha}`) + } + const header = output.subarray(offset, headerEnd).toString('utf8') + const match = /^([a-f0-9]+) blob (\d+)$/.exec(header) + if (!match || match[1] !== requestedSha) { + throw new Error(`Unexpected git cat-file header for ${requestedSha}: ${header}`) + } + const size = Number(match[2]) + const contentStart = headerEnd + 1 + const contentEnd = contentStart + size + if (contentEnd >= output.length || output[contentEnd] !== 10) { + throw new Error(`Truncated git blob for ${requestedSha}`) + } + blobs.set(requestedSha, Buffer.from(output.subarray(contentStart, contentEnd))) + offset = contentEnd + 1 + } + return blobs +} + +function collectGitPackageFiles(treeSha, name, entries, blobs) { + const caseFoldedPaths = new Map() + const files = entries.map(({ mode, type, objectSha, manifestPath }) => { + if (type !== 'blob' || (mode !== '100644' && mode !== '100755')) { + throw new Error(`Unsupported shipped skill entry in ${treeSha}: ${name}/${manifestPath}`) + } + assertSafeRelativePath(manifestPath) + const foldedPath = manifestPath.toLocaleLowerCase('en-US') + const collision = caseFoldedPaths.get(foldedPath) + if (collision && collision !== manifestPath) { + throw new Error(`Case-colliding skill paths in ${treeSha}: ${collision} and ${manifestPath}`) + } + caseFoldedPaths.set(foldedPath, manifestPath) + const bytes = blobs.get(objectSha) + if (!bytes) { + throw new Error(`Missing git blob ${objectSha} for ${name}/${manifestPath}`) + } + return describeFile(manifestPath, bytes, mode === '100755') + }) + // Why: git ls-tree emits git byte-order, not the canonical walk order. + return sortManifestFiles(files) +} + +// Why: snapshot matching compares files by array index, so every producer — +// working-tree walk, git history, and runtime observation — must emit one +// canonical order. This mirrors the sorted depth-first filesystem walk. +function compareManifestPaths(left, right) { + const leftParts = left.split('/') + const rightParts = right.split('/') + const shared = Math.min(leftParts.length, rightParts.length) + for (let index = 0; index < shared; index += 1) { + const order = compareCodeUnits(leftParts[index], rightParts[index]) + if (order !== 0) { + return order + } + } + return leftParts.length - rightParts.length +} + +function sortManifestFiles(files) { + return [...files].sort((left, right) => compareManifestPaths(left.path, right.path)) +} + +function packageDigest(files) { + return sha256( + Buffer.from( + JSON.stringify( + files.map((file) => ({ + path: file.path, + executable: file.executable, + classification: file.classification, + identitySha256: file.identitySha256 + })) + ), + 'utf8' + ) + ) +} + +function releaseTags() { + return execFileSync( + 'git', + ['for-each-ref', '--sort=creatordate', '--format=%(refname:short)', 'refs/tags/v*'], + { encoding: 'utf8' } + ) + .split('\n') + .filter((tag) => /^v\d+\.\d+\.\d+(?:[-.][0-9A-Za-z.-]+)?$/.test(tag)) +} + +function skillsTreeShasAtRefs(refs) { + if (refs.length === 0) { + return [] + } + const output = execFileSync('git', ['cat-file', '--batch-check=%(objectname) %(objecttype)'], { + input: `${refs.map((ref) => `${ref}:skills`).join('\n')}\n`, + encoding: 'utf8' + }) + const lines = output.trimEnd().split('\n') + if (lines.length !== refs.length) { + throw new Error(`Expected ${refs.length} skills tree identities, received ${lines.length}`) + } + return lines.map((line, index) => { + if (line.endsWith(' missing')) { + return null + } + const match = /^([a-f0-9]+) tree$/.exec(line) + if (!match) { + throw new Error(`Unexpected skills tree identity at ${refs[index]}: ${line}`) + } + return match[1] + }) +} + +function buildReleasedHistory() { + const registry = { schemaVersion: SCHEMA_VERSION, skills: {} } + const mapping = { schemaVersion: SCHEMA_VERSION, releases: [] } + const tags = releaseTags() + const treeShas = skillsTreeShasAtRefs(tags) + const distinctTreeShas = [...new Set(treeShas.filter(Boolean))] + const packagesByTree = new Map( + distinctTreeShas.map((treeSha) => [treeSha, collectGitSkillTreeEntries(treeSha)]) + ) + const blobs = readGitBlobs( + [...packagesByTree.values()].flatMap((packages) => + [...packages.values()].flatMap((entries) => entries.map((entry) => entry.objectSha)) + ) + ) + let previousSkillsTreeSha = null + for (const [index, tag] of tags.entries()) { + const skillsTreeSha = treeShas[index] + if (!skillsTreeSha || skillsTreeSha === previousSkillsTreeSha) { + continue + } + previousSkillsTreeSha = skillsTreeSha + const revisions = {} + const packages = packagesByTree.get(skillsTreeSha) + if (!packages) { + throw new Error(`Missing released skill tree ${skillsTreeSha} at ${tag}`) + } + for (const name of [...packages.keys()].sort(compareCodeUnits)) { + const entries = packages.get(name) + const filesWithGitHashes = collectGitPackageFiles(skillsTreeSha, name, entries, blobs) + if (!filesWithGitHashes.some((file) => file.path === 'SKILL.md')) { + continue + } + const digest = packageDigest(filesWithGitHashes) + const snapshots = registry.skills[name] ?? [] + const latest = snapshots.at(-1) + if (!latest || latest.packageDigest !== digest) { + const files = filesWithGitHashes.map(({ gitBlobSha: _gitBlobSha, ...file }) => file) + snapshots.push({ + releaseRevision: (latest?.releaseRevision ?? 0) + 1, + packageDigest: digest, + gitTreeSha: gitTreeSha(filesWithGitHashes), + files + }) + registry.skills[name] = snapshots + } + revisions[name] = snapshots.at(-1).releaseRevision + } + if (Object.keys(revisions).length > 0) { + mapping.releases.push({ appVersion: tag.slice(1), skills: revisions }) + } + } + return { registry, mapping } +} + +async function buildArtifacts(appVersion) { + const { registry, mapping } = buildReleasedHistory() + const releasedSnapshotCounts = Object.fromEntries( + Object.entries(registry.skills).map(([name, snapshots]) => [name, snapshots.length]) + ) + const skillDirectories = (await readdir(SKILLS_ROOT, { withFileTypes: true })) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(compareCodeUnits) + const currentSkills = [] + for (const name of skillDirectories) { + const filesWithGitHashes = await collectPackageFiles(path.join(SKILLS_ROOT, name)) + if (!filesWithGitHashes.some((file) => file.path === 'SKILL.md')) { + throw new Error(`Skill package ${name} has no top-level SKILL.md`) + } + const digest = packageDigest(filesWithGitHashes) + const snapshots = registry.skills[name] ?? [] + const latest = snapshots.at(-1) + let snapshot = latest + if (!latest || latest.packageDigest !== digest) { + const files = filesWithGitHashes.map(({ gitBlobSha: _gitBlobSha, ...file }) => file) + snapshot = { + releaseRevision: (latest?.releaseRevision ?? 0) + 1, + packageDigest: digest, + gitTreeSha: gitTreeSha(filesWithGitHashes), + files + } + snapshots.push(snapshot) + registry.skills[name] = snapshots + } + currentSkills.push({ + name, + sourcePath: `skills/${name}`, + appVersion, + ...snapshot + }) + } + return { + currentManifest: { + schemaVersion: SCHEMA_VERSION, + appVersion, + skills: currentSkills + }, + snapshotRegistry: registry, + releaseMapping: mapping, + releasedSnapshotCounts + } +} + +// Why: released snapshots are the detection ground truth for existing installs, +// so a generation-logic change must not rewrite them silently. Only the one +// unreleased working-tree append per skill may change between runs. +function assertReleasedHistoryPreserved(committedRegistry, artifacts) { + if (!committedRegistry || committedRegistry.schemaVersion !== SCHEMA_VERSION) { + return + } + for (const [name, committedSnapshots] of Object.entries(committedRegistry.skills ?? {})) { + const releasedCount = artifacts.releasedSnapshotCounts[name] ?? 0 + const regenerated = artifacts.snapshotRegistry.skills[name] ?? [] + if (releasedCount < Math.max(0, committedSnapshots.length - 1)) { + throw new Error( + `Released snapshot history is incomplete for ${name}. ` + + 'Fetch all release tags before regenerating skill artifacts.' + ) + } + const protectedCount = Math.min(committedSnapshots.length, releasedCount) + for (let index = 0; index < protectedCount; index += 1) { + const committed = committedSnapshots[index] + const rebuilt = regenerated[index] + if (!rebuilt || !isDeepStrictEqual(rebuilt, committed)) { + throw new Error( + `Released snapshot history changed for ${name} at revision ${committed.releaseRevision}. ` + + 'Released snapshots are append-only; a deliberate identity migration must update this check.' + ) + } + } + } +} + +async function readCommittedRegistry() { + try { + return JSON.parse(await readFile(SNAPSHOT_REGISTRY_PATH, 'utf8')) + } catch { + return null + } +} + +function serialized(value) { + return `${JSON.stringify(value, null, 2)}\n` +} + +async function writeArtifacts(artifacts) { + await mkdir(OUTPUT_ROOT, { recursive: true }) + await Promise.all([ + writeFile(CURRENT_MANIFEST_PATH, serialized(artifacts.currentManifest)), + writeFile(SNAPSHOT_REGISTRY_PATH, serialized(artifacts.snapshotRegistry)), + writeFile(RELEASE_MAPPING_PATH, serialized(artifacts.releaseMapping)) + ]) +} + +async function verifyArtifacts(artifacts) { + const expected = [ + [CURRENT_MANIFEST_PATH, artifacts.currentManifest], + [SNAPSHOT_REGISTRY_PATH, artifacts.snapshotRegistry], + [RELEASE_MAPPING_PATH, artifacts.releaseMapping] + ] + const stale = [] + for (const [filePath, value] of expected) { + try { + await access(filePath, constants.R_OK) + if ((await readFile(filePath, 'utf8')) !== serialized(value)) { + stale.push(filePath) + } + } catch { + stale.push(filePath) + } + } + if (stale.length > 0) { + throw new Error( + `Generated skill artifacts are stale:\n${stale + .map((filePath) => path.relative(REPO_ROOT, filePath)) + .join('\n')}\nRun pnpm generate:skill-bundle-manifest.` + ) + } +} + +async function main() { + const packageJson = JSON.parse(await readFile(path.join(REPO_ROOT, 'package.json'), 'utf8')) + const artifacts = await buildArtifacts(packageJson.version) + assertReleasedHistoryPreserved(await readCommittedRegistry(), artifacts) + await (process.argv.includes('--write') ? writeArtifacts : verifyArtifacts)(artifacts) +} + +if (process.argv[1] && path.resolve(process.argv[1]) === import.meta.filename) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 + }) +} + +export { + assertReleasedHistoryPreserved, + buildArtifacts, + buildReleasedHistory, + classifyFile, + collectPackageFiles, + describeFile, + gitTreeSha, + normalizeText, + packageDigest, + sortManifestFiles, + verifyArtifacts, + writeArtifacts +} diff --git a/config/scripts/generate-skill-bundle-manifest.test.mjs b/config/scripts/generate-skill-bundle-manifest.test.mjs new file mode 100644 index 00000000000..cf4a8a100e5 --- /dev/null +++ b/config/scripts/generate-skill-bundle-manifest.test.mjs @@ -0,0 +1,199 @@ +import { execFileSync } from 'node:child_process' +import { chmod, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + assertReleasedHistoryPreserved, + classifyFile, + collectPackageFiles, + describeFile, + gitTreeSha, + normalizeText, + packageDigest, + sortManifestFiles +} from './generate-skill-bundle-manifest.mjs' + +const temporaryDirectories = [] + +async function createPackage() { + const directory = await mkdtemp(path.join(tmpdir(), 'orca-skill-manifest-')) + temporaryDirectories.push(directory) + return directory +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })) + ) +}) + +describe('skill bundle manifest generator', () => { + it('folds platform line endings for text identity', () => { + const lf = Buffer.from('first\nsecond\n') + const crlf = Buffer.from('first\r\nsecond\r\n') + + expect(classifyFile(lf)).toBe('text') + expect(normalizeText(crlf)).toEqual(lf) + }) + + it('classifies null-containing and invalid UTF-8 content as binary', () => { + expect(classifyFile(Buffer.from([0, 1, 2]))).toBe('binary') + expect(classifyFile(Buffer.from([0xc3, 0x28]))).toBe('binary') + }) + + it('uses normalized text identity but exact executable identity', () => { + const skillFile = describeFile('SKILL.md', Buffer.from('line one\r\nline two\r\n'), false) + const executable = describeFile('run.sh', Buffer.from('#!/bin/sh\r\necho ok\r\n'), true) + + expect(skillFile.identitySha256).toBe(skillFile.textNormalizedSha256) + expect(skillFile.identitySha256).not.toBe(skillFile.exactSha256) + expect(executable.exactSha256).not.toBe(executable.textNormalizedSha256) + expect(executable.identitySha256).toBe(executable.exactSha256) + expect(packageDigest([skillFile, executable])).toMatch(/^[a-f0-9]{64}$/) + }) + + it('orders git-history files identically to the filesystem walk', async () => { + const packageRoot = await createPackage() + await mkdir(path.join(packageRoot, 'sub')) + for (const name of ['apple.md', 'sub.md', 'Zebra.md', path.join('sub', 'inner.txt')]) { + await writeFile(path.join(packageRoot, name), `${name}\n`) + } + const walked = await collectPackageFiles(packageRoot) + + // Why: git ls-tree emits [Zebra.md, apple.md, sub.md, sub/inner.txt]; index-based + // snapshot matching requires history and observation to share one order. + const gitOrdered = ['Zebra.md', 'apple.md', 'sub.md', 'sub/inner.txt'].map((manifestPath) => + walked.find((file) => file.path === manifestPath) + ) + + expect(sortManifestFiles(gitOrdered)).toEqual(walked) + expect(packageDigest(sortManifestFiles(gitOrdered))).toBe(packageDigest(walked)) + expect(walked.map((file) => file.path)).toEqual([ + 'Zebra.md', + 'apple.md', + 'sub/inner.txt', + 'sub.md' + ]) + }) + + it('rejects rewrites of released snapshots and allows floating-tail replacement', () => { + const snapshot = (releaseRevision, packageDigest) => ({ releaseRevision, packageDigest }) + const artifacts = { + releasedSnapshotCounts: { 'orca-cli': 2 }, + snapshotRegistry: { + schemaVersion: 1, + skills: { 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'bbb'), snapshot(3, 'ccc')] } + } + } + + expect(() => + assertReleasedHistoryPreserved( + { schemaVersion: 1, skills: { 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'bbb')] } }, + artifacts + ) + ).not.toThrow() + expect(() => + assertReleasedHistoryPreserved( + { + schemaVersion: 1, + skills: { 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'bbb'), snapshot(3, 'stale')] } + }, + artifacts + ) + ).not.toThrow() + expect(() => + assertReleasedHistoryPreserved( + { + schemaVersion: 1, + skills: { 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'rewritten')] } + }, + artifacts + ) + ).toThrow('Released snapshot history changed for orca-cli at revision 2') + expect(() => + assertReleasedHistoryPreserved( + { + schemaVersion: 1, + skills: { + 'orca-cli': [snapshot(1, 'aaa'), { ...snapshot(2, 'bbb'), gitTreeSha: 'rewritten' }] + } + }, + artifacts + ) + ).toThrow('Released snapshot history changed for orca-cli at revision 2') + expect(() => + assertReleasedHistoryPreserved( + { + schemaVersion: 1, + skills: { + 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'bbb'), snapshot(3, 'stale')] + } + }, + { ...artifacts, releasedSnapshotCounts: { 'orca-cli': 1 } } + ) + ).toThrow('Released snapshot history is incomplete for orca-cli') + expect(() => assertReleasedHistoryPreserved(null, artifacts)).not.toThrow() + }) + + it.runIf(process.platform !== 'win32')( + 'rejects executable files in shipped skill packages', + async () => { + const packageRoot = await createPackage() + await writeFile(path.join(packageRoot, 'SKILL.md'), 'skill\n') + await writeFile(path.join(packageRoot, 'run.sh'), '#!/bin/sh\necho ok\n') + await chmod(path.join(packageRoot, 'run.sh'), 0o755) + + await expect(collectPackageFiles(packageRoot)).rejects.toThrow( + 'Executable file is not allowed in a shipped skill: run.sh' + ) + } + ) + + it.runIf(process.platform === 'linux')('rejects case-colliding paths', async () => { + const packageRoot = await createPackage() + await writeFile(path.join(packageRoot, 'SKILL.md'), 'skill') + await writeFile(path.join(packageRoot, 'Readme.md'), 'one') + await writeFile(path.join(packageRoot, 'README.md'), 'two') + + await expect(collectPackageFiles(packageRoot)).rejects.toThrow('Case-colliding skill paths') + }) + + it.runIf(process.platform !== 'win32')('rejects symlinks inside shipped packages', async () => { + const packageRoot = await createPackage() + await writeFile(path.join(packageRoot, 'SKILL.md'), 'skill') + await symlink('SKILL.md', path.join(packageRoot, 'linked.md')) + + await expect(collectPackageFiles(packageRoot)).rejects.toThrow( + 'Symlink is not allowed in a shipped skill' + ) + }) + + it('computes the same Git tree identity as Git', async () => { + const packageRoot = path.resolve('skills', 'orca-cli') + const files = await collectPackageFiles(packageRoot) + const expected = execFileSync('git', ['ls-tree', 'HEAD:skills', 'orca-cli'], { + encoding: 'utf8' + }) + .trim() + .split(/\s+/)[2] + + expect(gitTreeSha(files)).toBe(expected) + }) + + it('matches Git when a directory and file share a name prefix', async () => { + const packageRoot = await createPackage() + await mkdir(path.join(packageRoot, 'sub')) + await writeFile(path.join(packageRoot, 'sub', 'inner.txt'), 'nested\n') + await writeFile(path.join(packageRoot, 'sub.md'), 'sibling\n') + const files = await collectPackageFiles(packageRoot) + execFileSync('git', ['init', '--quiet'], { cwd: packageRoot }) + execFileSync('git', ['add', '-A'], { cwd: packageRoot }) + const expected = execFileSync('git', ['write-tree'], { + cwd: packageRoot, + encoding: 'utf8' + }).trim() + + expect(gitTreeSha(files)).toBe(expected) + }) +}) diff --git a/config/scripts/serve-sim-seen-keys-leak-benchmark.mjs b/config/scripts/serve-sim-seen-keys-leak-benchmark.mjs deleted file mode 100644 index 512191599d0..00000000000 --- a/config/scripts/serve-sim-seen-keys-leak-benchmark.mjs +++ /dev/null @@ -1,93 +0,0 @@ -#!/usr/bin/env node -// Benchmark: main-process retained-Set growth of the serve-sim state watcher's -// external-helper dedupe across worktree switches. -// -// emitIfExternal() records one entry per (worktreeId, deviceUdid, pid, wsUrl, -// streamUrl) tuple in `seenExternalKeys` so a helper is only auto-tabbed once. -// Before the fix, forgetWorktree() pruned ptyToWorktree and ptyBuffers but never -// touched seenExternalKeys, so its keys accumulated for the renderer/main -// process lifetime — only cleared at app shutdown (stop()). This script -// simulates N bind→detect→forget cycles and reports retained Set size with the -// prune disabled vs enabled. -import { performance } from 'node:perf_hooks' - -const CYCLES = Number.parseInt(process.env.ORCA_SEEN_KEYS_BENCH_CYCLES ?? '5000', 10) -const HELPERS_PER_WORKTREE = Number.parseInt(process.env.ORCA_SEEN_KEYS_BENCH_HELPERS ?? '2', 10) - -for (const [name, value] of [ - ['ORCA_SEEN_KEYS_BENCH_CYCLES', CYCLES], - ['ORCA_SEEN_KEYS_BENCH_HELPERS', HELPERS_PER_WORKTREE] -]) { - if (!Number.isInteger(value) || value <= 0) { - throw new Error(`${name} must be a positive integer, received ${value}`) - } -} - -// Mirror of the watcher's dedupe bookkeeping (serve-sim-state-watcher.ts): a -// Set of `${worktreeId}::${instanceKey}` keys, plus the prefix-prune the fix -// adds to forgetWorktree(). -function makeWatcher({ prune }) { - const seenExternalKeys = new Set() - return { - set: seenExternalKeys, - emitIfExternal(worktreeId, instanceKey) { - const dedupeKey = `${worktreeId}::${instanceKey}` - if (seenExternalKeys.has(dedupeKey)) { - return false - } - seenExternalKeys.add(dedupeKey) - return true - }, - forgetWorktree(worktreeId) { - if (!prune) { - return - } - const prefix = `${worktreeId}::` - for (const key of seenExternalKeys) { - if (key.startsWith(prefix)) { - seenExternalKeys.delete(key) - } - } - } - } -} - -function run({ prune }) { - const watcher = makeWatcher({ prune }) - const start = performance.now() - for (let cycle = 0; cycle < CYCLES; cycle++) { - const worktreeId = `worktree-${cycle}` - for (let h = 0; h < HELPERS_PER_WORKTREE; h++) { - // A realistic instance key: device udid :: pid :: wsUrl :: streamUrl. - const instanceKey = `udid-${cycle}-${h}::pid-${1000 + h}::ws://127.0.0.1:${3100 + h}/ws::http://127.0.0.1:${3100 + h}/stream.mjpeg` - watcher.emitIfExternal(worktreeId, instanceKey) - } - watcher.forgetWorktree(worktreeId) - } - return { retained: watcher.set.size, elapsedMs: performance.now() - start } -} - -console.log( - `serve-sim seenExternalKeys leak benchmark: ${CYCLES} worktree open/forget cycles, ` + - `${HELPERS_PER_WORKTREE} helper(s)/worktree\n` -) - -const before = run({ prune: false }) -const after = run({ prune: true }) - -// Each key is ~120-160 bytes (worktreeId + udid + pid + two URLs). -const APPROX_BYTES_PER_KEY = 140 -const leakedKb = (before.retained * APPROX_BYTES_PER_KEY) / 1024 - -console.log( - `before (no prune) retained=${before.retained} keys ` + - `(~${leakedKb.toFixed(0)} KiB) time=${before.elapsedMs.toFixed(1)}ms` -) -console.log( - `after (prune) retained=${after.retained} keys ` + - `(~0 KiB) time=${after.elapsedMs.toFixed(1)}ms` -) -console.log( - `\nBounded the dedupe Set to live worktrees: ${before.retained} → ${after.retained} ` + - `retained keys after ${CYCLES} worktree switches.` -) diff --git a/config/scripts/verify-skill-update-roundtrip.mjs b/config/scripts/verify-skill-update-roundtrip.mjs new file mode 100644 index 00000000000..bfb10a50494 --- /dev/null +++ b/config/scripts/verify-skill-update-roundtrip.mjs @@ -0,0 +1,249 @@ +import { execFileSync } from 'node:child_process' +import { + chmod, + cp, + lstat, + mkdir, + mkdtemp, + readFile, + realpath, + rm, + symlink, + writeFile +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import process from 'node:process' +import { collectPackageFiles, packageDigest } from './generate-skill-bundle-manifest.mjs' + +function option(name) { + return process.argv.find((value) => value.startsWith(`--${name}=`))?.slice(name.length + 3) +} + +const cliVersion = option('cli') +const autocrlf = option('autocrlf') +const shape = option('shape') +// Why: PR branch names are untrusted workflow input. Keep them out of the +// generated shell command and pass them to Node through the environment. +const source = option('source') ?? process.env.SKILL_UPDATE_SOURCE +const ref = option('ref') ?? process.env.SKILL_UPDATE_REF +if ( + !cliVersion || + (autocrlf !== 'true' && autocrlf !== 'false') || + (shape !== 'symlink' && shape !== 'copy') || + !source || + !ref || + !/^[^/\s]+\/[^/\s]+$/.test(source) +) { + throw new Error( + 'Usage: verify-skill-update-roundtrip.mjs --cli= --autocrlf=true|false --shape=symlink|copy --source= --ref=' + ) +} + +const sandbox = await mkdtemp(path.join(tmpdir(), 'orca-skill-update-roundtrip-')) +const home = path.join(sandbox, 'home') +const stateHome = path.join(home, '.state') +const fakeBin = path.join(sandbox, 'bin') +const targetName = 'orca-cli' +const controlName = 'orchestration' +const manifest = JSON.parse(await readFile('resources/skills/current-manifest.json', 'utf8')) +const registry = JSON.parse(await readFile('resources/skills/snapshot-registry.json', 'utf8')) +const releaseMapping = JSON.parse(await readFile('resources/skills/release-mapping.json', 'utf8')) + +function currentSkill(name) { + const skill = manifest.skills.find((entry) => entry.name === name) + if (!skill) { + throw new Error(`Current manifest is missing ${name}`) + } + return skill +} + +function historicalRelease(name) { + const current = currentSkill(name) + for (const release of releaseMapping.releases.toReversed()) { + const revision = release.skills[name] + if (typeof revision !== 'number' || revision >= current.releaseRevision) { + continue + } + const snapshot = registry.skills[name]?.find((entry) => entry.releaseRevision === revision) + if (snapshot) { + return { tag: `v${release.appVersion}`, snapshot } + } + } + throw new Error(`No historical released snapshot is available for ${name}`) +} + +async function materializePackage(name, tag, destination) { + const prefix = `skills/${name}/` + const entries = execFileSync('git', ['ls-tree', '-r', '-z', tag, '--', `skills/${name}`]) + .toString('utf8') + .split('\0') + .filter(Boolean) + if (entries.length === 0) { + throw new Error(`${tag} does not contain ${name}`) + } + for (const entry of entries) { + const match = /^(\d+) (\w+) ([a-f0-9]+)\t(.+)$/.exec(entry) + if (!match || match[2] !== 'blob') { + throw new Error(`Unsupported historical tree entry: ${entry}`) + } + const relativePath = match[4].slice(prefix.length) + const destinationPath = path.join(destination, ...relativePath.split('/')) + await mkdir(path.dirname(destinationPath), { recursive: true }) + await writeFile(destinationPath, execFileSync('git', ['cat-file', 'blob', match[3]])) + if (process.platform !== 'win32' && match[1] === '100755') { + await chmod(destinationPath, 0o755) + } + } +} + +async function seedPlacement(name, tag) { + const canonical = path.join(home, '.agents', 'skills', name) + await materializePackage(name, tag, canonical) + const providerRoot = path.join(home, '.claude', 'skills') + const provider = path.join(providerRoot, name) + await mkdir(providerRoot, { recursive: true }) + await (shape === 'copy' + ? cp(canonical, provider, { recursive: true }) + : symlink(canonical, provider, process.platform === 'win32' ? 'junction' : 'dir')) +} + +async function installFakeAgentCommands() { + await mkdir(fakeBin, { recursive: true }) + for (const name of ['codex', 'claude']) { + const executable = path.join(fakeBin, process.platform === 'win32' ? `${name}.cmd` : name) + await writeFile( + executable, + process.platform === 'win32' ? '@exit /b 0\r\n' : '#!/bin/sh\nexit 0\n' + ) + if (process.platform !== 'win32') { + await chmod(executable, 0o755) + } + } +} + +async function packageDigestAt(pathValue) { + return packageDigest(await collectPackageFiles(pathValue)) +} + +async function assertCurrentCanonical(name) { + const expected = currentSkill(name).packageDigest + const canonical = path.join(home, '.agents', 'skills', name) + if ((await packageDigestAt(canonical)) !== expected) { + throw new Error(`${name} canonical placement did not update to the PR content`) + } +} + +function execSkills(args) { + const executable = process.platform === 'win32' ? (process.env.ComSpec ?? 'cmd.exe') : 'npx' + const cliArgs = ['--yes', `skills@${cliVersion}`, ...args] + execFileSync( + executable, + process.platform === 'win32' ? ['/d', '/s', '/c', 'npx.cmd', ...cliArgs] : cliArgs, + { + cwd: process.cwd(), + env: { + ...process.env, + HOME: home, + USERPROFILE: home, + CODEX_HOME: path.join(home, '.codex'), + CLAUDE_CONFIG_DIR: path.join(home, '.claude'), + XDG_STATE_HOME: stateHome, + GIT_CONFIG_COUNT: '1', + GIT_CONFIG_KEY_0: 'core.autocrlf', + GIT_CONFIG_VALUE_0: autocrlf, + PATH: `${fakeBin}${path.delimiter}${process.env.PATH ?? ''}`, + CI: '1' + }, + stdio: 'inherit' + } + ) +} + +try { + const targetHistorical = historicalRelease(targetName) + const controlHistorical = historicalRelease(controlName) + await installFakeAgentCommands() + await mkdir(path.join(home, '.codex'), { recursive: true }) + await mkdir(path.join(home, '.claude'), { recursive: true }) + await seedPlacement(targetName, targetHistorical.tag) + await seedPlacement(controlName, controlHistorical.tag) + const targetProvider = path.join(home, '.claude', 'skills', targetName) + const controlCanonical = path.join(home, '.agents', 'skills', controlName) + const controlProvider = path.join(home, '.claude', 'skills', controlName) + const targetProviderBefore = await packageDigestAt(await realpath(targetProvider)) + const controlBefore = await packageDigestAt(controlCanonical) + const controlProviderBefore = await packageDigestAt(await realpath(controlProvider)) + + const timestamp = new Date().toISOString() + const lock = { + version: 3, + skills: { + [targetName]: { + source, + sourceType: 'github', + sourceUrl: `https://github.com/${source}.git`, + ref, + skillPath: `skills/${targetName}/SKILL.md`, + skillFolderHash: targetHistorical.snapshot.gitTreeSha, + installedAt: timestamp, + updatedAt: timestamp + }, + [controlName]: { + source, + sourceType: 'github', + sourceUrl: `https://github.com/${source}.git`, + ref, + skillPath: `skills/${controlName}/SKILL.md`, + skillFolderHash: controlHistorical.snapshot.gitTreeSha, + installedAt: timestamp, + updatedAt: timestamp + } + } + } + const lockPath = path.join(stateHome, 'skills', '.skill-lock.json') + await mkdir(path.dirname(lockPath), { recursive: true }) + await writeFile(lockPath, `${JSON.stringify(lock, null, 2)}\n`) + + // Why: this is the exact user-visible rail. A bare update would include + // unrelated vendors, while this command must leave the control skill alone. + execSkills(['update', targetName, '--global']) + await assertCurrentCanonical(targetName) + const targetProviderAfter = await packageDigestAt(await realpath(targetProvider)) + const targetProviderStat = await lstat(targetProvider) + if (shape === 'symlink' && !targetProviderStat.isSymbolicLink()) { + throw new Error(`${targetName} provider alias was replaced with an independent copy`) + } + if (shape === 'symlink' && targetProviderAfter !== currentSkill(targetName).packageDigest) { + throw new Error(`${targetName} provider alias did not converge with the canonical update`) + } + if ( + shape === 'copy' && + targetProviderAfter !== targetProviderBefore && + targetProviderAfter !== currentSkill(targetName).packageDigest + ) { + throw new Error('Independent provider copy changed to an unexpected package identity') + } + if (shape === 'copy') { + // Why: hosted 1.5.17 replaces copies with aliases while equivalent local runs + // retain the copy. Both prove this input topology must remain ineligible. + const outcome = targetProviderStat.isSymbolicLink() + ? 'converged to an alias' + : targetProviderAfter === targetProviderBefore + ? 'remained a historical copy' + : 'converged as a copy' + console.log(`[skill-update-roundtrip] independent copy ${outcome}`) + } + if ((await packageDigestAt(controlCanonical)) !== controlBefore) { + throw new Error('Targeted update changed the non-targeted control skill') + } + if ((await packageDigestAt(await realpath(controlProvider))) !== controlProviderBefore) { + throw new Error('Targeted update changed the non-targeted control provider placement') + } + const controlProviderStat = await lstat(controlProvider) + if (shape === 'symlink' && !controlProviderStat.isSymbolicLink()) { + throw new Error('Targeted update changed the non-targeted control topology') + } +} finally { + await rm(sandbox, { recursive: true, force: true }) +} diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index ee1674f4124..417924fc997 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 5.6m + + downloads: 5.8m @@ -15,7 +15,7 @@ downloads downloads - 5.6m - 5.6m + 5.8m + 5.8m diff --git a/docs/assets/star-history.png b/docs/assets/star-history.png index 39d06509590..e07ed23112f 100644 Binary files a/docs/assets/star-history.png and b/docs/assets/star-history.png differ diff --git a/docs/assets/wechat-qr-backup.jpg b/docs/assets/wechat-qr-backup.jpg deleted file mode 100644 index a6392babbce..00000000000 Binary files a/docs/assets/wechat-qr-backup.jpg and /dev/null differ diff --git a/docs/assets/wechat-qr.jpg b/docs/assets/wechat-qr.jpg new file mode 100644 index 00000000000..0b4598e0f88 Binary files /dev/null and b/docs/assets/wechat-qr.jpg differ diff --git a/docs/assets/wechat-qr.png b/docs/assets/wechat-qr.png deleted file mode 100644 index bf270d857e8..00000000000 Binary files a/docs/assets/wechat-qr.png and /dev/null differ diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index 1d831afd402..0116866f048 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -235,10 +235,9 @@ yay -S stably-orca-bin - **Discord:** 加入 **[Discord](https://discord.gg/fzjDKHxv8Q)** 社区。 - **Twitter / X:** 关注 **[@orca_build](https://x.com/orca_build)** 获取更新和公告。 -- **微信:** 扫描二维码加入社区。如果第一个群已满,请使用备用微信群。 +- **微信:** 第一个微信群已满,现在可以加入第二个群。 - Orca 社区微信群二维码 - Orca 社区备用微信群二维码 + Orca 社区微信群二维码 - **反馈与想法:** 我们发布很快。缺少什么功能?[提交功能请求](https://github.com/stablyai/orca/issues)。 - **隐私:** 查看[隐私与遥测文档](https://www.onorca.dev/docs/telemetry),了解 Orca 收集哪些匿名使用数据以及如何退出。 diff --git a/docs/reference/terminal-session-lifecycle.md b/docs/reference/terminal-session-lifecycle.md index 3f86c687245..0b11fd15557 100644 --- a/docs/reference/terminal-session-lifecycle.md +++ b/docs/reference/terminal-session-lifecycle.md @@ -46,6 +46,22 @@ persisted physical-to-owner pane-key alias for detach, plus a live Electron parked-close gate. Focused tests cover local, SSH, split, parked, shared, paired-host, ordinary runtime, pane-transfer, and late-spawn paths. +`orca terminal close --tab` extends that same ownership boundary to automation. +With an attached renderer, main sends a request/reply close, the renderer runs +the canonical retirement path, persists a fresh host-partitioned session, and +forces the main store to disk before acknowledging success. Without a renderer, +the runtime applies the equivalent immutable projection to the persisted +aggregate tab, removes every split binding and resume record, safely selects a +surviving browser/editor/terminal surface, stops exact live or durable SSH PTYs, +and flushes before returning. The ordinary `terminal close` pane/session +contract is unchanged. + +This durability boundary does not use replay fences or tombstones. Renderer +session writes are ordered through the same IPC sender, and a pending debounced +write rebuilds from the latest store state, so the forced fresh snapshot is not +followed by a captured pre-close topology patch. Headless closes update the +single main-owned session projection directly. + Origin-aware defensive expiry remains a follow-up. Immediate deletion on explicit close fixes the incident without applying a wall-clock policy that could invalidate intentional long-lived `worktree-sleep` checkpoints. diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index d4d1b35896f..0251da70490 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -26,10 +26,8 @@ import { Bot, ChevronDown, ChevronLeft, - ChevronRight, ChevronsRight, Copy, - Eraser, Folder, File, FileText, @@ -174,6 +172,7 @@ import { type MobileNewTabAgentSettings } from '../../../../src/session/mobile-new-tab-agent-options' import { useMobileImageAttachment } from '../../../../src/session/use-mobile-image-attachment' +import { useMobileAttachmentInputLeaseGate } from '../../../../src/session/use-mobile-attachment-input-lease-gate' 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' @@ -198,6 +197,15 @@ import { isDictationSetupRequiredError } from '../../../../src/dictation/mobile-dictation-setup' import { TerminalPaneView } from '../../../../src/session/TerminalPaneView' +import { MobileNativeChatOverlay } from '../../../../src/session/MobileNativeChatOverlay' +import { MobileBrowserTabActionSheet } from '../../../../src/session/MobileBrowserTabActionSheet' +import { useMobileNativeChatController } from '../../../../src/session/use-mobile-native-chat-controller' +import { useMobileNativeChatReadability } from '../../../../src/session/use-mobile-native-chat-readability' +import { useMobileNativeChatInputLease } from '../../../../src/session/use-mobile-native-chat-input-lease' +import { getMobileTerminalActionSheetActions } from '../../../../src/session/mobile-terminal-action-sheet-actions' +import * as nativeChatTerminalStream from '../../../../src/session/mobile-native-chat-terminal-stream' +import { useMobileNativeChatTerminalStream } from '../../../../src/session/use-mobile-native-chat-terminal-stream' +import { subscribeMobileTerminalSafely } from '../../../../src/session/mobile-terminal-stream-subscribe' import { activateMobileSessionTab, focusMobileTerminal @@ -249,8 +257,6 @@ import type { TerminalGestureInputQueue } from './mobile-session-route-types' -type TerminalLiveAccessoryInput = ReturnType - const TERMINAL_KEYBOARD_DISMISS_ACTION_SHEET_FALLBACK_MS = 450 function MarkdownReader({ @@ -1167,11 +1173,48 @@ export default function SessionScreen() { }, [clearToastHideTimer] ) + const showNativeChatSendError = useCallback( + (message: string) => showToast(message, 1600), + [showToast] + ) + const nativeChatTranscriptIsLocalReadable = useMobileNativeChatReadability(client, worktreeId) + const { + ready: nativeChatInputLeaseReady, + readyRef: nativeChatInputLeaseReadyRef, + lockReason: nativeChatInputLockReason, + markReady: markNativeChatInputLeaseReady, + clear: clearNativeChatInputLease + } = useMobileNativeChatInputLease({ + activeHandle, + connected: connState === 'connected' + }) + const nativeChatController = useMobileNativeChatController({ + client, + hostId, + worktreeId, + activeSessionTab, + activeSessionTabId, + activeHandleRef, + deviceTokenRef, + nativeChatTranscriptIsLocalReadable, + nativeChatInputLeaseReady, + onSendError: showNativeChatSendError + }) + const { chatTabIds, toggleTabChatView, showNativeChat, showNativeChatRef } = nativeChatController const dictation = useMobileDictation({ client, enabled: canSend, onTranscript: (text) => { + // Why: dictation belongs to the visible composer. Native chat consumes it + // locally; terminal mode retains the live-input routing and flush contract. + if (showNativeChatRef.current) { + nativeChatController.setChatComposerText((current) => + appendBufferedDictation(current, text) + ) + showToast('Dictation inserted') + return + } // Live mode inserts the transcript straight into its originating PTY as // text (no Return — the user sends it themselves), matching live keystroke // semantics; buffered mode keeps appending to the command field. @@ -1290,22 +1333,27 @@ export default function SessionScreen() { return handle ? terminalRefs.current.get(handle) : undefined }, []) - const unsubscribeTerminal = useCallback((handle: string) => { - terminalUnsubsRef.current.get(handle)?.() - terminalUnsubsRef.current.delete(handle) - subscribingHandlesRef.current.delete(handle) - terminalDiagnosticsRef.current.terminalUnsubscribed(handle) - subscribeSeqRef.current.set(handle, (subscribeSeqRef.current.get(handle) ?? 0) + 1) - // Why: a fresh subscription will land on a new server-side state machine - // run (or the same one with a higher seq); reset the high-water mark so - // the first scrollback isn't accidentally dropped as stale. - layoutSeqRef.current.delete(handle) - }, []) + const unsubscribeTerminal = useCallback( + (handle: string) => { + terminalUnsubsRef.current.get(handle)?.() + terminalUnsubsRef.current.delete(handle) + subscribingHandlesRef.current.delete(handle) + terminalDiagnosticsRef.current.terminalUnsubscribed(handle) + subscribeSeqRef.current.set(handle, (subscribeSeqRef.current.get(handle) ?? 0) + 1) + // Why: a fresh subscription will land on a new server-side state machine + // run (or the same one with a higher seq); reset the high-water mark so + // the first scrollback isn't accidentally dropped as stale. + layoutSeqRef.current.delete(handle) + clearNativeChatInputLease(handle) + }, + [clearNativeChatInputLease] + ) + const unsubscribeTerminalRef = useRef(unsubscribeTerminal) + unsubscribeTerminalRef.current = unsubscribeTerminal const clearTerminalCache = useCallback(() => { - for (const unsub of terminalUnsubsRef.current.values()) { - unsub() - } + terminalUnsubsRef.current.forEach((unsub) => unsub()) + clearNativeChatInputLease() terminalUnsubsRef.current.clear() subscribingHandlesRef.current.clear() initializedHandlesRef.current.clear() @@ -1318,7 +1366,7 @@ export default function SessionScreen() { for (const term of terminalRefs.current.values()) { term.clear() } - }, []) + }, [clearNativeChatInputLease]) // Why: measures the phone viewport once from the first available TerminalWebView. // The viewport dims are passed with every subscribe call so the server can @@ -1357,13 +1405,22 @@ export default function SessionScreen() { logSkippedGate('subscribe-in-flight') return } - if (!getTerminalRef(handle)) { - logSkippedGate('no-webview-ref') - return - } - if (!webReadyHandlesRef.current.has(handle)) { - logSkippedGate('webview-not-ready') - return + const covered = nativeChatTerminalStream.isTerminalCoveredByNativeChat( + showNativeChatRef.current, + activeHandleRef.current, + handle + ) + // Why: a native-chat-covered terminal subscribes as the input-floor lease + // without a mounted xterm webview, so only gate on the webview when NOT covered. + if (!covered) { + if (!getTerminalRef(handle)) { + logSkippedGate('no-webview-ref') + return + } + if (!webReadyHandlesRef.current.has(handle)) { + logSkippedGate('webview-not-ready') + return + } } subscribingHandlesRef.current.add(handle) @@ -1375,13 +1432,13 @@ export default function SessionScreen() { // The viewport is embedded in the subscribe params so the server resizes // the PTY before serializing scrollback. This eliminates the focus→safeFit // race and the measure→resize→resubscribe pipeline. - const unsub = client.subscribe( - 'terminal.subscribe', + const unsub = subscribeMobileTerminalSafely( + client, { terminal: handle, client: { id: deviceTokenRef.current!, type: 'mobile' as const }, viewport: viewportRef.current ?? undefined, - capabilities: { terminalBinaryStream: 1 } + capabilities: nativeChatTerminalStream.mobileNativeChatTerminalCapabilities(covered) }, (result) => { if (subscribeSeqRef.current.get(handle) !== seq) { @@ -1389,6 +1446,25 @@ export default function SessionScreen() { } const data = result as Record diagnostics.firstStreamEvent(handle, seq, data.type) + if (data.type === 'end' || data.type === 'error') { + unsubscribeTerminalRef.current(handle) + return + } + if (data.type === 'subscribed') { + markNativeChatInputLeaseReady(handle) + return + } + // Why: retain the subscription as the mobile input-floor lease, but + // do not mutate covered xterm state; return-to-terminal resubscribes. + if ( + nativeChatTerminalStream.isTerminalCoveredByNativeChat( + showNativeChatRef.current, + activeHandleRef.current, + handle + ) + ) { + return + } // Why: stale-event filter. Server-side state machine bumps a // monotonic seq on every applyLayout. Drop `resized` events // whose seq is strictly older than what we've already observed @@ -1417,9 +1493,6 @@ export default function SessionScreen() { } else if (eventSeq != null && data.type === 'scrollback') { layoutSeqRef.current.set(handle, eventSeq) } - if (data.type === 'subscribed') { - return - } if (data.type === 'scrollback') { diagnostics.streamScrollback(handle, seq, eventSeq, data) if (initializedHandlesRef.current.has(handle)) { @@ -1572,7 +1645,8 @@ export default function SessionScreen() { } scheduleDelayedAction(() => getTerminalRef(handle)?.resetZoom(), 200) } - } + }, + () => unsubscribeTerminalRef.current(handle) ) if (subscribeSeqRef.current.get(handle) === seq) { @@ -1582,9 +1656,21 @@ export default function SessionScreen() { } subscribingHandlesRef.current.delete(handle) }, - [client, getTerminalRef, scheduleDelayedAction] + [client, getTerminalRef, markNativeChatInputLeaseReady, scheduleDelayedAction] ) + const notifyTerminalWebReady = useMobileNativeChatTerminalStream({ + showNativeChat, + activeHandle, + activeTabType: activeSessionTab?.type ?? null, + subscriptionsRef: terminalUnsubsRef, + subscribingRef: subscribingHandlesRef, + webReadyRef: webReadyHandlesRef, + initializedRef: initializedHandlesRef, + subscribe: subscribeToTerminal, + unsubscribe: unsubscribeTerminal + }) + // Why: toggles between phone and desktop mode via server RPC. The server // handles the actual resize and emits a 'resized' event on the existing // subscription stream — no client-side state tracking needed. @@ -3064,6 +3150,7 @@ export default function SessionScreen() { (handle: string) => { const wasAlreadyReady = webReadyHandlesRef.current.has(handle) webReadyHandlesRef.current.add(handle) + notifyTerminalWebReady(handle, wasAlreadyReady) terminalDiagnosticsRef.current.webViewReady( handle, wasAlreadyReady, @@ -3105,7 +3192,7 @@ export default function SessionScreen() { })() } }, - [getTerminalRef, measureViewportOnce, subscribeToTerminal, unsubscribeTerminal] + [measureViewportOnce, notifyTerminalWebReady, subscribeToTerminal, unsubscribeTerminal] ) useEffect(() => { @@ -3156,7 +3243,7 @@ export default function SessionScreen() { } } - async function handleAccessoryKey(input: TerminalLiveAccessoryInput) { + async function handleAccessoryKey(input: ReturnType) { if (!client || !activeHandle || !canSend) { return } @@ -3643,7 +3730,7 @@ export default function SessionScreen() { } }, []) const startAccessoryRepeat = useCallback( - (input: TerminalLiveAccessoryInput) => { + (input: ReturnType) => { stopAccessoryRepeat() repeatTimeoutRef.current = setTimeout(() => { repeatIntervalRef.current = setInterval(() => { @@ -3815,19 +3902,14 @@ export default function SessionScreen() { showToast }) - const flushPendingLiveInputBeforeAttachmentSend = useCallback( - async (targetHandle: string): Promise => { - const flushedPendingInput = await flushPendingLiveInputBeforeExternalSend(targetHandle) - // Why: image picking/upload and IME flushing can outlive the original tab. - return ( - flushedPendingInput && - connStateRef.current === 'connected' && - targetHandle === activeHandleRef.current && - activeSessionTabTypeRef.current === 'terminal' - ) - }, - [flushPendingLiveInputBeforeExternalSend] - ) + const flushPendingLiveInputBeforeAttachmentSend = useMobileAttachmentInputLeaseGate({ + flushPendingLiveInputBeforeExternalSend, + connStateRef, + activeHandleRef, + activeSessionTabTypeRef, + nativeChatInputLeaseReadyRef, + showToast + }) const { attachImage, isAttaching } = useMobileImageAttachment({ client, @@ -4887,6 +4969,18 @@ export default function SessionScreen() { onOpenUrl={handleTerminalOpenUrl} /> ))} + void attachImage('library')} + isAttaching={isAttaching} + onMicPress={handleDictationToggle} + micActive={dictation.isRecording} + dictationMode={dictationMode} + onMicPressIn={handleDictationPressIn} + onMicPressOut={handleDictationPressOut} + inputLockReason={nativeChatInputLockReason} + keyboardInset={keyboardLift} + /> {toastMessage && ( {toastMessage} @@ -4896,8 +4990,9 @@ export default function SessionScreen() { )} {/* Why: translate instead of resizing so keyboard open/close does not - trigger a server-side PTY viewport change. */} - {!activeMarkdownTab && !activeFileTab && !activeBrowserTab && ( + trigger a server-side PTY viewport change. The dock hides in native + chat because that view supplies its own composer. */} + {!activeMarkdownTab && !activeFileTab && !activeBrowserTab && !showNativeChat && ( { - const target = actionTarget - setActionTarget(null) - if (target) { - void toggleDisplayMode(target.handle) - } - } - } - ] - : []), - { - label: 'Rename', - onPress: () => { - const target = actionTarget - setActionTarget(null) - if (target) { - setRenameTarget(target) - } - } - }, - { - label: 'Clear Terminal', - icon: Eraser, - onPress: () => { - const target = actionTarget - setActionTarget(null) - if (target) { - void handleClearTerminal(target) - } - } - }, - { - label: 'Close', - destructive: true, - onPress: () => { - const target = actionTarget - setActionTarget(null) - if (target) { - void handleCloseTerminal(target) - } - } - } - ]} + actions={getMobileTerminalActionSheetActions({ + target: actionTarget, + tabs: sessionTabs.filter((tab) => tab.type === 'terminal'), + chatTabIds, + nativeChatTranscriptIsLocalReadable, + onDismiss: () => setActionTarget(null), + onToggleChat: toggleTabChatView, + isPhoneMode, + onToggleDisplayMode: (handle) => void toggleDisplayMode(handle), + onRename: setRenameTarget, + onClear: (target) => void handleClearTerminal(target), + onClose: (target) => void handleCloseTerminal(target) + })} onClose={() => setActionTarget(null)} /> setFileActionTarget(null)} /> - { - const target = browserActionTarget - setBrowserActionTarget(null) - if (target) { - void handleBrowserNavigationCommand(target, 'browser.back') - } - } - } - ] - : []), - ...(browserActionTarget?.canGoForward - ? [ - { - label: 'Forward', - icon: ChevronRight, - onPress: () => { - const target = browserActionTarget - setBrowserActionTarget(null) - if (target) { - void handleBrowserNavigationCommand(target, 'browser.forward') - } - } - } - ] - : []), - { - label: 'Reload', - icon: RefreshCw, - onPress: () => { - const target = browserActionTarget - setBrowserActionTarget(null) - if (target) { - void handleBrowserNavigationCommand(target, 'browser.reload') - } - } - }, - { - label: 'Close', - destructive: true, - onPress: () => { - const target = browserActionTarget - setBrowserActionTarget(null) - if (target) { - void handleCloseSessionTab(target) - } - } - } - ]} + setBrowserActionTarget(null)} + onNavigate={handleBrowserNavigationCommand} + onCloseTab={handleCloseSessionTab} /> 1 so agent prose reads larger than the compact base. */ + textScale?: number + /** When provided, detected file-path tokens render as tappable and invoke this + * with the worktree-relative path. Omitted on screens with no file viewer, where + * paths render as plain text (no behavior change). */ + onOpenFile?: (relativePath: string) => void } const MAX_TABLE_ROWS = 40 @@ -19,7 +31,37 @@ function openMarkdownUrl(url: string): void { } } -function renderInline(text: string): ReactNode[] { +// Render a plain (non-token) text run, splitting out tappable file paths when +// onOpenFile is provided. Without it, paths stay plain text. +function renderTextRun( + text: string, + keyPrefix: string, + onOpenFile?: (relativePath: string) => void +): ReactNode { + if (!onOpenFile) { + return text + } + const segments = detectFilePathSegments(text) + if (segments.length === 1 && segments[0]!.type === 'text') { + return text + } + return segments.map((segment, segmentIndex) => { + if (segment.type === 'file') { + return ( + onOpenFile(segment.path)} + > + {segment.value} + + ) + } + return {segment.value} + }) +} + +function renderInline(text: string, onOpenFile?: (relativePath: string) => void): ReactNode[] { const parts: ReactNode[] = [] const pattern = /(!\[[^\]]*\]\([^)]+\)|`[^`]+`|~~[^~]+~~|\*\*[^*]+\*\*|__[^_]+__|\*[^*\n]+\*|_[^_\n]+_|\[[^\]]+\]\([^)]+\)|https?:\/\/[^\s<]+)/g @@ -28,7 +70,7 @@ function renderInline(text: string): ReactNode[] { while ((match = pattern.exec(text))) { if (match.index > lastIndex) { - parts.push(text.slice(lastIndex, match.index)) + parts.push(renderTextRun(text.slice(lastIndex, match.index), `t${lastIndex}`, onOpenFile)) } const token = match[0] const key = `${match.index}:${token}` @@ -53,11 +95,24 @@ function renderInline(text: string): ReactNode[] { ) } else if (token.startsWith('`')) { - parts.push( - - {token.slice(1, -1)} - - ) + const code = token.slice(1, -1) + if (onOpenFile && isFilePathCodeSpan(code)) { + parts.push( + onOpenFile(normalizeFilePath(code.trim()))} + > + {code} + + ) + } else { + parts.push( + + {code} + + ) + } } else if (token.startsWith('~~')) { parts.push( @@ -81,15 +136,20 @@ function renderInline(text: string): ReactNode[] { } if (lastIndex < text.length) { - parts.push(text.slice(lastIndex)) + parts.push(renderTextRun(text.slice(lastIndex), `t${lastIndex}`, onOpenFile)) } return parts } -function MobileMarkdownInner({ content, fallback = '' }: Props) { +function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile }: Props) { const text = content?.trim() ?? '' const previewText = useMemo(() => normalizeMobileMarkdownPreviewHtml(text), [text]) const blocks = useMemo(() => parseMobileMarkdown(previewText), [previewText]) + // Scale prose sizes; inline spans inherit fontSize from the wrapping Text. + const scaled = (size: number): { fontSize: number; lineHeight: number } | null => + textScale !== 1 ? { fontSize: size * textScale, lineHeight: (size + 6) * textScale } : null + const proseScale = scaled(13) + const listScale = scaled(14) if (!text) { return fallback ? {fallback} : null } @@ -103,14 +163,14 @@ function MobileMarkdownInner({ content, fallback = '' }: Props) { key={index} style={[styles.heading, block.level <= 2 ? styles.headingLarge : null]} > - {renderInline(block.text)} + {renderInline(block.text, onOpenFile)} ) } if (block.type === 'quote') { return ( - {renderInline(block.text)} + {renderInline(block.text, onOpenFile)} ) } @@ -147,7 +207,7 @@ function MobileMarkdownInner({ content, fallback = '' }: Props) { {visibleHeaders.map((header, cellIndex) => ( - {renderInline(header)} + {renderInline(header, onOpenFile)} ))} @@ -155,7 +215,7 @@ function MobileMarkdownInner({ content, fallback = '' }: Props) { {visibleHeaders.map((_, cellIndex) => ( - {renderInline(row[cellIndex] ?? '')} + {renderInline(row[cellIndex] ?? '', onOpenFile)} ))} @@ -185,7 +245,9 @@ function MobileMarkdownInner({ content, fallback = '' }: Props) { ? '[x]' : '[ ]'} - {renderInline(item.text)} + + {renderInline(item.text, onOpenFile)} + ))} @@ -195,11 +257,11 @@ function MobileMarkdownInner({ content, fallback = '' }: Props) { return } return ( - + {block.text.split('\n').map((line, lineIndex) => ( {lineIndex > 0 ? '\n' : null} - {renderInline(line)} + {renderInline(line, onOpenFile)} ))} @@ -210,147 +272,3 @@ function MobileMarkdownInner({ content, fallback = '' }: Props) { } export const MobileMarkdown = memo(MobileMarkdownInner) - -const styles = StyleSheet.create({ - root: { - gap: spacing.sm - }, - paragraph: { - fontSize: 13, - lineHeight: 19, - color: colors.textPrimary - }, - heading: { - fontSize: 14, - lineHeight: 20, - fontWeight: '700', - color: colors.textPrimary - }, - headingLarge: { - fontSize: 15, - lineHeight: 21 - }, - bold: { - fontWeight: '700', - color: colors.textPrimary - }, - italic: { - fontStyle: 'italic' - }, - strike: { - textDecorationLine: 'line-through' - }, - link: { - color: colors.accentBlue, - textDecorationLine: 'underline' - }, - inlineCode: { - fontFamily: typography.monoFamily, - fontSize: 12, - color: colors.textPrimary, - backgroundColor: colors.bgRaised, - borderRadius: radii.row, - paddingHorizontal: 4 - }, - quote: { - borderLeftWidth: 2, - borderLeftColor: colors.borderSubtle, - paddingLeft: spacing.sm - }, - quoteText: { - fontSize: 13, - lineHeight: 19, - color: colors.textSecondary - }, - codeBlock: { - backgroundColor: colors.bgRaised, - borderWidth: 1, - borderColor: colors.borderSubtle, - borderRadius: radii.input, - padding: spacing.sm - }, - codeLanguage: { - fontSize: 10, - color: colors.textMuted, - marginBottom: spacing.xs, - textTransform: 'uppercase' - }, - codeText: { - fontFamily: typography.monoFamily, - fontSize: 12, - lineHeight: 17, - color: colors.textPrimary - }, - imageFrame: { - borderWidth: 1, - borderColor: colors.borderSubtle, - borderRadius: radii.input, - backgroundColor: colors.bgRaised, - overflow: 'hidden', - padding: spacing.sm - }, - imageCaption: { - paddingHorizontal: spacing.sm, - paddingVertical: spacing.xs, - fontSize: 11, - color: colors.textSecondary - }, - table: { - borderTopWidth: 1, - borderLeftWidth: 1, - borderColor: colors.borderSubtle, - borderRadius: radii.input, - overflow: 'hidden', - backgroundColor: colors.bgPanel - }, - tableRow: { - flexDirection: 'row' - }, - tableCell: { - minWidth: 112, - maxWidth: 220, - borderRightWidth: 1, - borderBottomWidth: 1, - borderColor: colors.borderSubtle, - paddingHorizontal: spacing.sm, - paddingVertical: spacing.xs, - fontSize: 12, - lineHeight: 17, - color: colors.textPrimary - }, - tableHeader: { - fontWeight: '700', - backgroundColor: colors.bgRaised - }, - tableTruncated: { - padding: spacing.sm, - fontSize: 12, - color: colors.textMuted - }, - list: { - gap: spacing.xs - }, - listItem: { - flexDirection: 'row', - alignItems: 'flex-start', - gap: spacing.sm - }, - listMarker: { - width: 22, - fontSize: 13, - lineHeight: 19, - color: colors.textSecondary, - fontFamily: typography.monoFamily - }, - listText: { - flex: 1, - minWidth: 0, - fontSize: 13, - lineHeight: 19, - color: colors.textPrimary - }, - rule: { - height: StyleSheet.hairlineWidth, - backgroundColor: colors.borderSubtle - } -}) diff --git a/mobile/src/components/markdown-file-path-detection.test.ts b/mobile/src/components/markdown-file-path-detection.test.ts new file mode 100644 index 00000000000..f75da01efdc --- /dev/null +++ b/mobile/src/components/markdown-file-path-detection.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it } from 'vitest' +import { + detectFilePathSegments, + isFilePathCodeSpan, + normalizeFilePath +} from './markdown-file-path-detection' + +describe('detectFilePathSegments', () => { + it('returns a single text segment when there is no path', () => { + expect(detectFilePathSegments('just some prose here')).toEqual([ + { type: 'text', value: 'just some prose here' } + ]) + }) + + it('detects a relative source path with surrounding prose', () => { + const segments = detectFilePathSegments('Edit src/app/Main.tsx now') + expect(segments).toEqual([ + { type: 'text', value: 'Edit ' }, + { type: 'file', value: 'src/app/Main.tsx', path: 'src/app/Main.tsx' }, + { type: 'text', value: ' now' } + ]) + }) + + it('strips a leading ./ in the path but keeps the displayed value', () => { + const segments = detectFilePathSegments('see ./lib/x.ts') + expect(segments).toEqual([ + { type: 'text', value: 'see ' }, + { type: 'file', value: './lib/x.ts', path: 'lib/x.ts' } + ]) + }) + + it('keeps ../ parent-relative paths intact', () => { + const segments = detectFilePathSegments('../shared/util.ts') + expect(segments).toEqual([ + { type: 'file', value: '../shared/util.ts', path: '../shared/util.ts' } + ]) + }) + + it('detects multiple paths in one run', () => { + const segments = detectFilePathSegments('a/b.ts and c/d/e.json') + expect(segments.filter((s) => s.type === 'file')).toEqual([ + { type: 'file', value: 'a/b.ts', path: 'a/b.ts' }, + { type: 'file', value: 'c/d/e.json', path: 'c/d/e.json' } + ]) + }) + + it('detects Windows relative, drive, and UNC paths', () => { + const segments = detectFilePathSegments( + String.raw`Edit src\app\Main.tsx, C:\repo\config.json, and \\server\share\docs\readme.md` + ) + + expect(segments.filter((segment) => segment.type === 'file')).toEqual([ + { type: 'file', value: String.raw`src\app\Main.tsx`, path: String.raw`src\app\Main.tsx` }, + { + type: 'file', + value: String.raw`C:\repo\config.json`, + path: String.raw`C:\repo\config.json` + }, + { + type: 'file', + value: String.raw`\\server\share\docs\readme.md`, + path: String.raw`\\server\share\docs\readme.md` + } + ]) + }) + + it('does not match bare filenames without a slash', () => { + expect(detectFilePathSegments('open Main.tsx please')).toEqual([ + { type: 'text', value: 'open Main.tsx please' } + ]) + }) + + it('does not match URLs', () => { + expect(detectFilePathSegments('https://example.com/path/file.ts')).toEqual([ + { type: 'text', value: 'https://example.com/path/file.ts' } + ]) + }) + + it('does not match version numbers', () => { + expect(detectFilePathSegments('upgraded to 1.2.3 today')).toEqual([ + { type: 'text', value: 'upgraded to 1.2.3 today' } + ]) + }) + + it('does not match unknown extensions', () => { + expect(detectFilePathSegments('path/to/thing.whatever')).toEqual([ + { type: 'text', value: 'path/to/thing.whatever' } + ]) + }) + + it('detects scoped-package file paths with a segment-leading @', () => { + expect(detectFilePathSegments('open @types/react/index.d.ts here')).toEqual([ + { type: 'text', value: 'open ' }, + { + type: 'file', + value: '@types/react/index.d.ts', + path: '@types/react/index.d.ts' + }, + { type: 'text', value: ' here' } + ]) + expect( + detectFilePathSegments('node_modules/@scope/pkg/file.ts').filter((s) => s.type === 'file') + ).toEqual([ + { + type: 'file', + value: 'node_modules/@scope/pkg/file.ts', + path: 'node_modules/@scope/pkg/file.ts' + } + ]) + }) + + it('does not match emails or git URLs with a mid-token @', () => { + expect(detectFilePathSegments('clone git@github.com:user/repo.git')).toEqual([ + { type: 'text', value: 'clone git@github.com:user/repo.git' } + ]) + expect(detectFilePathSegments('open user@host.com/path/file.txt')).toEqual([ + { type: 'text', value: 'open user@host.com/path/file.txt' } + ]) + }) + + it('returns a single text segment when the run has no dot', () => { + const text = 'a/'.repeat(8192) + expect(detectFilePathSegments(text)).toEqual([{ type: 'text', value: text }]) + }) + + it('skips detection for runs over the length cap even with dots', () => { + // 'a.b/'-repeats pass the dot precheck, so this exercises the length cap that + // bounds CANDIDATE_PATTERN's worst-case backtracking. + const text = 'a.b/'.repeat(2000) + expect(detectFilePathSegments(text)).toEqual([{ type: 'text', value: text }]) + }) + + it('still detects a path in a long-but-under-cap run', () => { + const prefix = 'context '.repeat(200) + const segments = detectFilePathSegments(`${prefix}src/app/Main.tsx`) + expect(segments.filter((s) => s.type === 'file')).toEqual([ + { type: 'file', value: 'src/app/Main.tsx', path: 'src/app/Main.tsx' } + ]) + }) +}) + +describe('isFilePathCodeSpan', () => { + it('accepts a slashed path code span', () => { + expect(isFilePathCodeSpan('src/app/Main.tsx')).toBe(true) + }) + + it('accepts Windows paths in code spans', () => { + expect(isFilePathCodeSpan(String.raw`src\app\Main.tsx`)).toBe(true) + expect(isFilePathCodeSpan(String.raw`C:\repo\Main.tsx`)).toBe(true) + expect(isFilePathCodeSpan(String.raw`\\server\share\Main.tsx`)).toBe(true) + }) + + it('accepts a bare filename code span', () => { + expect(isFilePathCodeSpan('package.json')).toBe(true) + }) + + it('rejects multi-word code spans', () => { + expect(isFilePathCodeSpan('npm run build')).toBe(false) + }) + + it('rejects non-file code spans', () => { + expect(isFilePathCodeSpan('someVariable')).toBe(false) + }) + + it('rejects urls in code spans', () => { + expect(isFilePathCodeSpan('https://x.com/a.ts')).toBe(false) + }) + + it('accepts scoped-package paths with a segment-leading @', () => { + expect(isFilePathCodeSpan('@types/react/index.d.ts')).toBe(true) + expect(isFilePathCodeSpan('node_modules/@scope/pkg/file.ts')).toBe(true) + }) + + it('rejects emails and git URLs with a mid-token @', () => { + expect(isFilePathCodeSpan('git@github.com:user/repo.git')).toBe(false) + expect(isFilePathCodeSpan('user@host.com/path/file.txt')).toBe(false) + }) +}) + +describe('normalizeFilePath', () => { + it('strips a leading ./', () => { + expect(normalizeFilePath('./a/b.ts')).toBe('a/b.ts') + expect(normalizeFilePath(String.raw`.\a\b.ts`)).toBe(String.raw`a\b.ts`) + }) + + it('leaves other paths unchanged', () => { + expect(normalizeFilePath('../a/b.ts')).toBe('../a/b.ts') + expect(normalizeFilePath('a/b.ts')).toBe('a/b.ts') + }) +}) diff --git a/mobile/src/components/markdown-file-path-detection.ts b/mobile/src/components/markdown-file-path-detection.ts new file mode 100644 index 00000000000..a33c0dceae4 --- /dev/null +++ b/mobile/src/components/markdown-file-path-detection.ts @@ -0,0 +1,212 @@ +// Conservative detection of file-path-like tokens inside an inline markdown text +// run, so the chat view can render them as tappable (opening the mobile file +// viewer). We deliberately favor precision over recall: a missed path is a minor +// annoyance, but a false positive on prose or a version number is a broken tap. + +export type FilePathSegment = + | { type: 'text'; value: string } + | { type: 'file'; value: string; path: string } + +// Common source/code/config extensions we treat as openable file paths. Kept +// explicit (rather than "any extension") so prose like "etc." or "e.g." and +// domain-ish tokens like "example.com" don't get matched. +const FILE_EXTENSIONS = [ + 'ts', + 'tsx', + 'js', + 'jsx', + 'mjs', + 'cjs', + 'json', + 'jsonc', + 'css', + 'scss', + 'sass', + 'less', + 'html', + 'htm', + 'md', + 'mdx', + 'markdown', + 'txt', + 'py', + 'rb', + 'go', + 'rs', + 'java', + 'kt', + 'kts', + 'swift', + 'c', + 'h', + 'cc', + 'cpp', + 'hpp', + 'cs', + 'php', + 'sh', + 'bash', + 'zsh', + 'fish', + 'yml', + 'yaml', + 'toml', + 'ini', + 'cfg', + 'conf', + 'env', + 'lock', + 'sql', + 'graphql', + 'gql', + 'proto', + 'xml', + 'svg', + 'vue', + 'svelte', + 'astro', + 'dart', + 'ex', + 'exs', + 'erl', + 'lua', + 'pl', + 'r', + 'scala', + 'clj', + 'gradle', + 'dockerfile', + 'gitignore', + 'npmrc' +] as const + +const EXTENSION_SET = new Set(FILE_EXTENSIONS) + +// Accept the host's native separator because transcript paths originate on the +// connected runtime, which may be Windows even when the phone is not. +const CANDIDATE_PATTERN = + /(?:[A-Za-z]:[\\/]|\\\\)?(?:\.{1,2}[\\/])?(?:[\w.@~+-]+[\\/])+[\w.@+-]+\.[A-Za-z0-9]+/g + +// A path candidate in chat prose is short; a much longer run can't hold one worth +// linkifying but can push CANDIDATE_PATTERN into super-linear backtracking, so we +// skip detection entirely above this cap. +const MAX_DETECTION_LENGTH = 2000 + +// A mid-token '@' (one preceded by a non-separator) marks an email or git URL such +// as git@github.com; a segment-leading '@' is a scoped package dir (@scope/…) and +// stays eligible. +function hasMidTokenAt(candidate: string): boolean { + return /[^\\/]@/.test(candidate) +} + +function isOpenablePath(candidate: string): boolean { + // Reject anything URL-ish or scheme-bearing — those are handled as web links. + if (candidate.includes('://') || hasMidTokenAt(candidate)) { + return false + } + // Must contain a separator (a bare "file.ts" is too ambiguous in prose). + if (!/[\\/]/.test(candidate)) { + return false + } + const lastSeparator = Math.max(candidate.lastIndexOf('/'), candidate.lastIndexOf('\\')) + const lastSegment = candidate.slice(lastSeparator + 1) + const dot = lastSegment.lastIndexOf('.') + // A leading-dot dotfile in the final segment (e.g. ".env") has no extension to + // anchor on; require a real name.ext shape. + if (dot <= 0) { + return false + } + const ext = lastSegment.slice(dot + 1).toLowerCase() + if (!EXTENSION_SET.has(ext)) { + return false + } + // Guard against version-number-ish tails ("1.2.3" style) where the "extension" + // is purely numeric — those aren't files. + if (/^\d+$/.test(ext)) { + return false + } + return true +} + +// Strip the leading ./ marker so callers receive a clean worktree-relative path, +// while keeping ../ (which is meaningful) intact. +export function normalizeFilePath(path: string): string { + return path.replace(/^\.[\\/]/, '') +} + +/** + * Given a plain inline text run, return ordered segments marking which substrings + * are openable file paths. Non-path text is preserved verbatim so the renderer can + * reassemble the run exactly. Returns a single text segment when nothing matches. + */ +export function detectFilePathSegments(text: string): FilePathSegment[] { + // Every real match ends in a name.ext, so a dot is mandatory; skip the regex when + // there is none, or when the run is too long to hold a chat path but long enough + // to drive CANDIDATE_PATTERN into super-linear backtracking. + if (text.length > MAX_DETECTION_LENGTH || !text.includes('.')) { + return [{ type: 'text', value: text }] + } + const segments: FilePathSegment[] = [] + let lastIndex = 0 + let match: RegExpExecArray | null + CANDIDATE_PATTERN.lastIndex = 0 + + while ((match = CANDIDATE_PATTERN.exec(text))) { + const candidate = match[0] + // Skip candidates that are part of a URL (preceded by a scheme colon or an + // alphanumeric/host char that would make this a domain tail, not a path). + const prev = match.index > 0 ? text[match.index - 1]! : '' + if (prev === ':' || prev === '/' || /[\w.@]/.test(prev)) { + continue + } + if (!isOpenablePath(candidate)) { + continue + } + if (match.index > lastIndex) { + segments.push({ type: 'text', value: text.slice(lastIndex, match.index) }) + } + segments.push({ type: 'file', value: candidate, path: normalizeFilePath(candidate) }) + lastIndex = match.index + candidate.length + } + + if (lastIndex < text.length) { + segments.push({ type: 'text', value: text.slice(lastIndex) }) + } + // Collapse to a single text segment for the common no-match case. + if (segments.length === 0) { + return [{ type: 'text', value: text }] + } + return segments +} + +/** + * True when an inline-code span's entire content is a single openable file path + * (e.g. `src/app/Main.tsx`). Code spans are a strong signal, so we also accept a + * bare `file.ts` here even without a slash. + */ +export function isFilePathCodeSpan(code: string): boolean { + const trimmed = code.trim() + if (!trimmed || /\s/.test(trimmed)) { + return false + } + if (trimmed.includes('://') || hasMidTokenAt(trimmed)) { + return false + } + if (isOpenablePath(trimmed)) { + return true + } + // Separator-less code span: accept a clean name.ext with a known extension. + if (/[\\/]/.test(trimmed)) { + return false + } + const dot = trimmed.lastIndexOf('.') + if (dot <= 0) { + return false + } + const name = trimmed.slice(0, dot) + const ext = trimmed.slice(dot + 1).toLowerCase() + if (/[^\w.@+-]/.test(name)) { + return false + } + return EXTENSION_SET.has(ext) +} diff --git a/mobile/src/components/mobile-markdown-styles.ts b/mobile/src/components/mobile-markdown-styles.ts new file mode 100644 index 00000000000..414a8daf57b --- /dev/null +++ b/mobile/src/components/mobile-markdown-styles.ts @@ -0,0 +1,150 @@ +import { StyleSheet } from 'react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' + +export const styles = StyleSheet.create({ + root: { + gap: spacing.sm + }, + paragraph: { + fontSize: 13, + lineHeight: 19, + color: colors.textPrimary + }, + heading: { + fontSize: 14, + lineHeight: 20, + fontWeight: '700', + color: colors.textPrimary + }, + headingLarge: { + fontSize: 15, + lineHeight: 21 + }, + bold: { + fontWeight: '700', + color: colors.textPrimary + }, + italic: { + fontStyle: 'italic' + }, + strike: { + textDecorationLine: 'line-through' + }, + link: { + color: colors.accentBlue, + textDecorationLine: 'underline' + }, + inlineCode: { + fontFamily: typography.monoFamily, + fontSize: 12, + color: colors.textPrimary, + backgroundColor: colors.bgRaised, + borderRadius: radii.row, + paddingHorizontal: 4 + }, + inlineCodeLink: { + color: colors.accentBlue, + textDecorationLine: 'underline' + }, + quote: { + borderLeftWidth: 2, + borderLeftColor: colors.borderSubtle, + paddingLeft: spacing.sm + }, + quoteText: { + fontSize: 13, + lineHeight: 19, + color: colors.textSecondary + }, + codeBlock: { + backgroundColor: colors.bgRaised, + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.input, + padding: spacing.sm + }, + codeLanguage: { + fontSize: 10, + color: colors.textMuted, + marginBottom: spacing.xs, + textTransform: 'uppercase' + }, + codeText: { + fontFamily: typography.monoFamily, + fontSize: 12, + lineHeight: 17, + color: colors.textPrimary + }, + imageFrame: { + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.input, + backgroundColor: colors.bgRaised, + overflow: 'hidden', + padding: spacing.sm + }, + imageCaption: { + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + fontSize: 11, + color: colors.textSecondary + }, + table: { + borderTopWidth: 1, + borderLeftWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.input, + overflow: 'hidden', + backgroundColor: colors.bgPanel + }, + tableRow: { + flexDirection: 'row' + }, + tableCell: { + minWidth: 112, + maxWidth: 220, + borderRightWidth: 1, + borderBottomWidth: 1, + borderColor: colors.borderSubtle, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + fontSize: 12, + lineHeight: 17, + color: colors.textPrimary + }, + tableHeader: { + fontWeight: '700', + backgroundColor: colors.bgRaised + }, + tableTruncated: { + padding: spacing.sm, + fontSize: 12, + color: colors.textMuted + }, + list: { + gap: spacing.xs + }, + listItem: { + flexDirection: 'row', + alignItems: 'flex-start', + gap: spacing.sm + }, + listMarker: { + width: 22, + fontSize: 13, + lineHeight: 19, + color: colors.textSecondary, + fontFamily: typography.monoFamily + }, + listText: { + flex: 1, + minWidth: 0, + fontSize: 13, + lineHeight: 19, + color: colors.textPrimary + }, + rule: { + height: StyleSheet.hairlineWidth, + backgroundColor: colors.borderSubtle + } +}) diff --git a/mobile/src/session/MobileAgentWorkingIndicator.tsx b/mobile/src/session/MobileAgentWorkingIndicator.tsx new file mode 100644 index 00000000000..cb766b0be51 --- /dev/null +++ b/mobile/src/session/MobileAgentWorkingIndicator.tsx @@ -0,0 +1,64 @@ +import { useEffect, useRef } from 'react' +import { Animated, StyleSheet, Text, View } from 'react-native' +import { colors, spacing, typography } from '../theme/mobile-theme' + +/** Animated three-dot "agent is working" row, shown while the active agent is + * still producing a reply. Pure presentation — visibility is the caller's call. */ +export function MobileAgentWorkingIndicator(): React.JSX.Element { + const dots = [ + useRef(new Animated.Value(0.3)).current, + useRef(new Animated.Value(0.3)).current, + useRef(new Animated.Value(0.3)).current + ] + + useEffect(() => { + const animations = dots.map((dot, i) => + Animated.loop( + Animated.sequence([ + Animated.delay(i * 160), + Animated.timing(dot, { toValue: 1, duration: 320, useNativeDriver: true }), + Animated.timing(dot, { toValue: 0.3, duration: 320, useNativeDriver: true }) + ]) + ) + ) + animations.forEach((a) => a.start()) + return () => animations.forEach((a) => a.stop()) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + return ( + + Agent is working + + {dots.map((dot, i) => ( + + ))} + + + ) +} + +const styles = StyleSheet.create({ + row: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm + }, + label: { + color: colors.textMuted, + fontSize: typography.metaSize, + fontStyle: 'italic' + }, + dots: { + flexDirection: 'row', + gap: 4 + }, + dot: { + width: 5, + height: 5, + borderRadius: 2.5, + backgroundColor: colors.textSecondary + } +}) diff --git a/mobile/src/session/MobileBrowserTabActionSheet.tsx b/mobile/src/session/MobileBrowserTabActionSheet.tsx new file mode 100644 index 00000000000..3baa6da8c3a --- /dev/null +++ b/mobile/src/session/MobileBrowserTabActionSheet.tsx @@ -0,0 +1,79 @@ +import { ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react-native' +import type { MobileSessionTab } from '../../app/h/[hostId]/session/mobile-session-route-types' +import { ActionSheetModal } from '../components/ActionSheetModal' +import { getMobileSessionTabTitle } from './mobile-terminal-tab-agent' + +type BrowserTab = Extract +export type MobileBrowserNavigationMethod = 'browser.back' | 'browser.forward' | 'browser.reload' + +/** Keeps browser-tab navigation actions out of the session route while preserving + * the target captured at the moment each drawer action is pressed. */ +export function MobileBrowserTabActionSheet(props: { + target: BrowserTab | null + onClose: () => void + onNavigate: (target: BrowserTab, method: MobileBrowserNavigationMethod) => void + onCloseTab: (target: BrowserTab) => void +}): React.JSX.Element { + const { target, onClose, onNavigate, onCloseTab } = props + return ( + { + const current = target + onClose() + if (current) { + onNavigate(current, 'browser.back') + } + } + } + ] + : []), + ...(target?.canGoForward + ? [ + { + label: 'Forward', + icon: ChevronRight, + onPress: () => { + const current = target + onClose() + if (current) { + onNavigate(current, 'browser.forward') + } + } + } + ] + : []), + { + label: 'Reload', + icon: RefreshCw, + onPress: () => { + const current = target + onClose() + if (current) { + onNavigate(current, 'browser.reload') + } + } + }, + { + label: 'Close', + destructive: true, + onPress: () => { + const current = target + onClose() + if (current) { + onCloseTab(current) + } + } + } + ]} + onClose={onClose} + /> + ) +} diff --git a/mobile/src/session/MobileNativeChatAsk.tsx b/mobile/src/session/MobileNativeChatAsk.tsx new file mode 100644 index 00000000000..b485818970c --- /dev/null +++ b/mobile/src/session/MobileNativeChatAsk.tsx @@ -0,0 +1,378 @@ +import { useMemo, useRef, useState } from 'react' +import { Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native' +import { Check } from 'lucide-react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import type { AskAnswerSelection, AskPrompt } from './mobile-native-chat-ask' + +type Props = { + prompt: AskPrompt + /** Deliver the chosen answer (per-question option indices + free text) — + * index-based so Claude's arrow-navigate selector can be driven by the + * option's stable number instead of pasted label text (STA-1860). */ + onAnswer: (selections: AskAnswerSelection[]) => Promise + onCancel?: () => Promise +} + +// Sentinel index for the free-text "Other…" row (never a real option index). +const OTHER = -1 + +/** Native renderer for an agent's AskUserQuestion prompt as a wizard: one + * question per step with tabs across the top, a Next button that advances (Send + * on the last step), and a Cancel that dismisses the prompt. Neutral styling + * with a subtle green accent on the active choice to match the rest of the app. */ +export function MobileNativeChatAsk({ prompt, onAnswer, onCancel }: Props): React.JSX.Element { + const [index, setIndex] = useState(0) + const [selections, setSelections] = useState(() => prompt.questions.map(() => [])) + const [otherText, setOtherText] = useState(() => prompt.questions.map(() => '')) + const [submitting, setSubmitting] = useState(false) + const submittingRef = useRef(false) + + const toggle = (qi: number, optIndex: number, multi: boolean): void => { + setSelections((prev) => { + const next = prev.map((s) => [...s]) + const cur = next[qi] ?? [] + if (multi) { + next[qi] = cur.includes(optIndex) ? cur.filter((i) => i !== optIndex) : [...cur, optIndex] + } else { + next[qi] = cur.includes(optIndex) ? [] : [optIndex] + } + return next + }) + } + + const setOther = (qi: number, value: string): void => { + setOtherText((prev) => { + const next = [...prev] + next[qi] = value + return next + }) + } + + const selectionFor = (qi: number): AskAnswerSelection => { + const picked = (selections[qi] ?? []).filter((i) => i !== OTHER) + const other = (selections[qi] ?? []).includes(OTHER) ? (otherText[qi] ?? '').trim() : '' + return other ? { indices: picked, other } : { indices: picked } + } + + const isAnswered = (qi: number): boolean => { + const sel = selectionFor(qi) + return sel.indices.length > 0 || (sel.other ?? '').length > 0 + } + + const total = prompt.questions.length + const isLast = index === total - 1 + const currentAnswered = useMemo( + () => isAnswered(index), + // eslint-disable-next-line react-hooks/exhaustive-deps + [selections, otherText, index] + ) + const allAnswered = useMemo( + () => prompt.questions.every((_, i) => isAnswered(i)), + // eslint-disable-next-line react-hooks/exhaustive-deps + [otherText, prompt.questions, selections] + ) + const canAdvance = !submitting && (isLast ? allAnswered : currentAnswered) + + const submit = async (): Promise => { + if (!allAnswered || submittingRef.current) { + return + } + submittingRef.current = true + setSubmitting(true) + try { + await onAnswer(prompt.questions.map((_, i) => selectionFor(i))) + } finally { + submittingRef.current = false + setSubmitting(false) + } + } + + const advance = async (): Promise => { + if (isLast) { + await submit() + } else { + setIndex((i) => Math.min(i + 1, total - 1)) + } + } + + const q = prompt.questions[index]! + const otherSelected = (selections[index] ?? []).includes(OTHER) + + return ( + + {total > 1 ? ( + + {prompt.questions.map((qq, i) => ( + setIndex(i)} + > + + {qq.header || `Step ${i + 1}`} + + {isAnswered(i) ? ( + + ) : null} + + ))} + + ) : null} + + + {q.question} + {q.options.map((opt, optIndex) => ( + toggle(index, optIndex, q.multiSelect)} + /> + ))} + toggle(index, OTHER, q.multiSelect)} + /> + {otherSelected ? ( + setOther(index, v)} + placeholder="Type your answer" + placeholderTextColor={colors.textMuted} + multiline + autoFocus + /> + ) : null} + + + + { + if (!submittingRef.current && onCancel) { + submittingRef.current = true + setSubmitting(true) + try { + await onCancel() + } finally { + submittingRef.current = false + setSubmitting(false) + } + } + }} + disabled={submitting} + hitSlop={8} + > + Cancel + + {total > 1 ? ( + + {index + 1}/{total} + + ) : null} + + + {isLast ? 'Send answer' : 'Next'} + + + + + ) +} + +function OptionRow({ + label, + description, + selected, + multi, + onPress +}: { + label: string + description?: string + selected: boolean + multi?: boolean + onPress: () => void +}): React.JSX.Element { + return ( + + {/* Multi-select reads as a checkbox (square); single-select as a radio (circle). */} + + {selected ? : null} + + + {label} + {description ? ( + + {description} + + ) : null} + + + ) +} + +const styles = StyleSheet.create({ + card: { + maxHeight: 380, + backgroundColor: colors.bgPanel, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.borderSubtle + }, + tabs: { + flexGrow: 0, + paddingTop: spacing.sm, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.borderSubtle + }, + tabsContent: { + paddingHorizontal: spacing.sm, + gap: spacing.xs, + alignItems: 'center' + }, + tab: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + minHeight: 36, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + borderBottomWidth: 2, + borderBottomColor: 'transparent' + }, + tabActive: { + borderBottomColor: colors.statusGreen + }, + tabText: { + color: colors.textSecondary, + fontSize: typography.metaSize, + fontWeight: '600' + }, + tabTextActive: { + color: colors.textPrimary + }, + scroll: { + paddingHorizontal: spacing.md + }, + questionText: { + color: colors.textPrimary, + fontSize: typography.bodySize + 1, + fontWeight: '600', + marginVertical: spacing.sm + }, + option: { + flexDirection: 'row', + gap: spacing.sm, + padding: spacing.sm, + borderRadius: radii.card, + backgroundColor: colors.bgRaised, + borderWidth: 1, + borderColor: colors.borderSubtle, + marginBottom: spacing.xs + }, + optionSelected: { + borderColor: colors.statusGreen + }, + check: { + width: 18, + height: 18, + borderWidth: 1.5, + borderColor: colors.textMuted, + alignItems: 'center', + justifyContent: 'center', + marginTop: 1 + }, + checkCircle: { + borderRadius: 9 + }, + checkSquare: { + borderRadius: 4 + }, + checkOn: { + backgroundColor: colors.statusGreen, + borderColor: colors.statusGreen + }, + optionBody: { + flex: 1, + gap: 2 + }, + optionLabel: { + color: colors.textPrimary, + fontSize: typography.bodySize, + fontWeight: '600' + }, + optionDescription: { + color: colors.textSecondary, + fontSize: typography.metaSize + }, + input: { + backgroundColor: colors.bgRaised, + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.card, + color: colors.textPrimary, + fontSize: typography.bodySize, + padding: spacing.sm, + minHeight: 44, + marginBottom: spacing.xs + }, + footer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + padding: spacing.md, + gap: spacing.sm, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.borderSubtle + }, + cancel: { + paddingVertical: spacing.sm, + paddingHorizontal: spacing.sm + }, + cancelText: { + color: colors.textSecondary, + fontSize: typography.bodySize, + fontWeight: '600' + }, + progress: { + color: colors.textMuted, + fontSize: typography.metaSize + }, + next: { + paddingVertical: spacing.sm, + paddingHorizontal: spacing.lg, + borderRadius: radii.button, + backgroundColor: colors.textPrimary + }, + nextDisabled: { + backgroundColor: colors.bgRaised + }, + nextText: { + color: colors.bgBase, + fontSize: typography.bodySize, + fontWeight: '700' + }, + nextTextDisabled: { + color: colors.textMuted + } +}) diff --git a/mobile/src/session/MobileNativeChatComposer.test.ts b/mobile/src/session/MobileNativeChatComposer.test.ts new file mode 100644 index 00000000000..ef2c1012603 --- /dev/null +++ b/mobile/src/session/MobileNativeChatComposer.test.ts @@ -0,0 +1,206 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MobileNativeChatComposer } from './MobileNativeChatComposer' + +vi.mock('react-native', async () => { + const React = await import('react') + return { + ActivityIndicator: 'ActivityIndicator', + Pressable: 'Pressable', + ScrollView: ({ children, ...props }: { children?: unknown }) => + React.createElement('ScrollView', props, children), + StyleSheet: { + create: (styles: unknown) => styles, + hairlineWidth: 1 + }, + Text: 'Text', + TextInput: 'TextInput', + View: 'View' + } +}) + +vi.mock('lucide-react-native', () => ({ + ArrowUp: 'ArrowUp', + ImagePlus: 'ImagePlus', + Mic: 'Mic', + Square: 'Square' +})) + +function suppressRendererWarning(): () => void { + const original = console.error + const spy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + return () => spy.mockRestore() +} + +describe('MobileNativeChatComposer', () => { + let renderer: ReactTestRenderer | null = null + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + async function render( + onSend: (text: string) => Promise, + onChangeText: () => void, + isAttaching = false + ) { + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create( + createElement(MobileNativeChatComposer, { + value: ' hello ', + onChangeText, + onSend, + isAttaching + }) + ) + }) + } finally { + restore() + } + } + + function sendButton(): { props: { onPress: () => Promise } } { + if (!renderer) { + throw new Error('Composer was not rendered') + } + return renderer.root.find( + (node) => node.type === 'Pressable' && node.props.accessibilityLabel === 'Send message' + ) as { props: { onPress: () => Promise } } + } + + it('reports an accepted send without owning route-scoped draft cleanup', async () => { + const onChangeText = vi.fn() + const onSend = vi.fn().mockResolvedValue(true) + await render(onSend, onChangeText) + + await act(async () => sendButton().props.onPress()) + + expect(onSend).toHaveBeenCalledWith('hello') + expect(onChangeText).not.toHaveBeenCalled() + }) + + it('keeps the draft when the send is rejected', async () => { + const onChangeText = vi.fn() + const onSend = vi.fn().mockResolvedValue(false) + await render(onSend, onChangeText) + + await act(async () => sendButton().props.onPress()) + + expect(onSend).toHaveBeenCalledWith('hello') + expect(onChangeText).not.toHaveBeenCalled() + }) + + it('disables send while an attachment path is still being injected', async () => { + const onSend = vi.fn().mockResolvedValue(true) + await render(onSend, vi.fn(), true) + + expect(sendButton().props).toMatchObject({ disabled: true }) + await act(async () => sendButton().props.onPress()) + expect(onSend).not.toHaveBeenCalled() + }) + + it('moves the caret to the insert point after an autocomplete pick, then releases control', async () => { + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create( + createElement(MobileNativeChatComposer, { + value: '/c', + onChangeText: vi.fn(), + onSend: vi.fn().mockResolvedValue(true) + }) + ) + }) + } finally { + restore() + } + const input = () => + renderer!.root.find((node) => node.type === 'TextInput') as { + props: { + selection?: { start: number; end: number } + onSelectionChange: (e: { nativeEvent: { selection: { end: number } } }) => void + } + } + // Uncontrolled selection until a suggestion is applied. + expect(input().props.selection).toBeUndefined() + // Place the caret at the end so the slash trigger is active and suggestions render. + await act(async () => + input().props.onSelectionChange({ nativeEvent: { selection: { end: 2 } } }) + ) + const firstSuggestion = renderer!.root.findAll( + (node) => node.type === 'Pressable' && !node.props.accessibilityLabel + )[0] as { props: { onPress: () => void } } + await act(async () => firstSuggestion.props.onPress()) + // `/clear ` is 7 chars — the caret jumps just past the inserted command + space. + expect(input().props.selection).toEqual({ start: 7, end: 7 }) + // The next native selection event releases control so manual placement still works. + await act(async () => + input().props.onSelectionChange({ nativeEvent: { selection: { end: 7 } } }) + ) + expect(input().props.selection).toBeUndefined() + }) + + it('wires the mic for hold vs toggle dictation like the terminal composer', async () => { + const onMicPress = vi.fn() + const onMicPressIn = vi.fn() + const onMicPressOut = vi.fn() + const mic = () => + renderer!.root.find( + (node) => node.type === 'Pressable' && node.props.accessibilityLabel === 'Dictate' + ) as { props: { onPress?: unknown; onPressIn?: unknown; onPressOut?: unknown } } + + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create( + createElement(MobileNativeChatComposer, { + value: '', + onChangeText: vi.fn(), + onSend: vi.fn().mockResolvedValue(true), + onMicPress, + dictationMode: 'hold', + onMicPressIn, + onMicPressOut + }) + ) + }) + // Hold mode is walkie-talkie: press-in/out drive dictation, tap is inert. + expect(mic().props.onPress).toBeUndefined() + expect(mic().props.onPressIn).toBe(onMicPressIn) + expect(mic().props.onPressOut).toBe(onMicPressOut) + + await act(async () => { + renderer!.update( + createElement(MobileNativeChatComposer, { + value: '', + onChangeText: vi.fn(), + onSend: vi.fn().mockResolvedValue(true), + onMicPress, + dictationMode: 'toggle', + onMicPressIn, + onMicPressOut + }) + ) + }) + // Toggle mode: tap drives dictation, press-in/out inert. + expect(mic().props.onPress).toBe(onMicPress) + expect(mic().props.onPressIn).toBeUndefined() + expect(mic().props.onPressOut).toBeUndefined() + } finally { + restore() + } + }) +}) diff --git a/mobile/src/session/MobileNativeChatComposer.tsx b/mobile/src/session/MobileNativeChatComposer.tsx new file mode 100644 index 00000000000..ba02fe3d44b --- /dev/null +++ b/mobile/src/session/MobileNativeChatComposer.tsx @@ -0,0 +1,285 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { + ActivityIndicator, + Pressable, + ScrollView, + StyleSheet, + Text, + TextInput, + View +} from 'react-native' +import { ArrowUp, ImagePlus, Mic, Square } from 'lucide-react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import { + applyAutocomplete, + detectAutocompleteTrigger, + rankSuggestions +} from './mobile-native-chat-autocomplete' + +// Common agent slash commands offered as autocomplete; sending them is just text +// to the agent's terminal, so the set is intentionally provider-agnostic. +const SLASH_COMMANDS = [ + '/clear', + '/compact', + '/review', + '/model', + '/help', + '/init', + '/cost', + '/diff' +] + +const NO_FILE_PATHS: string[] = [] + +type Props = { + /** Controlled composer text — owned by the parent so dictation can write to it. */ + value: string + onChangeText: (text: string) => void + onSend: (text: string) => Promise + onAttachImage?: () => void + isAttaching?: boolean + onMicPress?: () => void + micActive?: boolean + /** Dictation trigger style — 'hold' uses press-in/out, 'toggle' uses tap. */ + dictationMode?: 'toggle' | 'hold' + onMicPressIn?: () => void + onMicPressOut?: () => void + disabled?: boolean + placeholder?: string + filePaths?: string[] + onNeedFiles?: (query: string) => void +} + +export function MobileNativeChatComposer({ + value, + onChangeText, + onSend, + onAttachImage, + isAttaching = false, + onMicPress, + micActive = false, + dictationMode = 'toggle', + onMicPressIn, + onMicPressOut, + disabled = false, + placeholder = 'Message, @files, /commands', + filePaths = NO_FILE_PATHS, + onNeedFiles +}: Props): React.JSX.Element { + const [cursor, setCursor] = useState(0) + // Transiently drives the native caret after a mid-text autocomplete insert, + // then released on the next selection change so manual caret placement still + // works (a permanently controlled `selection` breaks it in React Native). + const [pendingSelection, setPendingSelection] = useState<{ start: number; end: number } | null>( + null + ) + const sendingRef = useRef(false) + const [sending, setSending] = useState(false) + const trimmed = value.trim() + const canSend = trimmed.length > 0 && !disabled && !sending && !isAttaching + + const trigger = useMemo(() => detectAutocompleteTrigger(value, cursor), [value, cursor]) + const suggestions = useMemo(() => { + if (!trigger) { + return [] + } + if (trigger.kind === 'slash') { + return rankSuggestions(SLASH_COMMANDS, trigger.query) + } + return rankSuggestions(filePaths, trigger.query).map((p) => `@${p}`) + }, [trigger, filePaths]) + + useEffect(() => { + if (trigger?.kind === 'file') { + onNeedFiles?.(trigger.query) + } + }, [onNeedFiles, trigger?.kind, trigger?.query]) + + const handleChange = (next: string): void => { + onChangeText(next) + } + + const pickSuggestion = (suggestion: string): void => { + if (!trigger) { + return + } + const { text: nextText, cursor: nextCursor } = applyAutocomplete(value, trigger, suggestion) + onChangeText(nextText) + setCursor(nextCursor) + setPendingSelection({ start: nextCursor, end: nextCursor }) + } + + const handleSend = async (): Promise => { + if (!canSend || sendingRef.current) { + return + } + sendingRef.current = true + setSending(true) + try { + const accepted = await onSend(trimmed) + if (accepted) { + setCursor(0) + } + } finally { + sendingRef.current = false + setSending(false) + } + } + + return ( + + {suggestions.length > 0 ? ( + + + {suggestions.map((s) => ( + [styles.suggestion, pressed && styles.suggestionPressed]} + onPress={() => pickSuggestion(s)} + > + + {s} + + + ))} + + + ) : null} + + {onAttachImage ? ( + [styles.iconButton, pressed && styles.pressed]} + onPress={onAttachImage} + disabled={isAttaching || disabled} + > + {isAttaching ? ( + + ) : ( + + )} + + ) : null} + { + setCursor(e.nativeEvent.selection.end) + setPendingSelection(null) + }} + placeholder={placeholder} + placeholderTextColor={colors.textMuted} + selectionColor={colors.accentBlue} + multiline + editable={!disabled} + textAlignVertical="top" + /> + {onMicPress ? ( + [styles.iconButton, pressed && styles.pressed]} + // Hold mode is walkie-talkie (press-in/out); toggle mode taps. + onPress={dictationMode === 'hold' ? undefined : onMicPress} + onPressIn={dictationMode === 'hold' ? onMicPressIn : undefined} + onPressOut={dictationMode === 'hold' ? onMicPressOut : undefined} + disabled={disabled} + > + {micActive ? ( + + ) : ( + + )} + + ) : null} + [ + styles.sendButton, + !canSend && styles.sendButtonDisabled, + pressed && canSend && styles.pressed + ]} + onPress={handleSend} + disabled={!canSend} + > + + + + + ) +} + +const styles = StyleSheet.create({ + suggestions: { + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.borderSubtle, + backgroundColor: colors.bgPanel + }, + suggestionScroll: { + maxHeight: 180 + }, + suggestion: { + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.borderSubtle + }, + suggestionPressed: { + backgroundColor: colors.bgRaised + }, + suggestionText: { + color: colors.textPrimary, + fontFamily: typography.monoFamily, + fontSize: typography.metaSize + }, + bar: { + flexDirection: 'row', + alignItems: 'flex-end', + gap: spacing.sm, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.borderSubtle, + backgroundColor: colors.bgPanel + }, + input: { + flex: 1, + maxHeight: 140, + minHeight: 40, + color: colors.textPrimary, + fontSize: typography.bodySize + 1, + backgroundColor: colors.bgRaised, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + paddingTop: spacing.sm, + paddingBottom: spacing.sm + }, + iconButton: { + width: 40, + height: 40, + alignItems: 'center', + justifyContent: 'center' + }, + sendButton: { + width: 40, + height: 40, + borderRadius: 20, + alignItems: 'center', + justifyContent: 'center', + // White send affordance per design — dark arrow on a light circle. + backgroundColor: colors.textPrimary + }, + sendButtonDisabled: { + backgroundColor: colors.bgRaised + }, + pressed: { + opacity: 0.7 + } +}) diff --git a/mobile/src/session/MobileNativeChatMessage.tsx b/mobile/src/session/MobileNativeChatMessage.tsx new file mode 100644 index 00000000000..eadfd2043f8 --- /dev/null +++ b/mobile/src/session/MobileNativeChatMessage.tsx @@ -0,0 +1,371 @@ +import { memo, useEffect, useRef, useState } from 'react' +import { Pressable, Text, View } from 'react-native' +import * as Clipboard from 'expo-clipboard' +import { ArrowUp, ChevronDown, Copy, SquareChevronRight } from 'lucide-react-native' +import type { NativeChatBlock, NativeChatMessage } from '../../../src/shared/native-chat-types' +import { MobileMarkdown } from '../components/MobileMarkdown' +import { colors } from '../theme/mobile-theme' +import { + isImageRefBlock, + isTextBlock, + pairToolBlocks, + splitNativeChatBlocks, + type ToolPair +} from './mobile-native-chat-blocks' +import { diffFromText, diffFromToolCall, type DiffLine } from './mobile-native-chat-diff' +import { MAX_TOOL_RESULT_CHARS, styles, TEXT_SIZE } from './mobile-native-chat-message-styles' +import { nativeChatMessageText } from './mobile-native-chat-message-text' +import { + summarizeToolInput, + summarizeToolRun, + toolFilePath +} from './mobile-native-chat-tool-summary' + +const MAX_VISIBLE_TOOL_PAIRS = 6 +const MAX_TOOL_RUN_DIFF_ROWS = 240 + +function DiffView({ lines }: { lines: DiffLine[] }): React.JSX.Element { + return ( + + {lines.map((line, i) => ( + + {line.kind === 'add' ? '+' : line.kind === 'del' ? '-' : ' '} + {line.text} + + ))} + + ) +} + +/** A single inline tool line — `▸ ToolName preview` — that expands in place to + * show the call's diff/input or the result's body. Mirrors the reference design + * where tool calls read as flat lines in the conversation, not boxed blocks. */ +function ResultBody({ + output, + isError, + diff +}: { + output: string + isError?: boolean + diff: DiffLine[] | null +}): React.JSX.Element { + if (diff) { + return + } + return ( + + + {output.length > MAX_TOOL_RESULT_CHARS + ? `${output.slice(0, MAX_TOOL_RESULT_CHARS)}…` + : output} + + + ) +} + +/** One request: a tool call and its result rendered together as a single + * expandable line. `defaultExpanded` lets the group toggle open every line. */ +function ToolLine({ + pair, + defaultExpanded, + diffLineLimit, + onOpenFile +}: { + pair: ToolPair + defaultExpanded: boolean + diffLineLimit: number + onOpenFile?: (relativePath: string) => void +}): React.JSX.Element { + const [expanded, setExpanded] = useState(defaultExpanded) + const { call, result } = pair + const name = call ? call.name : 'Result' + const preview = call + ? summarizeToolInput(call.input) + : (result?.output.split('\n')[0]?.slice(0, 80) ?? '') + // Why: collapsed tool rows are the common path; defer bounded diff parsing + // until the user asks to reveal the detail. + const callDiff = expanded && call ? diffFromToolCall(call.name, call.input, diffLineLimit) : null + const resultDiff = expanded && result ? diffFromText(result.output, diffLineLimit) : null + const hasDetail = callDiff !== null || result !== undefined || preview.length > 40 + // A tool that targets a file (Read/Edit/Write…) renders its preview as a + // tappable link that opens the file, independent of the line's expand tap. + const filePath = call ? toolFilePath(call.input) : null + const openable = filePath !== null && onOpenFile !== undefined + return ( + + hasDetail && setExpanded((v) => !v)} + hitSlop={6} + > + {expanded ? ( + + ) : ( + + )} + {name} + {preview ? ( + onOpenFile!(filePath!) : undefined} + suppressHighlighting={!openable} + > + {preview} + + ) : null} + + {expanded ? ( + + {callDiff ? : null} + {!callDiff && call && preview ? {preview} : null} + {result ? ( + + ) : null} + + ) : null} + + ) +} + +function Prose({ + block, + invert, + fontScale, + onOpenFile +}: { + block: NativeChatBlock + invert?: boolean + fontScale: number + onOpenFile?: (relativePath: string) => void +}): React.JSX.Element | null { + if (isTextBlock(block)) { + // Inverted (user) bubbles use a fixed dark-on-light text rather than the + // markdown renderer's light-on-dark palette. + if (invert) { + return ( + {block.text} + ) + } + return ( + + ) + } + if (isImageRefBlock(block)) { + return ( + + 🖼 {block.alt ?? block.path ?? block.url ?? 'image'} + + ) + } + return null +} + +/** A run of a message's tool calls/results, collapsed to a one-line summary that + * expands to the individual inline tool lines. `defaultExpanded` lets the global + * toolbar toggle drive every run at once while still allowing per-run override. */ +function ToolRun({ + blocks, + defaultExpanded, + trailing, + onOpenFile +}: { + blocks: NativeChatBlock[] + defaultExpanded: boolean + trailing?: React.ReactNode + onOpenFile?: (relativePath: string) => void +}): React.JSX.Element { + const [open, setOpen] = useState(defaultExpanded) + const pairs = pairToolBlocks(blocks, MAX_VISIBLE_TOOL_PAIRS) + const diffLineLimit = Math.max(1, Math.floor(MAX_TOOL_RUN_DIFF_ROWS / (pairs.length * 2 || 1))) + let callCount = 0 + for (const block of blocks) { + if (block.type === 'tool-call') { + callCount++ + } + } + callCount ||= pairs.length + const summary = summarizeToolRun(blocks) + return ( + + + setOpen((v) => !v)} hitSlop={6}> + {open ? ( + + ) : ( + + )} + {callCount}× + + {summary || `${callCount} tool ${callCount === 1 ? 'call' : 'calls'}`} + + + {trailing} + + {open ? ( + + {pairs.map((pair, i) => ( + + ))} + {callCount > pairs.length ? ( + … {callCount - pairs.length} more tool calls + ) : null} + + ) : null} + + ) +} + +/** Subtle top-right controls for an agent message: copy its prose, or scroll so + * this message's top aligns to the top of the viewport. */ +function AgentControls({ + onCopy, + onScrollToTop +}: { + onCopy: () => void + onScrollToTop?: () => void +}): React.JSX.Element { + return ( + + [styles.controlButton, pressed && styles.controlPressed]} + onPress={onCopy} + hitSlop={8} + accessibilityLabel="Copy message" + > + + + {onScrollToTop ? ( + [styles.controlButton, pressed && styles.controlPressed]} + onPress={onScrollToTop} + hitSlop={8} + accessibilityLabel="Scroll this message to top" + > + + + ) : null} + + ) +} + +function MobileNativeChatMessageImpl({ + message, + queued, + toolsExpanded = false, + fontScale = 1, + messageIndex, + onScrollToMessage, + onOpenFile +}: { + message: NativeChatMessage + queued?: boolean + toolsExpanded?: boolean + /** Multiplies all chat text sizes for pinch-to-zoom (1 = no change). */ + fontScale?: number + /** This message's index in the list, paired with onScrollToMessage. */ + messageIndex?: number + /** Ask the list to align this message's top to the top of the viewport. */ + onScrollToMessage?: (index: number) => void + onOpenFile?: (relativePath: string) => void +}): React.JSX.Element { + const isUser = message.role === 'user' + const isReasoning = message.role === 'reasoning' + const isAgent = !isUser + // Briefly tint the bubble to confirm a copy landed. + const [copied, setCopied] = useState(false) + const copyTimer = useRef | null>(null) + useEffect( + () => () => { + if (copyTimer.current) { + clearTimeout(copyTimer.current) + } + }, + [] + ) + // Separate the agent's words from its tool activity: prose renders first, the + // tool calls fold into a collapsible run beneath. The user's own messages get + // an inverted (filled accent) bubble so they stand apart from agent prose. + const { prose, tools } = splitNativeChatBlocks(message.blocks) + + const handleCopy = (): void => { + const text = nativeChatMessageText(message.blocks) + if (!text) { + return + } + void Clipboard.setStringAsync(text) + setCopied(true) + if (copyTimer.current) { + clearTimeout(copyTimer.current) + } + copyTimer.current = setTimeout(() => setCopied(false), 700) + } + + // Copy + scroll-to-top, shown inline with the first tool call (or after the + // prose when there are no tools). + const controls = + isAgent && !queued ? ( + onScrollToMessage(messageIndex) + : undefined + } + /> + ) : null + + return ( + + {isUser && queued ? Queued : null} + + {prose.map((block, index) => ( + + ))} + {tools.length > 0 ? ( + + ) : controls ? ( + {controls} + ) : null} + + + ) +} + +export const MobileNativeChatMessage = memo(MobileNativeChatMessageImpl) diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx new file mode 100644 index 00000000000..6acf2d305f6 --- /dev/null +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -0,0 +1,79 @@ +import { StyleSheet, View } from 'react-native' +import { MobileNativeChatView, type MobileNativeChatInputLockReason } from './MobileNativeChatView' +import type { MobileNativeChatController } from './use-mobile-native-chat-controller' + +type Props = { + controller: MobileNativeChatController + onAttachImage: () => void + isAttaching: boolean + onMicPress: () => void + micActive: boolean + dictationMode: 'toggle' | 'hold' + onMicPressIn: () => void + onMicPressOut: () => void + inputLockReason: MobileNativeChatInputLockReason | null + keyboardInset: number +} + +/** Keeps the terminal mounted underneath chat so its PTY subscription survives + * view toggles while the native surface owns the visible composer. */ +export function MobileNativeChatOverlay({ + controller, + onAttachImage, + isAttaching, + onMicPress, + micActive, + dictationMode, + onMicPressIn, + onMicPressOut, + inputLockReason, + keyboardInset +}: Props): React.JSX.Element | null { + if (!controller.showNativeChat) { + return null + } + const session = controller.nativeChatSession + return ( + + + + ) +} + +const styles = StyleSheet.create({ + overlay: StyleSheet.absoluteFillObject +}) diff --git a/mobile/src/session/MobileNativeChatPermission.test.ts b/mobile/src/session/MobileNativeChatPermission.test.ts new file mode 100644 index 00000000000..8e0223f19ec --- /dev/null +++ b/mobile/src/session/MobileNativeChatPermission.test.ts @@ -0,0 +1,60 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MobileNativeChatPermission } from './MobileNativeChatPermission' + +vi.mock('react-native', () => ({ + Pressable: 'Pressable', + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, + Text: 'Text', + View: 'View' +})) + +vi.mock('lucide-react-native', () => ({ ShieldQuestion: 'ShieldQuestion' })) + +describe('MobileNativeChatPermission', () => { + let renderer: ReactTestRenderer | null = null + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + it('accepts only one response when two presses land in the same render batch', async () => { + let resolveResponse: (accepted: boolean) => void = () => {} + const response = new Promise((resolve) => (resolveResponse = resolve)) + const onRespond = vi.fn(() => response) + const original = console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + try { + await act(async () => { + renderer = create( + createElement(MobileNativeChatPermission, { + permission: { title: 'Approve?', options: [{ label: 'Allow', send: '1' }] }, + onRespond + }) + ) + }) + } finally { + consoleSpy.mockRestore() + } + const button = renderer.root.findByType('Pressable') + + act(() => { + button.props.onPress() + button.props.onPress() + }) + + expect(onRespond).toHaveBeenCalledOnce() + await act(async () => resolveResponse(true)) + }) +}) diff --git a/mobile/src/session/MobileNativeChatPermission.tsx b/mobile/src/session/MobileNativeChatPermission.tsx new file mode 100644 index 00000000000..ad26d578d93 --- /dev/null +++ b/mobile/src/session/MobileNativeChatPermission.tsx @@ -0,0 +1,123 @@ +import { memo, useRef, useState } from 'react' +import { Pressable, StyleSheet, Text, View } from 'react-native' +import { ShieldQuestion } from 'lucide-react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import type { MobileChatPermission } from './mobile-native-chat-permission' + +// Renders a detected agent permission ask as a card with tappable options. +// The first option is treated as the primary (allow) action and gets a filled +// accent button so the affirmative choice reads as distinct from the rest. +function MobileNativeChatPermissionImpl({ + permission, + onRespond +}: { + permission: MobileChatPermission + onRespond: (send: string) => Promise +}): React.JSX.Element { + const [submitting, setSubmitting] = useState(false) + const submittingRef = useRef(false) + const respond = async (send: string): Promise => { + if (submittingRef.current) { + return + } + submittingRef.current = true + setSubmitting(true) + const accepted = await onRespond(send) + if (!accepted) { + submittingRef.current = false + setSubmitting(false) + } + } + return ( + + + + {permission.title} + + {permission.detail ? {permission.detail} : null} + + {permission.options.map((option, index) => { + const isPrimary = index === 0 + return ( + [ + styles.option, + isPrimary ? styles.optionPrimary : styles.optionSecondary, + pressed && !submitting && styles.optionPressed + ]} + hitSlop={6} + onPress={() => respond(option.send)} + disabled={submitting} + > + + {option.label} + + + ) + })} + + + ) +} + +export const MobileNativeChatPermission = memo(MobileNativeChatPermissionImpl) + +const styles = StyleSheet.create({ + card: { + marginHorizontal: spacing.lg, + marginVertical: spacing.sm, + padding: spacing.md, + gap: spacing.sm, + borderRadius: radii.card, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.borderSubtle, + backgroundColor: colors.bgPanel + }, + header: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + }, + title: { + color: colors.textPrimary, + fontSize: typography.bodySize, + fontWeight: '600' + }, + detail: { + color: colors.textSecondary, + fontSize: typography.metaSize, + lineHeight: typography.metaSize + 5 + }, + options: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.sm + }, + option: { + minHeight: 44, + justifyContent: 'center', + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderRadius: radii.button + }, + optionPrimary: { + backgroundColor: colors.accentBlue + }, + optionSecondary: { + backgroundColor: colors.bgRaised, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.borderSubtle + }, + optionPressed: { + opacity: 0.7 + }, + optionText: { + color: colors.textPrimary, + fontSize: typography.bodySize, + fontWeight: '600' + }, + optionTextPrimary: { + color: colors.onAccent + } +}) diff --git a/mobile/src/session/MobileNativeChatQuestion.tsx b/mobile/src/session/MobileNativeChatQuestion.tsx new file mode 100644 index 00000000000..f470214dbed --- /dev/null +++ b/mobile/src/session/MobileNativeChatQuestion.tsx @@ -0,0 +1,268 @@ +import { useMemo, useRef, useState } from 'react' +import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native' +import { ArrowUp, Check, CircleHelp } from 'lucide-react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import { formatQuestionAnswer, type MobileChatQuestion } from './mobile-native-chat-question' + +type Props = { + question: MobileChatQuestion + onAnswer: (text: string) => Promise +} + +/** Renders an agent's choice prompt as a tappable card. Single-select answers + * on tap; multi-select toggles then Submits; an always-present text entry lets + * the user answer freely (the escape hatch) when the heuristic misreads the + * options or none apply. */ +export function MobileNativeChatQuestion({ question, onAnswer }: Props): React.JSX.Element { + const [selected, setSelected] = useState([]) + const [freeText, setFreeText] = useState('') + const [sending, setSending] = useState(false) + const sendingRef = useRef(false) + + const hasOptions = question.options.length > 0 + const trimmedFreeText = freeText.trim() + + const toggle = (option: string): void => { + setSelected((prev) => + prev.includes(option) ? prev.filter((o) => o !== option) : [...prev, option] + ) + } + + const sendAnswer = async (text: string): Promise => { + if (sendingRef.current) { + return false + } + sendingRef.current = true + setSending(true) + try { + return await onAnswer(text) + } finally { + sendingRef.current = false + setSending(false) + } + } + + const answerSingle = async (option: string): Promise => { + await sendAnswer(formatQuestionAnswer(question, [option])) + } + + const submitMulti = async (): Promise => { + if (selected.length === 0) { + return + } + await sendAnswer(formatQuestionAnswer(question, selected)) + } + + const submitFreeText = async (): Promise => { + if (trimmedFreeText.length === 0) { + return + } + // Free text is an unknown entry; formatQuestionAnswer passes it through. + if (await sendAnswer(formatQuestionAnswer(question, [trimmedFreeText]))) { + setFreeText('') + } + } + + const canSubmitMulti = selected.length > 0 && !sending + const canSendFreeText = trimmedFreeText.length > 0 && !sending + + // Stable keys for option rows even if an agent repeats a label. + const optionRows = useMemo( + () => question.options.map((label, index) => ({ label, key: `${index}:${label}` })), + [question.options] + ) + + return ( + + + + {question.question} + + + {hasOptions ? ( + + {optionRows.map(({ label, key }) => { + const isSelected = selected.includes(label) + return ( + [ + styles.option, + isSelected && styles.optionSelected, + pressed && styles.pressed + ]} + onPress={() => (question.multiSelect ? toggle(label) : answerSingle(label))} + > + {question.multiSelect ? ( + + {isSelected ? : null} + + ) : null} + {label} + + ) + })} + + ) : null} + + {question.multiSelect && hasOptions ? ( + [ + styles.submit, + !canSubmitMulti && styles.submitDisabled, + pressed && canSubmitMulti && styles.pressed + ]} + onPress={submitMulti} + disabled={!canSubmitMulti} + > + + Submit{selected.length > 0 ? ` (${selected.length})` : ''} + + + ) : null} + + + + [ + styles.freeSend, + !canSendFreeText && styles.freeSendDisabled, + pressed && canSendFreeText && styles.pressed + ]} + onPress={submitFreeText} + disabled={!canSendFreeText} + > + + + + + ) +} + +const styles = StyleSheet.create({ + card: { + marginHorizontal: spacing.lg, + marginVertical: spacing.sm, + padding: spacing.md, + gap: spacing.sm, + backgroundColor: colors.bgPanel, + borderRadius: radii.card, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.borderSubtle + }, + header: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + }, + question: { + flex: 1, + color: colors.textPrimary, + fontSize: typography.bodySize + 1, + fontWeight: '600', + lineHeight: typography.bodySize + 7 + }, + options: { + gap: spacing.xs + }, + option: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + minHeight: 44, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + backgroundColor: colors.bgRaised, + borderRadius: radii.button, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.borderSubtle + }, + optionSelected: { + borderColor: colors.accentBlue + }, + optionText: { + flex: 1, + color: colors.textPrimary, + fontSize: typography.bodySize + 1 + }, + checkbox: { + width: 20, + height: 20, + borderRadius: radii.button, + borderWidth: 1.5, + borderColor: colors.textMuted, + alignItems: 'center', + justifyContent: 'center' + }, + checkboxOn: { + backgroundColor: colors.accentBlue, + borderColor: colors.accentBlue + }, + submit: { + minHeight: 44, + alignItems: 'center', + justifyContent: 'center', + borderRadius: radii.button, + backgroundColor: colors.accentBlue + }, + submitDisabled: { + backgroundColor: colors.bgRaised + }, + submitText: { + color: colors.onMergeGreen, + fontSize: typography.bodySize + 1, + fontWeight: '600' + }, + submitTextDisabled: { + color: colors.textMuted + }, + freeTextRow: { + flexDirection: 'row', + alignItems: 'flex-end', + gap: spacing.sm + }, + freeInput: { + flex: 1, + minHeight: 40, + maxHeight: 120, + color: colors.textPrimary, + fontSize: typography.bodySize + 1, + backgroundColor: colors.bgRaised, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + paddingTop: spacing.sm, + paddingBottom: spacing.sm + }, + freeSend: { + width: 40, + height: 40, + borderRadius: 20, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.textPrimary + }, + freeSendDisabled: { + backgroundColor: colors.bgRaised + }, + pressed: { + opacity: 0.7 + } +}) diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx new file mode 100644 index 00000000000..1cfe37e5d4d --- /dev/null +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -0,0 +1,425 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + ActivityIndicator, + FlatList, + type NativeScrollEvent, + type NativeSyntheticEvent, + Pressable, + Text, + View +} from 'react-native' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler' +import { ArrowDown, ChevronsDownUp, ChevronsUpDown, Square } from 'lucide-react-native' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { colors } from '../theme/mobile-theme' +import { styles } from './mobile-native-chat-view-styles' +import { + buildMobileNativeChatTransientData, + foldMobileNativeChatMessages, + mobileNativeChatEmptyState +} from './mobile-native-chat-render-data' +import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dismiss' +import { useMobileNativeChatPinchGesture } from './use-mobile-native-chat-pinch-gesture' +import { MobileAgentWorkingIndicator } from './MobileAgentWorkingIndicator' +import { MobileNativeChatComposer } from './MobileNativeChatComposer' +import { MobileNativeChatMessage } from './MobileNativeChatMessage' +import { MobileNativeChatAsk } from './MobileNativeChatAsk' +import type { AskAnswerSelection, AskPrompt } from './mobile-native-chat-ask' +import { MobileNativeChatPermission } from './MobileNativeChatPermission' +import type { MobileChatPermission } from './mobile-native-chat-permission' +import { MobileNativeChatQuestion } from './MobileNativeChatQuestion' +import { mobileChatQuestionKey, type MobileChatQuestion } from './mobile-native-chat-question' +import type { MobileNativeChatStatus } from './use-mobile-native-chat-session' + +/** Why the composer input is locked: the transport is disconnected, or the + * terminal subscription has not acknowledged its input lease yet. */ +export type MobileNativeChatInputLockReason = 'disconnected' | 'waiting' + +type Props = { + messages: NativeChatMessage[] + status: MobileNativeChatStatus + error?: string + /** Resolved agent for this chat; names the empty-state copy (desktop parity). */ + agent?: string | null + agentWorking?: boolean + /** Interrupt the agent mid-turn (shown as a Stop button on the working bar). */ + onStop?: () => void + /** Live partial assistant text while a turn is still streaming (from the agent + * status hook). Shown as an in-progress bubble until the transcript catches up. */ + streamingText?: string + hasMore?: boolean + loadingEarlier?: boolean + onLoadEarlier?: () => void + onSend: (text: string) => Promise + /** Optimistic queued sends (owned by the route so they survive view switches). */ + pending: Array<{ id: string; text: string }> + /** Controlled composer text (owned by the route so dictation can write to it). */ + composerText: string + onComposerTextChange: (text: string) => void + onAttachImage?: () => void + isAttaching?: boolean + onMicPress?: () => void + micActive?: boolean + dictationMode?: 'toggle' | 'hold' + onMicPressIn?: () => void + onMicPressOut?: () => void + inputLockReason?: MobileNativeChatInputLockReason | null + filePaths?: string[] + onNeedFiles?: (query: string) => void + /** A pending agent question/permission detected from live status, shown as a + * native card above the composer; answering sends text to the agent. */ + /** Structured AskUserQuestion prompt parsed from the transcript (preferred over + * the heuristic question card). */ + ask?: AskPrompt | null + /** Deliver the ask answer as per-question selections; the send hook turns them + * into selector keystrokes (Claude) or pasted label text (other agents). */ + onAnswerAsk?: (prompt: AskPrompt, selections: AskAnswerSelection[]) => Promise + onCancelAsk?: () => Promise + question?: MobileChatQuestion | null + onAnswerQuestion?: (text: string) => Promise + permission?: MobileChatPermission | null + onRespondPermission?: (send: string) => Promise + /** Open a worktree file tapped in agent markdown. */ + onOpenFile?: (relativePath: string) => void + /** Pixels to lift the composer by when the soft keyboard is open. The route + * owns keyboard tracking (the app uses manual lift, not KeyboardAvoidingView). */ + keyboardInset?: number +} + +export function MobileNativeChatView({ + messages, + status, + error, + agent, + agentWorking, + onStop, + streamingText, + hasMore, + loadingEarlier, + onLoadEarlier, + onSend, + pending, + composerText, + onComposerTextChange, + onAttachImage, + isAttaching, + onMicPress, + micActive, + dictationMode, + onMicPressIn, + onMicPressOut, + inputLockReason, + filePaths, + onNeedFiles, + ask, + onAnswerAsk, + onCancelAsk, + question, + onAnswerQuestion, + permission, + onRespondPermission, + onOpenFile, + keyboardInset = 0 +}: Props): React.JSX.Element { + const insets = useSafeAreaInsets() + const listRef = useRef>(null) + const [toolsExpanded, setToolsExpanded] = useState(false) + // Dismiss the question card as soon as it's answered; the live status lingers + // briefly (the agent emits a post-tool event with the same prompt), so hide it + // until a genuinely different question arrives. + const { askKey, showAsk, dismissAsk } = useMobileNativeChatAskDismiss(ask) + // Lift the composer clear of the keyboard, plus the bottom safe-area so it + // never sits under the home indicator / nav bar (mirrors the terminal dock). + const bottomPad = keyboardInset > 0 ? keyboardInset + insets.bottom : insets.bottom + const [atBottom, setAtBottom] = useState(true) + const sendScrollTimerRef = useRef | null>(null) + const { fontScale, pinchGesture } = useMobileNativeChatPinchGesture() + // Surface a rejected send inline above the composer — a bottom toast gets hidden + // behind the keyboard (the case that prompted this). Auto-dismisses after a beat. + const [sendFailed, setSendFailed] = useState(false) + useEffect(() => { + if (!sendFailed) { + return + } + const t = setTimeout(() => setSendFailed(false), 4000) + return () => clearTimeout(t) + }, [sendFailed]) + + useEffect( + () => () => { + if (sendScrollTimerRef.current) { + clearTimeout(sendScrollTimerRef.current) + } + }, + [] + ) + + const pendingIds = useMemo(() => new Set(pending.map((p) => p.id)), [pending]) + // `data` is the list source: folded transcript + synthetic streaming bubble + + // route-owned optimistic queued messages. Memoize on the same deps so the + // downstream autoscroll effects/`renderItem` keep referential stability. + const foldedMessages = useMemo(() => foldMobileNativeChatMessages(messages), [messages]) + const { data } = useMemo( + () => buildMobileNativeChatTransientData({ folded: foldedMessages, streamingText, pending }), + [foldedMessages, streamingText, pending] + ) + + // Follow the tail as the conversation grows and keep the newest message above + // the keyboard when it opens — but only when already pinned to the bottom, so + // we don't yank the user away while they read history. (Also fires on keyboard + // close, which is harmless while atBottom.) + useEffect(() => { + if (data.length === 0 || !atBottom) { + return + } + const t = setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 60) + return () => clearTimeout(t) + }, [data.length, atBottom, keyboardInset]) + + const handleSend = useCallback( + async (text: string): Promise => { + const accepted = await onSend(text) + if (!accepted) { + setSendFailed(true) + return false + } + setSendFailed(false) + // Always jump to the newest message when the user sends. + setAtBottom(true) + if (sendScrollTimerRef.current) { + clearTimeout(sendScrollTimerRef.current) + } + sendScrollTimerRef.current = setTimeout(() => { + sendScrollTimerRef.current = null + listRef.current?.scrollToEnd({ animated: true }) + }, 60) + return true + }, + [onSend] + ) + + const onScroll = useCallback( + (e: NativeSyntheticEvent) => { + const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent + const distanceFromBottom = contentSize.height - (contentOffset.y + layoutMeasurement.height) + setAtBottom(distanceFromBottom < 80) + // Near the top — page in older history. + if (contentOffset.y < 60 && hasMore && !loadingEarlier) { + onLoadEarlier?.() + } + }, + [hasMore, loadingEarlier, onLoadEarlier] + ) + + // Align a single message's top to the top of the viewport. + const onScrollToMessage = useCallback((index: number) => { + listRef.current?.scrollToIndex({ index, viewPosition: 0, animated: true }) + }, []) + + const renderItem = useCallback( + ({ item, index }: { item: NativeChatMessage; index: number }) => ( + + ), + [pendingIds, toolsExpanded, fontScale, onScrollToMessage, onOpenFile] + ) + + const emptyState = mobileNativeChatEmptyState(status, agent ?? null, error) + const showLoading = status === 'loading' && messages.length === 0 + + // Composer-lock flicker guard: on a remote link, brief connState blips or lease + // hand-offs would otherwise toggle the lock placeholder on and off. Only surface + // a lock once it has held ~600ms; drop it instantly so unlocking stays snappy. + const rawLockReason = inputLockReason ?? null + const [lockHeld, setLockHeld] = useState(false) + useEffect(() => { + if (rawLockReason === null) { + setLockHeld(false) + return + } + const timer = setTimeout(() => setLockHeld(true), 600) + return () => clearTimeout(timer) + }, [rawLockReason]) + const lockReason = lockHeld ? rawLockReason : null + + return ( + + {showLoading ? ( + + + + ) : ( + + + item.id} + renderItem={renderItem} + contentContainerStyle={styles.listContent} + onScroll={onScroll} + scrollEventThrottle={32} + onContentSizeChange={() => { + if (data.length > 0 && atBottom) { + listRef.current?.scrollToEnd({ animated: false }) + } + }} + // scrollToIndex can fail before an off-screen row is measured — + // fall back to an estimated offset, then retry once it's laid out. + onScrollToIndexFailed={(info) => { + listRef.current?.scrollToOffset({ + offset: info.averageItemLength * info.index, + animated: true + }) + setTimeout(() => { + listRef.current?.scrollToIndex({ + index: info.index, + viewPosition: 0, + animated: true + }) + }, 120) + }} + ListHeaderComponent={ + hasMore ? ( + + {loadingEarlier ? ( + + ) : ( + Load earlier messages + )} + + ) : null + } + ListEmptyComponent={ + emptyState ? ( + + {emptyState.title} + {emptyState.subtitle} + + ) : null + } + /> + + {/* Jump-to-latest control. The scroll-to-top affordance now lives + per-message (the up-arrow in each agent message's controls). */} + {!atBottom ? ( + listRef.current?.scrollToEnd({ animated: true })} + > + + + ) : null} + + )} + {/* Pending agent prompt: a structured AskUserQuestion wins, then a + heuristic permission, then a heuristic question. */} + {showAsk && ask ? ( + { + const accepted = (await onAnswerAsk?.(ask, selections)) ?? false + if (accepted) { + dismissAsk() + } + return accepted + }} + onCancel={async () => { + const accepted = (await onCancelAsk?.()) ?? false + if (accepted) { + dismissAsk() + } + return accepted + }} + /> + ) : permission ? ( + (await onRespondPermission?.(send)) ?? false} + /> + ) : question ? ( + (await onAnswerQuestion?.(text)) ?? false} + /> + ) : null} + {/* Chrome row above the composer: the working indicator and the global + tool-calls expand/collapse toggle on the left, Stop in the far corner. */} + + + {agentWorking ? : null} + [styles.chromeToggle, pressed && styles.pressed]} + onPress={() => setToolsExpanded((v) => !v)} + hitSlop={8} + > + {toolsExpanded ? ( + + ) : ( + + )} + {toolsExpanded ? 'Collapse' : 'Tools'} + + + {agentWorking ? ( + [styles.stopButton, pressed && styles.pressed]} + onPress={onStop} + hitSlop={8} + accessibilityLabel="Stop the agent" + > + + Stop + + ) : null} + + {sendFailed ? ( + + + {rawLockReason === 'disconnected' + ? 'Message not sent — reconnecting…' + : 'Message not sent'} + + + ) : null} + + + ) +} diff --git a/mobile/src/session/mobile-image-attachment.test.ts b/mobile/src/session/mobile-image-attachment.test.ts index 1bf3df59e74..9d725d5fe60 100644 --- a/mobile/src/session/mobile-image-attachment.test.ts +++ b/mobile/src/session/mobile-image-attachment.test.ts @@ -8,9 +8,9 @@ function ok(id: string, result: unknown): RpcSuccess { } function clientWithResponses(responses: RpcResponse[]): Pick & { - calls: Array<{ method: string; params: unknown }> + calls: { method: string; params: unknown }[] } { - const calls: Array<{ method: string; params: unknown }> = [] + const calls: { method: string; params: unknown }[] = [] return { calls, sendRequest: vi.fn(async (method: string, params?: unknown) => { @@ -35,7 +35,7 @@ describe('attachMobileImageToTerminal', () => { _meta: { runtimeId: 'r' } }, ok('save', '/tmp/orca-attach.png'), - ok('send', { ok: true }) + ok('send', { send: { accepted: true } }) ]) const sent = await attachMobileImageToTerminal('library', { @@ -65,7 +65,7 @@ describe('attachMobileImageToTerminal', () => { _meta: { runtimeId: 'r' } }, ok('save', '/tmp/x.png'), - ok('send', { ok: true }) + ok('send', { send: { accepted: true } }) ]) await attachMobileImageToTerminal('files', { @@ -104,7 +104,7 @@ describe('attachMobileImageToTerminal', () => { _meta: { runtimeId: 'r' } }, ok('save', '/tmp/y.png'), - ok('send', { ok: true }) + ok('send', { send: { accepted: true } }) ]) await attachMobileImageToTerminal('library', { @@ -119,7 +119,7 @@ describe('attachMobileImageToTerminal', () => { expect(sendCall?.params).not.toHaveProperty('client') }) - it('waits for pending live input before sending the image payload', async () => { + it('drops the image payload when the final pre-send check observes an input lease gap', async () => { const client = clientWithResponses([ { id: 'start', @@ -129,6 +129,8 @@ describe('attachMobileImageToTerminal', () => { }, ok('save', '/tmp/pending.png') ]) + // Why: upload can outlive the stream subscription whose acknowledgement + // originally enabled the composer. const beforeTerminalSend = vi.fn(async () => false) const sent = await attachMobileImageToTerminal('library', { @@ -144,4 +146,27 @@ describe('attachMobileImageToTerminal', () => { expect(beforeTerminalSend).toHaveBeenCalledWith('term-pending') expect(client.calls.some((call) => call.method === 'terminal.send')).toBe(false) }) + + it('reports failure when the terminal rejects the uploaded image path', async () => { + const client = clientWithResponses([ + { + id: 'start', + ok: false, + error: { code: 'method_not_found', message: 'no' }, + _meta: { runtimeId: 'r' } + }, + ok('save', '/tmp/rejected.png'), + ok('send', { send: { accepted: false } }) + ]) + + const sent = await attachMobileImageToTerminal('library', { + client, + terminal: 'term-rejected', + deviceToken: null, + getConnectionId: async () => null, + pickImage: vi.fn().mockResolvedValue({ base64: 'EEEE' }) + }) + + expect(sent).toBe(false) + }) }) diff --git a/mobile/src/session/mobile-image-attachment.ts b/mobile/src/session/mobile-image-attachment.ts index b7d14d9a04d..567be99a9a1 100644 --- a/mobile/src/session/mobile-image-attachment.ts +++ b/mobile/src/session/mobile-image-attachment.ts @@ -4,6 +4,7 @@ import { saveMobileClipboardImageAsTempFile } from './mobile-clipboard-image' import type { MobileImageSource, PickedMobileImage } from './mobile-image-source-picker' +import { isTerminalSendRpcAccepted } from '../terminal/terminal-send-rpc-response' export type AttachMobileImageDeps = { readonly client: Pick @@ -50,11 +51,11 @@ export async function attachMobileImageToTerminal( if (beforeTerminalSend && !(await beforeTerminalSend(terminal))) { return false } - await client.sendRequest('terminal.send', { + const response = await client.sendRequest('terminal.send', { terminal, text: payload, enter: false, ...(deviceToken ? { client: { id: deviceToken, type: 'mobile' as const } } : {}) }) - return true + return isTerminalSendRpcAccepted(response) } diff --git a/mobile/src/session/mobile-native-chat-answer-stepping.test.ts b/mobile/src/session/mobile-native-chat-answer-stepping.test.ts new file mode 100644 index 00000000000..05e05b1f188 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-answer-stepping.test.ts @@ -0,0 +1,61 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + mobileNativeChatQuestionOffsets, + scheduleMobileClaudeAnswer, + MOBILE_NATIVE_CHAT_ADVANCE_BUFFER_MS, + MOBILE_NATIVE_CHAT_QUESTION_STEP_MS, + MOBILE_NATIVE_CHAT_SUBMIT_DELAY_MS +} from './mobile-native-chat-answer-stepping' + +describe('mobileNativeChatQuestionOffsets', () => { + it('matches the desktop cadence constants', () => { + expect(MOBILE_NATIVE_CHAT_SUBMIT_DELAY_MS).toBe(500) + expect(MOBILE_NATIVE_CHAT_ADVANCE_BUFFER_MS).toBe(500) + expect(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS).toBe(1000) + }) + + it('paces each question a full step apart, Enter 500ms after its body', () => { + expect(mobileNativeChatQuestionOffsets(0)).toEqual({ bodyAt: 0, enterAt: 500 }) + expect(mobileNativeChatQuestionOffsets(1)).toEqual({ bodyAt: 1000, enterAt: 1500 }) + expect(mobileNativeChatQuestionOffsets(2)).toEqual({ bodyAt: 2000, enterAt: 2500 }) + }) +}) + +describe('scheduleMobileClaudeAnswer', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) + + it('writes each question body then its Enter, paced per step, one Enter per line', () => { + const events: string[] = [] + const timers = scheduleMobileClaudeAnswer( + ['', 'b', 'c'], // blank middle answer must still get its own body + Enter + (line) => events.push(`body:${line}`), + () => events.push('enter') + ) + expect(timers).toHaveLength(6) + + vi.advanceTimersByTime(0) + expect(events).toEqual(['body:']) + vi.advanceTimersByTime(MOBILE_NATIVE_CHAT_SUBMIT_DELAY_MS) + expect(events).toEqual(['body:', 'enter']) + vi.advanceTimersByTime(MOBILE_NATIVE_CHAT_ADVANCE_BUFFER_MS) + expect(events).toEqual(['body:', 'enter', 'body:b']) + + vi.runAllTimers() + expect(events).toEqual(['body:', 'enter', 'body:b', 'enter', 'body:c', 'enter']) + }) + + it('cancelling the returned timers stops all pending writes', () => { + const events: string[] = [] + const timers = scheduleMobileClaudeAnswer( + ['a', 'b'], + (line) => events.push(`body:${line}`), + () => events.push('enter') + ) + for (const timer of timers) { + clearTimeout(timer) + } + vi.runAllTimers() + expect(events).toEqual([]) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-answer-stepping.ts b/mobile/src/session/mobile-native-chat-answer-stepping.ts new file mode 100644 index 00000000000..441f41291d7 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-answer-stepping.ts @@ -0,0 +1,13 @@ +import { + nativeChatQuestionOffsets, + scheduleNativeChatAnswer, + NATIVE_CHAT_ADVANCE_BUFFER_MS, + NATIVE_CHAT_QUESTION_STEP_MS, + NATIVE_CHAT_SUBMIT_DELAY_MS +} from '../../../src/shared/native-chat-answer-stepping' + +export const MOBILE_NATIVE_CHAT_ADVANCE_BUFFER_MS = NATIVE_CHAT_ADVANCE_BUFFER_MS +export const MOBILE_NATIVE_CHAT_QUESTION_STEP_MS = NATIVE_CHAT_QUESTION_STEP_MS +export const MOBILE_NATIVE_CHAT_SUBMIT_DELAY_MS = NATIVE_CHAT_SUBMIT_DELAY_MS +export const mobileNativeChatQuestionOffsets = nativeChatQuestionOffsets +export const scheduleMobileClaudeAnswer = scheduleNativeChatAnswer diff --git a/mobile/src/session/mobile-native-chat-ask.test.ts b/mobile/src/session/mobile-native-chat-ask.test.ts new file mode 100644 index 00000000000..6654a0e8689 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-ask.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { + buildAskAnswerKeys, + extractPendingAsk, + formatAskAnswer, + parseAskFromStatus +} from './mobile-native-chat-ask' + +function msg(blocks: NativeChatMessage['blocks'], id = 'm'): NativeChatMessage { + return { id, role: 'assistant', blocks, timestamp: 0, source: 'transcript' } +} + +const askInput = { + questions: [ + { + question: 'Pick one', + header: 'Choice', + multiSelect: false, + options: [ + { label: 'A', description: 'first' }, + { label: 'B', description: 'second' } + ] + } + ] +} + +describe('extractPendingAsk', () => { + it('returns the structured prompt from a pending AskUserQuestion call', () => { + const ask = extractPendingAsk([ + msg([{ type: 'tool-call', name: 'AskUserQuestion', input: askInput }]) + ]) + expect(ask?.questions[0]).toMatchObject({ + question: 'Pick one', + header: 'Choice', + multiSelect: false + }) + expect(ask?.questions[0]!.options.map((o) => o.label)).toEqual(['A', 'B']) + }) + + it('returns null once a tool-result follows the ask', () => { + const ask = extractPendingAsk([ + msg([{ type: 'tool-call', name: 'AskUserQuestion', input: askInput }], 'a'), + msg([{ type: 'tool-result', output: 'answered' }], 'r') + ]) + expect(ask).toBeNull() + }) + + it('ignores non-ask tool calls', () => { + expect( + extractPendingAsk([msg([{ type: 'tool-call', name: 'Bash', input: { command: 'ls' } }])]) + ).toBeNull() + }) + + it('survives an unrelated earlier tool result (FIFO, not adjacency)', () => { + // A parallel Bash call precedes the ask; its result resolves the Bash call + // (oldest outstanding), so the unanswered question must remain pending. + const ask = extractPendingAsk([ + msg([{ type: 'tool-call', name: 'Bash', input: { command: 'ls' } }], 'c1'), + msg([{ type: 'tool-call', name: 'AskUserQuestion', input: askInput }], 'a1'), + msg([{ type: 'tool-result', output: 'ls output' }], 'r1') + ]) + expect(ask?.questions[0]!.question).toBe('Pick one') + }) + + it("clears the ask only when the ask's own result arrives", () => { + const ask = extractPendingAsk([ + msg([{ type: 'tool-call', name: 'Bash', input: { command: 'ls' } }], 'c1'), + msg([{ type: 'tool-call', name: 'AskUserQuestion', input: askInput }], 'a1'), + msg([{ type: 'tool-result', output: 'ls output' }], 'r1'), + msg([{ type: 'tool-result', output: 'answered' }], 'r2') + ]) + expect(ask).toBeNull() + }) + + it('keeps the latest ask when several appear', () => { + const ask = extractPendingAsk([ + msg([{ type: 'tool-call', name: 'AskUserQuestion', input: askInput }], 'a1'), + msg([{ type: 'tool-result', output: 'x' }], 'r1'), + msg([ + { + type: 'tool-call', + name: 'AskUserQuestion', + input: { + questions: [{ question: 'Second', multiSelect: false, options: [{ label: 'Z' }] }] + } + } + ]) + ]) + expect(ask?.questions[0]!.question).toBe('Second') + }) +}) + +describe('parseAskFromStatus', () => { + it('parses the live interactivePrompt JSON into a prompt', () => { + const ask = parseAskFromStatus(JSON.stringify(askInput)) + expect(ask?.questions[0]!.options.map((o) => o.label)).toEqual(['A', 'B']) + }) + + it('returns null for empty or malformed input', () => { + expect(parseAskFromStatus(undefined)).toBeNull() + expect(parseAskFromStatus('')).toBeNull() + expect(parseAskFromStatus('{not json')).toBeNull() + expect(parseAskFromStatus('{"foo":1}')).toBeNull() + }) +}) + +describe('formatAskAnswer', () => { + it('joins selected labels per question', () => { + const prompt = { + questions: [ + { question: 'q1', multiSelect: true, options: [{ label: 'A' }, { label: 'B' }] }, + { question: 'q2', multiSelect: false, options: [{ label: 'C' }] } + ] + } + expect(formatAskAnswer(prompt, [{ indices: [0, 1] }, { indices: [0] }])).toBe('A, B\nC') + }) + + it('keeps one line per question with a blank middle answer (3 questions)', () => { + const prompt = { + questions: [ + { question: 'q1', multiSelect: false, options: [{ label: 'A' }] }, + { question: 'q2', multiSelect: false, options: [{ label: 'B' }] }, + { question: 'q3', multiSelect: false, options: [{ label: 'C' }] } + ] + } + const answer = formatAskAnswer(prompt, [{ indices: [0] }, { indices: [] }, { indices: [0] }]) + expect(answer).toBe('A\n\nC') + expect(answer.split('\n')).toHaveLength(3) + }) +}) + +describe('buildAskAnswerKeys', () => { + it('answers a single-select pick with its option number only', () => { + const prompt = { + questions: [ + { question: 'q', multiSelect: false, options: [{ label: 'Tabs' }, { label: 'Spaces' }] } + ] + } + expect(buildAskAnswerKeys(prompt, [{ indices: [1] }])).toEqual([{ raw: '2' }]) + }) + + it('toggles multi-select numbers then steps to Submit and confirms', () => { + const prompt = { + questions: [ + { + question: 'q', + multiSelect: true, + options: [{ label: 'A' }, { label: 'B' }, { label: 'C' }] + } + ] + } + expect(buildAskAnswerKeys(prompt, [{ indices: [0, 2] }])).toEqual([ + { raw: '1' }, + { raw: '3' }, + { raw: '\x1b[C' }, + { raw: '\r' } + ]) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-ask.ts b/mobile/src/session/mobile-native-chat-ask.ts new file mode 100644 index 00000000000..50766b25ee4 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-ask.ts @@ -0,0 +1,2 @@ +// Mobile and desktop share one parser/formatter so question-tool behavior cannot drift. +export * from '../../../src/shared/native-chat-ask' diff --git a/mobile/src/session/mobile-native-chat-autocomplete.test.ts b/mobile/src/session/mobile-native-chat-autocomplete.test.ts new file mode 100644 index 00000000000..25c904c7b13 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-autocomplete.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import { + applyAutocomplete, + detectAutocompleteTrigger, + rankSuggestions +} from './mobile-native-chat-autocomplete' + +describe('detectAutocompleteTrigger', () => { + it('detects a slash command only at the start', () => { + expect(detectAutocompleteTrigger('/rev', 4)).toEqual({ + kind: 'slash', + query: 'rev', + start: 0, + end: 4 + }) + expect(detectAutocompleteTrigger('hi /rev', 7)).toBeNull() + }) + + it('detects an @ mention after whitespace or at start', () => { + expect(detectAutocompleteTrigger('@src', 4)).toMatchObject({ kind: 'file', query: 'src' }) + expect(detectAutocompleteTrigger('look at @comp', 13)).toMatchObject({ + kind: 'file', + query: 'comp' + }) + }) + + it('does not trigger @ mid-word (email-like)', () => { + expect(detectAutocompleteTrigger('me@host', 7)).toBeNull() + }) + + it('closes the token once a space is typed', () => { + expect(detectAutocompleteTrigger('@src ', 5)).toBeNull() + }) + + it('returns empty query right after the trigger char', () => { + expect(detectAutocompleteTrigger('@', 1)).toMatchObject({ kind: 'file', query: '' }) + }) +}) + +describe('applyAutocomplete', () => { + it('replaces the trigger span and leaves a trailing space + cursor', () => { + const trigger = detectAutocompleteTrigger('look at @comp', 13)! + const { text, cursor } = applyAutocomplete('look at @comp', trigger, '@src/App.tsx') + expect(text).toBe('look at @src/App.tsx ') + expect(cursor).toBe(text.length) + }) +}) + +describe('rankSuggestions', () => { + it('prefers prefix matches on the basename', () => { + const out = rankSuggestions(['src/app/Main.tsx', 'src/AppBar.tsx', 'lib/zapp.ts'], 'app') + expect(out[0]).toBe('src/AppBar.tsx') + expect(out).toContain('lib/zapp.ts') + }) + + it('returns the head of the list for an empty query', () => { + expect(rankSuggestions(['a', 'b', 'c'], '', 2)).toEqual(['a', 'b']) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-autocomplete.ts b/mobile/src/session/mobile-native-chat-autocomplete.ts new file mode 100644 index 00000000000..de0ddc91f67 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-autocomplete.ts @@ -0,0 +1,93 @@ +// Why: the composer offers two kinds of autocomplete — `@file` mentions and +// `/slash` commands. Detection is pure (text + cursor → active trigger) so it's +// unit-testable and the composer stays a thin view over it. + +export type AutocompleteKind = 'file' | 'slash' + +export type AutocompleteTrigger = { + kind: AutocompleteKind + /** The query typed after the trigger char (may be empty). */ + query: string + /** Index of the trigger char in the text (inclusive). */ + start: number + /** Index just past the cursor / token end (exclusive) — the replace span end. */ + end: number +} + +const TOKEN_CHAR = /[^\s]/ + +/** Detect an active autocomplete trigger at the cursor. A `@` mention triggers + * anywhere it follows whitespace/start; a `/` command only at the very start of + * the input (slash commands are line-leading). Returns null when the cursor is + * not inside such a token. */ +export function detectAutocompleteTrigger( + text: string, + cursor: number +): AutocompleteTrigger | null { + const pos = Math.max(0, Math.min(cursor, text.length)) + // Walk left from the cursor over non-whitespace token chars to find the trigger. + let i = pos - 1 + while (i >= 0 && TOKEN_CHAR.test(text[i]!)) { + i-- + } + const triggerIndex = i + 1 + const triggerChar = text[triggerIndex] + if (triggerChar !== '@' && triggerChar !== '/') { + return null + } + const before = triggerIndex === 0 ? '' : text[triggerIndex - 1]! + if (triggerChar === '/' && triggerIndex !== 0) { + // Slash commands are only offered at the very start of the message. + return null + } + if (triggerChar === '@' && triggerIndex !== 0 && !/\s/.test(before)) { + return null + } + const query = text.slice(triggerIndex + 1, pos) + // A space in the query means the token already closed. + if (/\s/.test(query)) { + return null + } + return { + kind: triggerChar === '@' ? 'file' : 'slash', + query, + start: triggerIndex, + end: pos + } +} + +/** Replace the trigger span with `value`, leaving a trailing space and the + * cursor after it. Returns the new text and the new cursor position. */ +export function applyAutocomplete( + text: string, + trigger: AutocompleteTrigger, + value: string +): { text: string; cursor: number } { + const inserted = `${value} ` + const next = text.slice(0, trigger.start) + inserted + text.slice(trigger.end) + return { text: next, cursor: trigger.start + inserted.length } +} + +/** Rank suggestions for a query: case-insensitive, prefix matches first, then + * substring matches, capped. Used for both file paths and command names. */ +export function rankSuggestions(candidates: readonly string[], query: string, limit = 8): string[] { + const q = query.toLowerCase() + if (q.length === 0) { + return candidates.slice(0, limit) + } + const prefix: string[] = [] + const substring: string[] = [] + for (const candidate of candidates) { + const lower = candidate.toLowerCase() + const base = lower.split('/').pop() ?? lower + if (lower.startsWith(q) || base.startsWith(q)) { + prefix.push(candidate) + } else if (lower.includes(q)) { + substring.push(candidate) + } + if (prefix.length >= limit) { + break + } + } + return [...prefix, ...substring].slice(0, limit) +} diff --git a/mobile/src/session/mobile-native-chat-blocks.test.ts b/mobile/src/session/mobile-native-chat-blocks.test.ts new file mode 100644 index 00000000000..57351e4a20c --- /dev/null +++ b/mobile/src/session/mobile-native-chat-blocks.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { + foldToolMessages, + pairToolBlocks, + splitNativeChatBlocks +} from './mobile-native-chat-blocks' + +function msg( + role: NativeChatMessage['role'], + blocks: NativeChatMessage['blocks'], + id: string = role +): NativeChatMessage { + return { id, role, blocks, timestamp: 0, source: 'transcript' } +} + +describe('splitNativeChatBlocks', () => { + it('separates prose from tool blocks', () => { + const { prose, tools } = splitNativeChatBlocks([ + { type: 'text', text: 'hi' }, + { type: 'tool-call', name: 'Bash', input: {} }, + { type: 'tool-result', output: 'ok' } + ]) + expect(prose.map((b) => b.type)).toEqual(['text']) + expect(tools.map((b) => b.type)).toEqual(['tool-call', 'tool-result']) + }) +}) + +describe('pairToolBlocks', () => { + it('pairs each call with the following result', () => { + const pairs = pairToolBlocks([ + { type: 'tool-call', name: 'Bash', input: { command: 'ls' } }, + { type: 'tool-result', output: 'a\nb' }, + { type: 'tool-call', name: 'Edit', input: {} }, + { type: 'tool-result', output: 'ok' } + ]) + expect(pairs).toHaveLength(2) + expect(pairs[0]!.call?.name).toBe('Bash') + expect(pairs[0]!.result?.output).toBe('a\nb') + expect(pairs[1]!.call?.name).toBe('Edit') + }) + + it('keeps an orphan result on its own', () => { + const pairs = pairToolBlocks([{ type: 'tool-result', output: 'x' }]) + expect(pairs).toEqual([{ result: { type: 'tool-result', output: 'x' } }]) + }) + + it('does not attach a second result to an already-filled pair', () => { + const pairs = pairToolBlocks([ + { type: 'tool-call', name: 'Bash', input: {} }, + { type: 'tool-result', output: '1' }, + { type: 'tool-result', output: '2' } + ]) + expect(pairs).toHaveLength(2) + expect(pairs[1]!.call).toBeUndefined() + expect(pairs[1]!.result?.output).toBe('2') + }) + + it('pairs batched parallel calls by ordinal, not adjacency', () => { + const call1 = { type: 'tool-call' as const, name: 'Bash', input: { command: 'a' } } + const call2 = { type: 'tool-call' as const, name: 'Bash', input: { command: 'b' } } + const result1 = { type: 'tool-result' as const, output: 'out-a' } + const result2 = { type: 'tool-result' as const, output: 'out-b' } + // Parallel calls stream as [call, call, result, result]; the Nth result + // answers the Nth call, so result1 must land on call1 (not the nearer call2). + const pairs = pairToolBlocks([call1, call2, result1, result2]) + + expect(pairs).toHaveLength(2) + expect(pairs[0]).toEqual({ call: call1, result: result1 }) + expect(pairs[1]).toEqual({ call: call2, result: result2 }) + }) + + it('does not graft a dropped over-limit call result onto a kept call', () => { + const call1 = { type: 'tool-call' as const, name: 'Bash', input: { command: 'a' } } + const call2 = { type: 'tool-call' as const, name: 'Bash', input: { command: 'b' } } + const call3 = { type: 'tool-call' as const, name: 'Bash', input: { command: 'c' } } + const result1 = { type: 'tool-result' as const, output: 'out-a' } + const result2 = { type: 'tool-result' as const, output: 'out-b' } + const result3 = { type: 'tool-result' as const, output: 'out-c' } + const pairs = pairToolBlocks([call1, call2, call3, result1, result2, result3], 2) + + expect(pairs).toHaveLength(2) + expect(pairs[0]).toEqual({ call: call1, result: result1 }) + // call3 was dropped past the limit, so result3 must not misgraft onto call2. + expect(pairs[1]).toEqual({ call: call2, result: result2 }) + }) + + it('retains the last result while bounding rendered pairs', () => { + const blocks = Array.from({ length: 20 }, (_unused, index) => [ + { type: 'tool-call' as const, name: 'Bash', input: { index } }, + { type: 'tool-result' as const, output: String(index) } + ]).flat() + const pairs = pairToolBlocks(blocks, 6) + + expect(pairs).toHaveLength(6) + expect(pairs[5].result?.output).toBe('5') + }) +}) + +describe('foldToolMessages', () => { + it('folds tool-call-only assistant messages and result messages into the prose turn', () => { + const folded = foldToolMessages([ + msg('assistant', [{ type: 'text', text: 'doing' }], 'a0'), + msg('assistant', [{ type: 'tool-call', name: 'Bash', input: {} }], 'a1'), + msg('tool', [{ type: 'tool-result', output: 'done' }], 't1'), + msg('assistant', [{ type: 'tool-call', name: 'Edit', input: {} }], 'a2'), + msg('tool', [{ type: 'tool-result', output: 'ok' }], 't2') + ]) + expect(folded).toHaveLength(1) + expect(folded[0]!.blocks.map((b) => b.type)).toEqual([ + 'text', + 'tool-call', + 'tool-result', + 'tool-call', + 'tool-result' + ]) + }) + + it('starts a new turn at the next prose assistant message', () => { + const folded = foldToolMessages([ + msg('assistant', [{ type: 'text', text: 't1' }], 'a0'), + msg('assistant', [{ type: 'tool-call', name: 'Bash', input: {} }], 'a1'), + msg('assistant', [{ type: 'text', text: 't2' }], 'a2') + ]) + expect(folded.map((m) => m.id)).toEqual(['a0', 'a2']) + }) + + it('keeps a tool message standalone when no assistant precedes it', () => { + const folded = foldToolMessages([msg('tool', [{ type: 'tool-result', output: 'x' }])]) + expect(folded).toHaveLength(1) + expect(folded[0]!.role).toBe('tool') + }) + + it('does not merge across a user message', () => { + const folded = foldToolMessages([ + msg('assistant', [{ type: 'tool-call', name: 'Bash', input: {} }], 'a'), + msg('user', [{ type: 'text', text: 'q' }], 'u'), + msg('tool', [{ type: 'tool-result', output: 'r' }], 't') + ]) + expect(folded.map((m) => m.role)).toEqual(['assistant', 'user', 'tool']) + }) + + it('folds a long tool run without mutating the source assistant', () => { + const assistant = msg('assistant', [{ type: 'text', text: 'working' }], 'a') + const tools = Array.from({ length: 1_000 }, (_unused, index) => + msg('tool', [{ type: 'tool-result', output: String(index) }], `t-${index}`) + ) + const folded = foldToolMessages([assistant, ...tools]) + + expect(folded[0].blocks).toHaveLength(1_001) + expect(assistant.blocks).toHaveLength(1) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-blocks.ts b/mobile/src/session/mobile-native-chat-blocks.ts new file mode 100644 index 00000000000..1cf293a79fd --- /dev/null +++ b/mobile/src/session/mobile-native-chat-blocks.ts @@ -0,0 +1,11 @@ +export { + isImageRefBlock, + isTextBlock, + NATIVE_CHAT_SOURCE_PRIORITY +} from '../../../src/shared/native-chat-types' +export { + foldToolMessages, + pairToolBlocks, + splitNativeChatBlocks, + type NativeChatToolPair as ToolPair +} from '../../../src/shared/native-chat-tool-fold' diff --git a/mobile/src/session/mobile-native-chat-diff.test.ts b/mobile/src/session/mobile-native-chat-diff.test.ts new file mode 100644 index 00000000000..3171b818050 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-diff.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { diffFromText, diffFromToolCall } from './mobile-native-chat-diff' + +describe('diffFromToolCall', () => { + it('builds del/add lines from an Edit tool call', () => { + const lines = diffFromToolCall('Edit', { + file_path: 'src/a.ts', + old_string: 'const a = 1\nconst b = 2', + new_string: 'const a = 10' + }) + expect(lines).toEqual([ + { kind: 'meta', text: 'src/a.ts' }, + { kind: 'del', text: 'const a = 1' }, + { kind: 'del', text: 'const b = 2' }, + { kind: 'add', text: 'const a = 10' } + ]) + }) + + it('returns null for non-edit tools', () => { + expect(diffFromToolCall('Bash', { command: 'ls' })).toBeNull() + }) +}) + +describe('diffFromText', () => { + it('parses unified diff lines with colours', () => { + const lines = diffFromText('@@ -1,2 +1,2 @@\n context\n-old\n+new') + expect(lines).toEqual([ + { kind: 'meta', text: '@@ -1,2 +1,2 @@' }, + { kind: 'context', text: ' context' }, + { kind: 'del', text: 'old' }, + { kind: 'add', text: 'new' } + ]) + }) + + it('does not treat ordinary prose as a diff', () => { + expect(diffFromText('Here is a list:\n- one bullet\nthat is all')).toBeNull() + }) + + it('caps large diffs before creating render rows', () => { + const text = Array.from( + { length: 1_000 }, + (_unused, index) => `${index % 2 === 0 ? '+' : '-'}line-${index}` + ).join('\n') + const lines = diffFromText(text) + + expect(lines).toHaveLength(120) + expect(lines?.at(-1)).toEqual({ kind: 'meta', text: '… diff truncated …' }) + }) + + it('honors a smaller caller-owned aggregate row budget', () => { + const text = Array.from( + { length: 100 }, + (_unused, index) => `${index % 2 === 0 ? '+' : '-'}line-${index}` + ).join('\n') + + expect(diffFromText(text, 20)).toHaveLength(20) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-diff.ts b/mobile/src/session/mobile-native-chat-diff.ts new file mode 100644 index 00000000000..4ace06d37d0 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-diff.ts @@ -0,0 +1,6 @@ +export { + diffFromText, + diffFromToolCall, + type NativeChatDiffLine as DiffLine, + type NativeChatDiffLineKind as DiffLineKind +} from '../../../src/shared/native-chat-diff' diff --git a/mobile/src/session/mobile-native-chat-eligibility.test.ts b/mobile/src/session/mobile-native-chat-eligibility.test.ts new file mode 100644 index 00000000000..387736f065e --- /dev/null +++ b/mobile/src/session/mobile-native-chat-eligibility.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest' +import type { AgentStatusEntry } from '../../../src/shared/agent-status-types' +import { + canShowMobileNativeChat, + isMobileNativeChatTranscriptReadable, + resolveMobileNativeChat +} from './mobile-native-chat-eligibility' + +function status(overrides: Partial = {}): AgentStatusEntry { + return { + state: 'working', + prompt: '', + updatedAt: 0, + stateStartedAt: 0, + paneKey: 'tab:leaf', + ...overrides + } as AgentStatusEntry +} + +describe('resolveMobileNativeChat', () => { + it('prefers the authoritative supported live agent over a stale launch hint', () => { + expect( + resolveMobileNativeChat({ + type: 'terminal', + launchAgent: 'claude', + agentStatus: { + agentType: 'codex', + providerSession: { id: 'codex-session', transcriptPath: '/tmp/codex.jsonl' } + } + } as never) + ).toMatchObject({ agent: 'codex', sessionId: 'codex-session' }) + }) + + it('rejects an unsupported live agent instead of combining it with a stale hint', () => { + expect( + resolveMobileNativeChat({ + type: 'terminal', + launchAgent: 'claude', + agentStatus: { + agentType: 'gemini', + providerSession: { id: 'gemini-session', transcriptPath: '/tmp/gemini.jsonl' } + } + } as never) + ).toBeNull() + }) + it('resolves agent + sessionId from launchAgent and provider session', () => { + expect( + resolveMobileNativeChat({ + type: 'terminal', + launchAgent: 'claude', + agentStatus: status({ + providerSession: { + key: 'session_id', + id: 'sess-1', + transcriptPath: '/tmp/claude-real-transcript.jsonl' + } + }) + }) + ).toEqual({ + agent: 'claude', + sessionId: 'sess-1', + transcriptPath: '/tmp/claude-real-transcript.jsonl' + }) + }) + + it('falls back to agentStatus.agentType when no launchAgent', () => { + expect( + resolveMobileNativeChat({ + type: 'terminal', + agentStatus: status({ agentType: 'codex' }) + }) + ).toEqual({ agent: 'codex', sessionId: null, transcriptPath: null }) + }) + + it('admits OpenClaude with its distinct agent identity', () => { + expect(resolveMobileNativeChat({ type: 'terminal', launchAgent: 'openclaude' })).toEqual({ + agent: 'openclaude', + sessionId: null, + transcriptPath: null + }) + }) + + it('returns null for unsupported agents', () => { + expect(resolveMobileNativeChat({ type: 'terminal', launchAgent: 'gemini' })).toBeNull() + }) + + it('admits Grok only when its transcript is readable by the serving host', () => { + const tab = { type: 'terminal', launchAgent: 'grok' } + expect(resolveMobileNativeChat(tab, isMobileNativeChatTranscriptReadable(null))).toMatchObject({ + agent: 'grok' + }) + expect( + resolveMobileNativeChat(tab, isMobileNativeChatTranscriptReadable('runtime-ssh-environment')) + ).toMatchObject({ agent: 'grok' }) + expect( + resolveMobileNativeChat(tab, isMobileNativeChatTranscriptReadable('model-a-ssh')) + ).toBeNull() + }) + + it('returns null for a plain shell (no agent)', () => { + expect(resolveMobileNativeChat({ type: 'terminal' })).toBeNull() + }) + + it('returns null for non-terminal tabs', () => { + expect(resolveMobileNativeChat({ type: 'browser', launchAgent: 'claude' })).toBeNull() + }) + + it('canShowMobileNativeChat mirrors resolution', () => { + expect(canShowMobileNativeChat({ type: 'terminal', launchAgent: 'claude' })).toBe(true) + expect(canShowMobileNativeChat(null)).toBe(false) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-eligibility.ts b/mobile/src/session/mobile-native-chat-eligibility.ts new file mode 100644 index 00000000000..84e0e00d012 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-eligibility.ts @@ -0,0 +1,66 @@ +import type { AgentStatusEntry } from '../../../src/shared/agent-status-types' +import { isRuntimeOwnedSshTargetId } from '../../../src/shared/execution-host' +import { isNativeChatSupportedAgent } from '../../../src/shared/native-chat-agent-support' + +// Why: native chat renders an agent's own JSONL transcript, and the host +// resolver knows these transcript layouts. Grok is additionally gated on host +// readability because Model-A SSH stores its transcript on the remote target. +export function isMobileNativeChatTranscriptReadable( + connectionId: string | null | undefined +): boolean { + return connectionId === null || isRuntimeOwnedSshTargetId(connectionId) +} + +export type MobileNativeChatResolution = { + agent: string + /** The agent's own session id, or null before it has reported one (the view + * then shows a waiting state instead of trying to read an unaddressable file). */ + sessionId: string | null + /** Hook-reported transcript path. Recent Claude sessions cannot always be + * resolved from the provider session id, so mobile forwards this to runtime. */ + transcriptPath: string | null +} + +export type MobileNativeChatTab = { + type: string + launchAgent?: string | null + agentStatus?: AgentStatusEntry | null +} + +/** Resolve a session tab to the transcript identity native chat needs, or + * null when the tab can't show native chat (not a terminal, no agent, or an + * agent whose transcript the host can't read). Agent comes from the launch + * hint or the live status; session id from the captured provider session. */ +export function resolveMobileNativeChat( + tab: MobileNativeChatTab | null, + nativeChatTranscriptIsLocalReadable = false +): MobileNativeChatResolution | null { + if (!tab || tab.type !== 'terminal') { + return null + } + const liveAgent = tab.agentStatus?.agentType ?? null + const agent = liveAgent + ? isNativeChatSupportedAgent(liveAgent) + ? liveAgent + : null + : tab.launchAgent + if (!agent || !isNativeChatSupportedAgent(agent)) { + return null + } + if (agent === 'grok' && !nativeChatTranscriptIsLocalReadable) { + return null + } + return { + agent, + sessionId: tab.agentStatus?.providerSession?.id ?? null, + transcriptPath: tab.agentStatus?.providerSession?.transcriptPath ?? null + } +} + +/** Whether the tab can toggle into native chat — gates the long-press item. */ +export function canShowMobileNativeChat( + tab: MobileNativeChatTab | null, + nativeChatTranscriptIsLocalReadable = false +): boolean { + return resolveMobileNativeChat(tab, nativeChatTranscriptIsLocalReadable) !== null +} diff --git a/mobile/src/session/mobile-native-chat-merge.test.ts b/mobile/src/session/mobile-native-chat-merge.test.ts new file mode 100644 index 00000000000..61312670512 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-merge.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { mergeNativeChatMessages } from './mobile-native-chat-merge' + +function msg(id: string, overrides: Partial = {}): NativeChatMessage { + return { + id, + role: 'assistant', + blocks: [{ type: 'text', text: id }], + timestamp: 0, + source: 'transcript', + ...overrides + } +} + +describe('mergeNativeChatMessages', () => { + it('appends new ids in arrival order', () => { + const merged = mergeNativeChatMessages([msg('a')], [msg('b'), msg('c')]) + expect(merged.map((m) => m.id)).toEqual(['a', 'b', 'c']) + }) + + it('replaces a re-emitted id in place without reordering', () => { + const merged = mergeNativeChatMessages( + [msg('a'), msg('b'), msg('c')], + [msg('b', { blocks: [{ type: 'text', text: 'updated' }] })] + ) + expect(merged.map((m) => m.id)).toEqual(['a', 'b', 'c']) + expect(merged[1]!.blocks).toEqual([{ type: 'text', text: 'updated' }]) + }) + + it('does not let a lower-priority source overwrite a higher one', () => { + const merged = mergeNativeChatMessages( + [msg('a', { source: 'transcript', blocks: [{ type: 'text', text: 'real' }] })], + [msg('a', { source: 'scrape', blocks: [{ type: 'text', text: 'scraped' }] })] + ) + expect(merged[0]!.blocks).toEqual([{ type: 'text', text: 'real' }]) + }) + + it('returns the existing array unchanged for an empty batch', () => { + const existing = [msg('a')] + expect(mergeNativeChatMessages(existing, [])).toBe(existing) + }) + + it('does not mutate the existing array', () => { + const existing = [msg('a')] + mergeNativeChatMessages(existing, [msg('b')]) + expect(existing.map((m) => m.id)).toEqual(['a']) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-merge.ts b/mobile/src/session/mobile-native-chat-merge.ts new file mode 100644 index 00000000000..6b6ffe1781e --- /dev/null +++ b/mobile/src/session/mobile-native-chat-merge.ts @@ -0,0 +1,9 @@ +// Keep the established mobile import path while executing the shared desktop/mobile merger. +export { + applyAppend, + boundNativeChatWindow, + createNativeChatMerger, + mergeNativeChatMessages, + replaceList, + type NativeChatMerger +} from '../../../src/shared/native-chat-merge' diff --git a/mobile/src/session/mobile-native-chat-merger-oracle.test.ts b/mobile/src/session/mobile-native-chat-merger-oracle.test.ts new file mode 100644 index 00000000000..2a034fd33f3 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-merger-oracle.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { + applyAppend, + boundNativeChatWindow, + createNativeChatMerger, + mergeNativeChatMessages, + replaceList +} from './mobile-native-chat-merge' + +function msg(id: string, overrides: Partial = {}): NativeChatMessage { + return { + id, + role: 'assistant', + blocks: [{ type: 'text', text: id }], + timestamp: 0, + source: 'transcript', + ...overrides + } +} + +// ORACLE: the stateful merger (cached index, applyAppend) must deep-equal the +// pure mergeNativeChatMessages for every prefix of an adversarial subscribe +// sequence — the cache must never diverge from a full rebuild (#18). +describe('mobile stateful merger — oracle vs pure merge', () => { + const base = [msg('a'), msg('b', { source: 'hook' })] + const batches: NativeChatMessage[][] = [ + [msg('c')], // pure tail + [], // empty frame + [msg('b', { source: 'transcript', blocks: [{ type: 'text', text: 'final' }] })], // supersede + [msg('b', { source: 'scrape' })], // lower priority — ignored + [msg('a')], // re-emit, no change + [msg('d'), msg('e')] // multi tail + ] + + it('matches the pure merge for every prefix', () => { + const merger = createNativeChatMerger() + replaceList(merger, base) + let pure = mergeNativeChatMessages(base, []) + expect(merger.list).toEqual(pure) + for (const batch of batches) { + const out = applyAppend(merger, batch) + pure = mergeNativeChatMessages(pure, batch) + expect(out).toEqual(pure) + } + }) + + it('keeps prior row identity on a pure tail append', () => { + const merger = createNativeChatMerger() + replaceList(merger, base) + const before = merger.list + const tail = msg('z') + const after = applyAppend(merger, [tail]) + expect(after).not.toBe(before) + expect(after[0]).toBe(before[0]) + expect(after[after.length - 1]).toBe(tail) + }) + + it('bounds the window to the most-recent tail', () => { + const long = Array.from({ length: 10 }, (_, i) => msg(`m${i}`)) + expect(boundNativeChatWindow(long, 3).map((m) => m.id)).toEqual(['m7', 'm8', 'm9']) + expect(boundNativeChatWindow(long, 0)).toBe(long) + expect(boundNativeChatWindow(long, 20)).toBe(long) + }) + + it('bounds live appends and rebuilds the cached indexes for the retained tail', () => { + const merger = createNativeChatMerger() + replaceList(merger, [msg('a'), msg('b'), msg('c')]) + + expect(applyAppend(merger, [msg('d'), msg('e')], 3).map((m) => m.id)).toEqual(['c', 'd', 'e']) + expect(merger.indexById.has('a')).toBe(false) + expect(merger.indexById.get('c')).toBe(0) + expect( + applyAppend(merger, [msg('d', { blocks: [{ type: 'text', text: 'updated' }] })], 3)[1] + ).toMatchObject({ id: 'd', blocks: [{ type: 'text', text: 'updated' }] }) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-message-styles.ts b/mobile/src/session/mobile-native-chat-message-styles.ts new file mode 100644 index 00000000000..c67ffb738cc --- /dev/null +++ b/mobile/src/session/mobile-native-chat-message-styles.ts @@ -0,0 +1,168 @@ +import { StyleSheet } from 'react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' + +export const TEXT_SIZE = 17 +export const MONO_SIZE = 12 +export const MAX_TOOL_RESULT_CHARS = 4000 + +export const styles = StyleSheet.create({ + row: { + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm + }, + rowUser: { + alignItems: 'flex-end' + }, + content: { + maxWidth: '100%', + gap: spacing.sm + }, + userBubble: { + maxWidth: '88%', + backgroundColor: colors.textPrimary, + borderRadius: radii.card, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm + }, + userText: { + color: colors.bgBase, + fontSize: TEXT_SIZE, + lineHeight: TEXT_SIZE + 6, + fontWeight: '500' + }, + controls: { + flexDirection: 'row', + justifyContent: 'flex-end', + gap: spacing.xs, + marginBottom: 2, + opacity: 0.7 + }, + controlButton: { + padding: 3 + }, + controlPressed: { + opacity: 0.5 + }, + copied: { + backgroundColor: colors.diffAddedBg, + borderRadius: radii.card + }, + reasoning: { + opacity: 0.7 + }, + queued: { + opacity: 0.55 + }, + queuedTag: { + color: colors.textMuted, + fontSize: 11, + fontWeight: '600', + marginBottom: 2 + }, + toolRun: { + marginTop: spacing.xs + }, + toolRunHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + }, + toolRunToggle: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingVertical: 3 + }, + controlsRow: { + flexDirection: 'row', + justifyContent: 'flex-end' + }, + toolRunCount: { + color: colors.statusGreen, + fontFamily: typography.monoFamily, + fontSize: MONO_SIZE, + fontWeight: '700' + }, + toolRunLabel: { + flex: 1, + color: colors.textMuted, + fontFamily: typography.monoFamily, + fontSize: MONO_SIZE + }, + toolRunBody: { + paddingLeft: spacing.sm, + borderLeftWidth: 2, + borderLeftColor: colors.borderSubtle, + marginTop: spacing.xs + }, + toolLine: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingVertical: 3 + }, + toolName: { + color: colors.textPrimary, + fontFamily: typography.monoFamily, + fontSize: MONO_SIZE + 1, + fontWeight: '600' + }, + toolPreview: { + flex: 1, + color: colors.textMuted, + fontFamily: typography.monoFamily, + fontSize: MONO_SIZE + }, + toolPreviewLink: { + color: colors.accentBlue, + textDecorationLine: 'underline' + }, + toolDetail: { + paddingLeft: spacing.lg, + paddingBottom: spacing.xs, + gap: spacing.xs + }, + mono: { + color: colors.textSecondary, + fontFamily: typography.monoFamily, + fontSize: MONO_SIZE, + lineHeight: MONO_SIZE + 5 + }, + toolResult: { + borderRadius: radii.button, + backgroundColor: colors.bgPanel, + padding: spacing.md + }, + toolResultError: { + backgroundColor: colors.diffDeletedBg + }, + imageRef: { + color: colors.textSecondary, + fontSize: TEXT_SIZE + }, + diff: { + borderRadius: radii.button, + backgroundColor: colors.bgPanel, + paddingVertical: spacing.xs, + overflow: 'hidden' + }, + diffLine: { + color: colors.textSecondary, + fontFamily: typography.monoFamily, + fontSize: MONO_SIZE, + lineHeight: MONO_SIZE + 5, + paddingHorizontal: spacing.sm + }, + diffAdd: { + color: colors.gitDecorationAdded, + backgroundColor: colors.diffAddedBg + }, + diffDel: { + color: colors.gitDecorationDeleted, + backgroundColor: colors.diffDeletedBg + }, + diffMeta: { + color: colors.textMuted + } +}) diff --git a/mobile/src/session/mobile-native-chat-message-text.test.ts b/mobile/src/session/mobile-native-chat-message-text.test.ts new file mode 100644 index 00000000000..225904cc5d8 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-message-text.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatBlock } from '../../../src/shared/native-chat-types' +import { + clampFontScale, + FONT_SCALE_MAX, + FONT_SCALE_MIN, + nativeChatMessageText +} from './mobile-native-chat-message-text' + +describe('nativeChatMessageText', () => { + it('joins text blocks and skips non-text blocks', () => { + const blocks: NativeChatBlock[] = [ + { type: 'text', text: 'Hello' }, + { type: 'tool-call', name: 'Read', input: {} }, + { type: 'text', text: 'World' } + ] + expect(nativeChatMessageText(blocks)).toBe('Hello\n\nWorld') + }) + + it('returns an empty string when there is no prose', () => { + const blocks: NativeChatBlock[] = [{ type: 'tool-call', name: 'Read', input: {} }] + expect(nativeChatMessageText(blocks)).toBe('') + }) + + it('trims surrounding whitespace', () => { + expect(nativeChatMessageText([{ type: 'text', text: ' hi ' }])).toBe('hi') + }) +}) + +describe('clampFontScale', () => { + it('clamps below the minimum', () => { + expect(clampFontScale(0.1)).toBe(FONT_SCALE_MIN) + }) + + it('clamps above the maximum', () => { + expect(clampFontScale(5)).toBe(FONT_SCALE_MAX) + }) + + it('passes through an in-range value', () => { + expect(clampFontScale(1.2)).toBe(1.2) + }) + + it('falls back to 1 for NaN', () => { + expect(clampFontScale(Number.NaN)).toBe(1) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-message-text.ts b/mobile/src/session/mobile-native-chat-message-text.ts new file mode 100644 index 00000000000..26aeea7680c --- /dev/null +++ b/mobile/src/session/mobile-native-chat-message-text.ts @@ -0,0 +1,24 @@ +import type { NativeChatBlock } from '../../../src/shared/native-chat-types' +import { isTextBlock } from './mobile-native-chat-blocks' + +/** Concatenate a message's text blocks into a single copyable string. Tool + * calls/results and image refs are skipped — Copy is for the agent's prose. */ +export function nativeChatMessageText(blocks: readonly NativeChatBlock[]): string { + return blocks + .filter(isTextBlock) + .map((b) => b.text) + .join('\n\n') + .trim() +} + +/** Pinch-to-zoom font bounds. Default 1 means no visible change until pinched. */ +export const FONT_SCALE_MIN = 0.8 +export const FONT_SCALE_MAX = 1.8 + +/** Clamp a proposed font scale into the supported range. */ +export function clampFontScale(scale: number): number { + if (Number.isNaN(scale)) { + return 1 + } + return Math.min(FONT_SCALE_MAX, Math.max(FONT_SCALE_MIN, scale)) +} diff --git a/mobile/src/session/mobile-native-chat-noise.test.ts b/mobile/src/session/mobile-native-chat-noise.test.ts new file mode 100644 index 00000000000..6977b764e72 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-noise.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { isNoiseMessage, stripNoiseMessages } from './mobile-native-chat-noise' + +function msg( + role: NativeChatMessage['role'], + text: string, + blocks?: NativeChatMessage['blocks'] +): NativeChatMessage { + return { + id: text.slice(0, 8), + role, + blocks: blocks ?? [{ type: 'text', text }], + timestamp: 0, + source: 'transcript' + } +} + +describe('isNoiseMessage', () => { + it('flags task-notification and system-reminder user turns', () => { + expect(isNoiseMessage(msg('user', '\nx'))).toBe(true) + expect(isNoiseMessage(msg('user', '\nbe careful'))).toBe(true) + expect( + isNoiseMessage( + msg( + 'user', + 'Caveat: The messages below were generated by the user while running local commands' + ) + ) + ).toBe(true) + expect(isNoiseMessage(msg('user', '[Request interrupted by user]'))).toBe(true) + }) + + it('flags inter-agent harness turns (teammate/agent messages)', () => { + expect(isNoiseMessage(msg('user', 'ping'))).toBe(true) + expect(isNoiseMessage(msg('user', 'status update'))).toBe(true) + }) + + it('keeps real user messages', () => { + expect(isNoiseMessage(msg('user', 'make it work for codex'))).toBe(false) + // A genuine custom tag is not a known harness tag, so it stays a user turn. + expect(isNoiseMessage(msg('user', ' is my band'))).toBe(false) + }) + + it('keeps assistant and tool turns', () => { + expect(isNoiseMessage(msg('assistant', ' in prose'))).toBe(false) + }) + + it('keeps a user turn that carries tool results', () => { + expect(isNoiseMessage(msg('user', '', [{ type: 'tool-result', output: 'ok' }]))).toBe(false) + }) + + it('stripNoiseMessages removes only the noise', () => { + const out = stripNoiseMessages([ + msg('user', 'hello'), + msg('user', 'done'), + msg('assistant', 'hi') + ]) + expect(out.map((m) => m.role)).toEqual(['user', 'assistant']) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-noise.ts b/mobile/src/session/mobile-native-chat-noise.ts new file mode 100644 index 00000000000..1b15a7e414a --- /dev/null +++ b/mobile/src/session/mobile-native-chat-noise.ts @@ -0,0 +1 @@ +export { isNoiseMessage, stripNoiseMessages } from '../../../src/shared/native-chat-noise' diff --git a/mobile/src/session/mobile-native-chat-open-file.test.ts b/mobile/src/session/mobile-native-chat-open-file.test.ts new file mode 100644 index 00000000000..2f8ceb936a7 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-open-file.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { + openMobileNativeChatFile, + resolveMobileNativeChatWorktreePath +} from './mobile-native-chat-open-file' + +describe('resolveMobileNativeChatWorktreePath', () => { + it('resolves an absolute tool path to a worktree-relative open target', async () => { + const sendRequest = vi.fn().mockResolvedValue({ + ok: true, + result: { + exists: true, + isDirectory: false, + openTarget: { kind: 'worktree-file', relativePath: 'src/app.ts' } + } + }) + await expect( + resolveMobileNativeChatWorktreePath({ + client: { sendRequest } as unknown as RpcClient, + worktreeId: 'worktree', + pathText: '/repo/src/app.ts', + terminal: 'terminal' + }) + ).resolves.toBe('src/app.ts') + expect(sendRequest).toHaveBeenCalledWith('files.resolveTerminalPath', { + worktree: 'id:worktree', + pathText: '/repo/src/app.ts', + terminal: 'terminal' + }) + }) + + it('opens only the resolved worktree-relative target', async () => { + const sendRequest = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + result: { + exists: true, + isDirectory: false, + openTarget: { kind: 'worktree-file', relativePath: 'src/app.ts' } + } + }) + .mockResolvedValueOnce({ ok: true, result: {} }) + + await openMobileNativeChatFile({ + client: { sendRequest } as unknown as RpcClient, + worktreeId: 'worktree', + pathText: '../repo/src/app.ts', + terminal: 'terminal' + }) + + expect(sendRequest).toHaveBeenLastCalledWith('files.open', { + worktree: 'id:worktree', + relativePath: 'src/app.ts' + }) + }) + + it('resolves null when the resolve request rejects', async () => { + const sendRequest = vi.fn().mockRejectedValue(new Error('Request timed out')) + await expect( + resolveMobileNativeChatWorktreePath({ + client: { sendRequest } as unknown as RpcClient, + worktreeId: 'worktree', + pathText: 'src/app.ts', + terminal: null + }) + ).resolves.toBeNull() + }) + + it('does not reject when the open request fails', async () => { + const sendRequest = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + result: { + exists: true, + isDirectory: false, + openTarget: { kind: 'worktree-file', relativePath: 'src/app.ts' } + } + }) + .mockRejectedValueOnce(new Error('connection interrupted')) + + await expect( + openMobileNativeChatFile({ + client: { sendRequest } as unknown as RpcClient, + worktreeId: 'worktree', + pathText: 'src/app.ts', + terminal: null + }) + ).resolves.toBeUndefined() + }) +}) diff --git a/mobile/src/session/mobile-native-chat-open-file.ts b/mobile/src/session/mobile-native-chat-open-file.ts new file mode 100644 index 00000000000..44d6d23c066 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-open-file.ts @@ -0,0 +1,51 @@ +import type { RuntimeTerminalPathResolution } from '../../../src/shared/runtime-types' +import type { RpcClient } from '../transport/rpc-client' + +export async function resolveMobileNativeChatWorktreePath(args: { + client: RpcClient + worktreeId: string + pathText: string + terminal: string | null +}): Promise { + try { + const response = await args.client.sendRequest('files.resolveTerminalPath', { + worktree: `id:${args.worktreeId}`, + pathText: args.pathText, + ...(args.terminal ? { terminal: args.terminal } : {}) + }) + if (!response.ok) { + return null + } + const resolved = response.result as RuntimeTerminalPathResolution + if (!resolved.exists || resolved.isDirectory) { + return null + } + return resolved.openTarget?.kind === 'worktree-file' + ? resolved.openTarget.relativePath + : (resolved.relativePath ?? null) + } catch { + // Callers fire-and-forget file opens; a disconnect/timeout must not become + // an unhandled rejection. + return null + } +} + +export async function openMobileNativeChatFile(args: { + client: RpcClient + worktreeId: string + pathText: string + terminal: string | null +}): Promise { + const relativePath = await resolveMobileNativeChatWorktreePath(args) + if (relativePath) { + try { + await args.client.sendRequest('files.open', { + worktree: `id:${args.worktreeId}`, + relativePath + }) + } catch { + // Best-effort open; failures surface as a no-op rather than an + // unhandled rejection. + } + } +} diff --git a/mobile/src/session/mobile-native-chat-permission-send.test.ts b/mobile/src/session/mobile-native-chat-permission-send.test.ts new file mode 100644 index 00000000000..2592038d7c9 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-permission-send.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { sendMobileNativeChatPermissionResponse } from './mobile-native-chat-permission-send' + +describe('sendMobileNativeChatPermissionResponse', () => { + it('writes an approval as raw bytes without appending Return', async () => { + const sendRequest = vi.fn().mockResolvedValue({ + ok: true, + result: { send: { handle: 'terminal', accepted: true, bytesWritten: 1 } } + }) + + await expect( + sendMobileNativeChatPermissionResponse({ + client: { sendRequest } as unknown as RpcClient, + terminal: 'terminal', + deviceToken: 'phone', + text: '1' + }) + ).resolves.toBe(true) + expect(sendRequest).toHaveBeenCalledWith('terminal.send', { + terminal: 'terminal', + text: '1', + enter: false, + client: { id: 'phone', type: 'mobile' } + }) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-permission-send.ts b/mobile/src/session/mobile-native-chat-permission-send.ts new file mode 100644 index 00000000000..679be2d94ed --- /dev/null +++ b/mobile/src/session/mobile-native-chat-permission-send.ts @@ -0,0 +1,49 @@ +import { useCallback, type MutableRefObject } from 'react' +import type { RpcClient } from '../transport/rpc-client' +import { sendMobileNativeChatMessage } from './mobile-native-chat-send' + +export function sendMobileNativeChatPermissionResponse(args: { + client: RpcClient + terminal: string + deviceToken: string | null + text: string +}): Promise { + // Why: approval choices are already complete terminal control sequences; + // appending Return changes both numbered choices and Escape denial. + return sendMobileNativeChatMessage({ + client: args.client, + terminal: args.terminal, + text: args.text, + enter: false, + ...(args.deviceToken ? { mobileClient: { id: args.deviceToken, type: 'mobile' as const } } : {}) + }) +} + +export function useMobileNativeChatPermissionSend(args: { + client: RpcClient | null + enabled: boolean + handleRef: MutableRefObject + deviceTokenRef: MutableRefObject + onSendError: (message: string) => void +}): (text: string) => Promise { + return useCallback( + async (text: string): Promise => { + const terminal = args.handleRef.current + if (!args.client || !terminal || !args.enabled) { + args.onSendError('Response not sent (disconnected)') + return false + } + const accepted = await sendMobileNativeChatPermissionResponse({ + client: args.client, + terminal, + deviceToken: args.deviceTokenRef.current, + text + }) + if (!accepted) { + args.onSendError('Response not sent') + } + return accepted + }, + [args.client, args.deviceTokenRef, args.enabled, args.handleRef, args.onSendError] + ) +} diff --git a/mobile/src/session/mobile-native-chat-permission.test.ts b/mobile/src/session/mobile-native-chat-permission.test.ts new file mode 100644 index 00000000000..4744c394756 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-permission.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from 'vitest' +import { detectAgentPermission, parseApprovalFromStatus } from './mobile-native-chat-permission' + +describe('detectAgentPermission', () => { + it('returns null for a working agent even with permission-like text', () => { + expect( + detectAgentPermission({ + state: 'working', + lastAssistantMessage: 'Do you want to proceed? (y/n)' + }) + ).toBeNull() + }) + + it('returns null when state is undefined', () => { + expect( + detectAgentPermission({ lastAssistantMessage: 'Do you want to proceed? (y/n)' }) + ).toBeNull() + }) + + it('returns null for ordinary prose while blocked', () => { + expect( + detectAgentPermission({ + state: 'blocked', + lastAssistantMessage: 'I finished editing the file and ran the tests.' + }) + ).toBeNull() + }) + + it('returns null when the message is empty', () => { + expect(detectAgentPermission({ state: 'waiting', lastAssistantMessage: ' ' })).toBeNull() + }) + + it('detects a (y/n) prompt and offers Allow/Deny with y/n send values', () => { + const result = detectAgentPermission({ + state: 'waiting', + lastAssistantMessage: 'Allow running `rm -rf build`? (y/n)' + }) + expect(result).not.toBeNull() + expect(result?.title).toBe('Permission requested') + expect(result?.options).toEqual([ + { label: 'Allow', send: 'y' }, + { label: 'Deny', send: 'n' } + ]) + expect(result?.detail).toContain('Allow running') + }) + + it('detects a numbered allow/deny menu and sends the digit', () => { + const result = detectAgentPermission({ + state: 'blocked', + lastAssistantMessage: + 'Claude wants to edit App.tsx. Do you want to proceed?\n' + + '1. Yes\n' + + '2. No, and tell Claude what to do differently' + }) + expect(result).not.toBeNull() + expect(result?.options).toHaveLength(2) + expect(result?.options[0]).toEqual({ label: 'Yes', send: '1' }) + expect(result?.options[1]?.send).toBe('2') + expect(result?.options[1]?.label).toContain('No') + }) + + it('handles parenthesized numbered options "1) Yes 2) No"', () => { + const result = detectAgentPermission({ + state: 'waiting', + lastAssistantMessage: 'Approve this command?\n1) Yes\n2) No' + }) + expect(result?.options.map((o) => o.send)).toEqual(['1', '2']) + }) + + it('supports a three-option numbered menu (Yes / Yes always / No)', () => { + const result = detectAgentPermission({ + state: 'blocked', + lastAssistantMessage: + "Allow this tool call?\n1. Yes\n2. Yes, and don't ask again this session\n3. No" + }) + expect(result?.options).toHaveLength(3) + expect(result?.options.map((o) => o.send)).toEqual(['1', '2', '3']) + }) + + it('adds an "Allow always" option when the text offers a persistent grant', () => { + const result = detectAgentPermission({ + state: 'waiting', + lastAssistantMessage: + 'Do you want to allow this? You can allow always for this session. (y/n)' + }) + expect(result?.options.map((o) => o.label)).toEqual(['Allow', 'Allow always', 'Deny']) + expect(result?.options.map((o) => o.send)).toEqual(['y', 'a', 'n']) + }) + + it('detects keyword-only permission asks without explicit y/n tokens', () => { + const result = detectAgentPermission({ + state: 'blocked', + lastAssistantMessage: 'I need your permission to run this Bash command.' + }) + expect(result).not.toBeNull() + expect(result?.options.map((o) => o.send)).toEqual(['y', 'n']) + }) + + it('detects "approve" / "deny" phrasing', () => { + const result = detectAgentPermission({ + state: 'waiting', + lastAssistantMessage: 'Please approve or deny this write to /etc/hosts.' + }) + expect(result).not.toBeNull() + expect(result?.options).toHaveLength(2) + }) + + it('truncates a very long detail line', () => { + const long = `Do you want to proceed? ${'x'.repeat(300)}` + const result = detectAgentPermission({ state: 'waiting', lastAssistantMessage: long }) + expect(result).not.toBeNull() + expect((result?.detail ?? '').length).toBeLessThanOrEqual(160) + }) + + it('shortens long numbered option labels', () => { + const result = detectAgentPermission({ + state: 'blocked', + lastAssistantMessage: + 'Proceed?\n1. Yes\n2. No, and explain in great detail exactly why this particular approach is wrong' + }) + const second = result?.options[1] + expect(second?.send).toBe('2') + expect((second?.label ?? '').length).toBeLessThanOrEqual(40) + }) +}) + +describe('parseApprovalFromStatus', () => { + it('parses an approval envelope into an Allow/Deny card', () => { + const card = parseApprovalFromStatus( + JSON.stringify({ approval: { tool: 'Bash', summary: 'rm -rf build' } }) + ) + expect(card?.title).toBe('Allow Bash?') + expect(card?.detail).toBe('rm -rf build') + expect(card?.options.map((o) => o.label)).toEqual(['Allow', 'Deny']) + expect(card?.options[0]!.send).toBe('1') + }) + + it('returns null for non-approval / malformed input', () => { + expect(parseApprovalFromStatus(undefined)).toBeNull() + expect(parseApprovalFromStatus('{bad')).toBeNull() + expect(parseApprovalFromStatus(JSON.stringify({ questions: [] }))).toBeNull() + expect(parseApprovalFromStatus(JSON.stringify({ approval: {} }))).toBeNull() + }) +}) diff --git a/mobile/src/session/mobile-native-chat-permission.ts b/mobile/src/session/mobile-native-chat-permission.ts new file mode 100644 index 00000000000..fe3f9a0d89b --- /dev/null +++ b/mobile/src/session/mobile-native-chat-permission.ts @@ -0,0 +1,179 @@ +// Agent permission asks (e.g. Claude/Codex "Do you want to proceed?") surface +// as plain TUI text in the agent's last assistant message — there is no +// structured permission event on mobile. We detect them heuristically so the +// native chat can render tappable Allow/Deny buttons instead of forcing the +// user to type into the composer. Be conservative: only fire when the agent is +// actually paused (blocked/waiting) AND the text reads like an approval ask. + +/** A detected permission prompt, rendered as a card with tappable options. + * Each option's `send` is the literal string to write back to the agent + * (e.g. "y", "1") when the user taps it. */ +export type MobileChatPermission = { + title: string + detail?: string + options: Array<{ label: string; send: string }> +} + +const ESCAPE = String.fromCharCode(27) + +/** Parse the live `agentStatus.interactivePrompt` approval envelope + * (`{ approval: { tool, summary } }`, emitted by the host on a PermissionRequest) + * into an Allow/Deny card. This is the reliable, agent-emitted signal — unlike + * detectAgentPermission it doesn't depend on heuristic text parsing. The default + * sends (number for allow, Escape for deny) match the common TUI approval prompt; + * detectAgentPermission still takes precedence when it can read the real numbered + * options from the prompt text. */ +export function parseApprovalFromStatus( + interactivePrompt: string | undefined | null +): MobileChatPermission | null { + if (!interactivePrompt) { + return null + } + let parsed: unknown + try { + parsed = JSON.parse(interactivePrompt) + } catch { + return null + } + if (!parsed || typeof parsed !== 'object') { + return null + } + const approval = (parsed as { approval?: unknown }).approval + if (!approval || typeof approval !== 'object') { + return null + } + const tool = (approval as { tool?: unknown }).tool + if (typeof tool !== 'string' || tool.length === 0) { + return null + } + const summary = (approval as { summary?: unknown }).summary + return { + title: `Allow ${tool}?`, + detail: typeof summary === 'string' && summary.length > 0 ? summary : undefined, + options: [ + { label: 'Allow', send: '1' }, + { label: 'Deny', send: ESCAPE } + ] + } +} + +type PermissionInput = { + state?: string + lastAssistantMessage?: string + toolName?: string + toolInput?: unknown +} + +// States where the agent is paused waiting on the human. Only these can yield a +// permission prompt — a "working" agent is mid-turn and must not be answered. +const PAUSED_STATES: ReadonlySet = new Set(['blocked', 'waiting']) + +// Phrases that read as an approval request. Kept broad but anchored to +// approval/permission language so ordinary prose doesn't trip detection. +const PERMISSION_PATTERNS: RegExp[] = [ + /\bpermission\b/i, + /\bapprove\b/i, + /\bapproval\b/i, + /\ballow\b/i, + /\bdeny\b/i, + /\bgrant\b/i, + /\bauthorize\b/i, + /\bdo you want to\b/i, + /\bwould you like to\b/i, + /\bproceed\?/i, + /\bconfirm\b/i, + /\(y\/n\)/i, + /\by\/n\b/i, + /\byes\/no\b/i, + /\bplease confirm\b/i +] + +function looksLikePermissionAsk(text: string): boolean { + return PERMISSION_PATTERNS.some((re) => re.test(text)) +} + +// A numbered-choice prompt like "1. Yes 2. No" / "2) No, and tell Claude…". +// Captures the option number and its label so we can send the literal digit. +const NUMBERED_OPTION_RE = /(?:^|\n)\s*(\d+)[.)]\s*([^\n]+)/g + +type NumberedOption = { num: string; text: string } + +function parseNumberedOptions(text: string): NumberedOption[] { + const out: NumberedOption[] = [] + for (const match of text.matchAll(NUMBERED_OPTION_RE)) { + const num = match[1] + const body = match[2]?.trim() + if (num && body) { + out.push({ num, text: body }) + } + } + return out +} + +// Whether a label reads as "allow for every future call" rather than just once. +function isAlwaysLabel(text: string): boolean { + return /\balways\b|don't ask again|do not ask again|for the rest|this session/i.test(text) +} + +function shortLabel(text: string, max = 40): string { + const trimmed = text.replace(/\s+/g, ' ').trim() + return trimmed.length > max ? `${trimmed.slice(0, max - 1)}…` : trimmed +} + +function buildNumberedPermission( + options: NumberedOption[], + detail: string | undefined +): MobileChatPermission { + return { + title: 'Permission requested', + detail, + options: options.map((opt) => ({ label: shortLabel(opt.text), send: opt.num })) + } +} + +function firstLine(text: string): string { + return text.split('\n')[0]?.trim() ?? '' +} + +/** + * Heuristically detect an agent permission ask from its paused-state context. + * Returns a renderable prompt, or null when the agent is working or the text + * doesn't read like an approval request. + */ +export function detectAgentPermission(input: PermissionInput): MobileChatPermission | null { + // Only answer a genuinely paused agent. A working agent is mid-turn. + if (!input.state || !PAUSED_STATES.has(input.state)) { + return null + } + + const text = typeof input.lastAssistantMessage === 'string' ? input.lastAssistantMessage : '' + if (!text.trim()) { + return null + } + + if (!looksLikePermissionAsk(text)) { + return null + } + + const detail = shortLabel(firstLine(text), 160) || undefined + + // Prefer an explicit numbered menu ("1. Yes 2. No, and tell…") — its labels + // and send-digits come straight from the agent, so no guessing. + const numbered = parseNumberedOptions(text) + if (numbered.length >= 2) { + return buildNumberedPermission(numbered, detail) + } + + // Otherwise fall back to a y/n prompt. We surface "Allow always" only when the + // text actually offers a persistent option, to avoid sending a token the agent + // doesn't understand. + const options: MobileChatPermission['options'] = [ + { label: 'Allow', send: 'y' }, + { label: 'Deny', send: 'n' } + ] + if (isAlwaysLabel(text)) { + options.splice(1, 0, { label: 'Allow always', send: 'a' }) + } + + return { title: 'Permission requested', detail, options } +} diff --git a/mobile/src/session/mobile-native-chat-question.test.ts b/mobile/src/session/mobile-native-chat-question.test.ts new file mode 100644 index 00000000000..94fbcf055a9 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-question.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from 'vitest' +import { + formatQuestionAnswer, + mobileChatQuestionKey, + parseAgentQuestion, + type MobileChatQuestion +} from './mobile-native-chat-question' + +describe('parseAgentQuestion', () => { + it('parses a numbered list with a question line', () => { + const text = ['Which database should I use?', '1. PostgreSQL', '2. MySQL', '3. SQLite'].join( + '\n' + ) + const q = parseAgentQuestion(text) + expect(q).not.toBeNull() + expect(q?.question).toBe('Which database should I use?') + expect(q?.options).toEqual(['PostgreSQL', 'MySQL', 'SQLite']) + expect(q?.optionTokens).toEqual(['1', '2', '3']) + expect(q?.multiSelect).toBe(false) + }) + + it('parses numbered lists using ) markers', () => { + const q = parseAgentQuestion('Pick:\n1) Yes\n2) No') + expect(q?.options).toEqual(['Yes', 'No']) + expect(q?.optionTokens).toEqual(['1', '2']) + }) + + it('parses a bulleted list', () => { + const text = ['How should we proceed?', '- Rebase', '- Merge', '* Squash'].join('\n') + const q = parseAgentQuestion(text) + expect(q?.options).toEqual(['Rebase', 'Merge', 'Squash']) + expect(q?.optionTokens).toEqual([null, null, null]) + expect(q?.question).toBe('How should we proceed?') + }) + + it('parses lettered options in brackets and parens', () => { + const q = parseAgentQuestion('Choose one:\n[a] Alpha\n[b] Beta') + expect(q?.options).toEqual(['Alpha', 'Beta']) + expect(q?.optionTokens).toEqual(['a', 'b']) + + const q2 = parseAgentQuestion('Choose one:\na) Alpha\nb) Beta') + expect(q2?.options).toEqual(['Alpha', 'Beta']) + expect(q2?.optionTokens).toEqual(['a', 'b']) + }) + + it('strips a leading pointer/highlight glyph from the selected row', () => { + const text = ['Select a target:', '❯ 1. main', ' 2. develop'].join('\n') + const q = parseAgentQuestion(text) + expect(q?.options).toEqual(['main', 'develop']) + expect(q?.optionTokens).toEqual(['1', '2']) + }) + + it('drops a trailing colon but keeps a question mark in the title', () => { + expect(parseAgentQuestion('Options:\n1. A\n2. B')?.question).toBe('Options') + expect(parseAgentQuestion('Ready?\n1. A\n2. B')?.question).toBe('Ready?') + }) + + it('returns null for ordinary prose with no option list', () => { + expect(parseAgentQuestion('I finished the refactor and ran the tests.')).toBeNull() + expect(parseAgentQuestion('')).toBeNull() + expect(parseAgentQuestion(' ')).toBeNull() + }) + + it('returns null for a single bare option with no introducing prompt', () => { + // A lone "- item" in prose must not be treated as a question. + expect(parseAgentQuestion('Here is what I did:\nsome work\n- one stray bullet')).toBeNull() + }) + + it('accepts a single option when introduced by a prompt line', () => { + const q = parseAgentQuestion('Apply this change?\n1. Yes, apply it') + expect(q).not.toBeNull() + expect(q?.options).toEqual(['Yes, apply it']) + }) + + it('uses a fallback title when no introducing line exists', () => { + const q = parseAgentQuestion('1. Alpha\n2. Beta') + expect(q?.question).toBe('Choose an option') + expect(q?.options).toEqual(['Alpha', 'Beta']) + }) + + it('detects multi-select hints', () => { + expect(parseAgentQuestion('Select all that apply:\n1. A\n2. B')?.multiSelect).toBe(true) + expect(parseAgentQuestion('Choose multiple:\n- A\n- B')?.multiSelect).toBe(true) + expect(parseAgentQuestion('Pick one:\n1. A\n2. B')?.multiSelect).toBe(false) + }) + + it('does not flag multi-select when only one option is present', () => { + const q = parseAgentQuestion('Select all that apply?\n1. Only one') + expect(q?.multiSelect).toBe(false) + }) + + it('finds the question line nearest the options, skipping blanks', () => { + const text = ['Some preamble.', '', 'Which branch?', '', '1. main', '2. dev'].join('\n') + const q = parseAgentQuestion(text) + expect(q?.question).toBe('Which branch?') + }) +}) + +describe('formatQuestionAnswer', () => { + const numbered: MobileChatQuestion = { + question: 'Pick', + options: ['Alpha', 'Beta', 'Gamma'], + multiSelect: false, + optionTokens: ['1', '2', '3'] + } + + it('echoes the leading token for single-select', () => { + expect(formatQuestionAnswer(numbered, ['Beta'])).toBe('2') + }) + + it('comma-joins tokens for multi-select', () => { + const multi: MobileChatQuestion = { ...numbered, multiSelect: true } + expect(formatQuestionAnswer(multi, ['Alpha', 'Gamma'])).toBe('1, 3') + }) + + it('sends the label text when the option had no token (bullet list)', () => { + const bullets: MobileChatQuestion = { + question: 'Pick', + options: ['Rebase', 'Merge'], + multiSelect: false, + optionTokens: [null, null] + } + expect(formatQuestionAnswer(bullets, ['Merge'])).toBe('Merge') + }) + + it('echoes letter tokens', () => { + const lettered: MobileChatQuestion = { + question: 'Pick', + options: ['Alpha', 'Beta'], + multiSelect: false, + optionTokens: ['a', 'b'] + } + expect(formatQuestionAnswer(lettered, ['Beta'])).toBe('b') + }) + + it('passes free-text / unknown entries through verbatim', () => { + expect(formatQuestionAnswer(numbered, ['something custom'])).toBe('something custom') + }) + + it('returns empty string when nothing is selected', () => { + expect(formatQuestionAnswer(numbered, [])).toBe('') + expect(formatQuestionAnswer(numbered, [' '])).toBe('') + }) +}) + +describe('mobileChatQuestionKey', () => { + it('changes when a replacement prompt changes any positional content', () => { + const first: MobileChatQuestion = { + question: 'Pick', + options: ['A', 'B'], + multiSelect: true, + optionTokens: ['1', '2'] + } + expect(mobileChatQuestionKey({ ...first, options: ['A', 'C'] })).not.toBe( + mobileChatQuestionKey(first) + ) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-question.ts b/mobile/src/session/mobile-native-chat-question.ts new file mode 100644 index 00000000000..8a1f06dfbf7 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-question.ts @@ -0,0 +1,154 @@ +// Heuristic detection of an agent's "pick an option" prompt from its status / +// assistant text. Agents (Claude et al.) render these as a TUI choice list; we +// have no structured signal, so we parse the text conservatively and only treat +// it as a question when a clear option list is present. + +export type MobileChatQuestion = { + question: string + options: string[] + multiSelect: boolean + /** Per-option leading marker ("1", "b", …) when the source line carried one, + * parallel to `options`. Null where the option was a plain bullet. Used to + * echo the exact choice the agent listed back to the terminal. */ + optionTokens: (string | null)[] +} + +export function mobileChatQuestionKey(question: MobileChatQuestion): string { + return JSON.stringify(question) +} + +type ParsedOption = { + label: string + token: string | null +} + +// A pointer/highlight glyph some TUIs prefix onto the *currently selected* row. +// Stripped first so "❯ 2. Foo" parses identically to "2. Foo". +const POINTER_PREFIX = /^(\s*)(?:❯|›|»)\s+/ + +// Ordered most-specific-first so a numbered/lettered marker wins over the +// bullet fallback. `token` is the capture index of the leading marker (0 = none), +// `label` the capture index of the choice text. +const OPTION_PATTERNS: { re: RegExp; token: number; label: number }[] = [ + // 1. Option 12) Option + { re: /^\s*(\d{1,2})[.)]\s+(\S.*?)\s*$/, token: 1, label: 2 }, + // [a] Option [1] Option + { re: /^\s*\[([0-9a-zA-Z])\]\s+(\S.*?)\s*$/, token: 1, label: 2 }, + // a) Option a. Option (single letter, to avoid eating prose like "e.g.") + { re: /^\s*([a-zA-Z])[.)]\s+(\S.*?)\s*$/, token: 1, label: 2 }, + // - Option * Option • Option > Option (no token) + { re: /^\s*(?:[-*•>])\s+(\S.*?)\s*$/, token: 0, label: 1 } +] + +function parseOptionLine(line: string): ParsedOption | null { + const stripped = line.replace(POINTER_PREFIX, '$1') + for (const { re, token, label } of OPTION_PATTERNS) { + const m = stripped.match(re) + if (!m) { + continue + } + const text = m[label].trim() + if (text.length === 0) { + continue + } + return { label: text, token: token > 0 ? m[token] : null } + } + return null +} + +const MULTI_SELECT_HINT = + /\b(select all|choose all|choose multiple|select multiple|pick multiple|all that apply|one or more|comma[- ]separated|multiple options)\b/i + +// Question-like introducing line: ends in ? or :. +const QUESTION_LINE = /[?:]\s*$/ + +// Drop a trailing ":" off a card title but keep a meaningful "?". +function cleanQuestionText(raw: string): string { + const trimmed = raw.trim() + return trimmed.endsWith(':') ? trimmed.slice(0, -1).trim() : trimmed +} + +/** + * Heuristically parse a question + its option list from agent text. Returns null + * when no clear option list is present (so ordinary prose is never treated as a + * question). Conservative on purpose: requires at least two option lines, or one + * option line introduced by a question-like prompt line. + */ +export function parseAgentQuestion(text: string): MobileChatQuestion | null { + if (typeof text !== 'string' || text.trim().length === 0) { + return null + } + + const lines = text.replace(/\r\n/g, '\n').split('\n') + const parsed: { index: number; option: ParsedOption }[] = [] + + lines.forEach((line, index) => { + const option = parseOptionLine(line) + if (option) { + parsed.push({ index, option }) + } + }) + + if (parsed.length === 0) { + return null + } + + const firstOptionIndex = parsed[0].index + const options = parsed.map((p) => p.option.label) + const optionTokens = parsed.map((p) => p.option.token) + + // Find the introducing question: nearest non-empty, non-option line above the + // first option. + let question = '' + let questionLooksLikePrompt = false + for (let i = firstOptionIndex - 1; i >= 0; i--) { + if (lines[i].trim().length === 0 || parseOptionLine(lines[i])) { + continue + } + question = lines[i] + questionLooksLikePrompt = QUESTION_LINE.test(lines[i]) + break + } + + // Conservative gate: a single bare option with no introducing prompt is more + // likely stray prose (a lone "- item") than a real choice list. + if (options.length < 2 && !questionLooksLikePrompt) { + return null + } + + const multiSelect = MULTI_SELECT_HINT.test(text) && options.length > 1 + + return { + question: question.length > 0 ? cleanQuestionText(question) : 'Choose an option', + options, + multiSelect, + optionTokens + } +} + +/** + * Build the text to send to the agent terminal for the selected option(s). + * Convention: echo the option's leading marker (number/letter) when the list had + * one; otherwise send the option label text (TUIs accept the literal choice). + * Multi-select answers are comma-joined; single-select is sent as-is. Unknown + * entries (free-text escape hatch) pass through verbatim. Returns '' when + * nothing is selected. + */ +export function formatQuestionAnswer(question: MobileChatQuestion, selected: string[]): string { + const labels = selected.map((s) => s.trim()).filter((s) => s.length > 0) + if (labels.length === 0) { + return '' + } + + const parts = labels.map((label) => { + const index = question.options.indexOf(label) + if (index === -1) { + // Free-text / unknown entry: pass the user's text straight through. + return label + } + const token = question.optionTokens[index] + return token != null && token.length > 0 ? token : label + }) + + return parts.join(question.multiSelect ? ', ' : ' ') +} diff --git a/mobile/src/session/mobile-native-chat-render-data.test.ts b/mobile/src/session/mobile-native-chat-render-data.test.ts new file mode 100644 index 00000000000..e933971220d --- /dev/null +++ b/mobile/src/session/mobile-native-chat-render-data.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { + buildMobileNativeChatData, + mobileNativeChatEmptyState +} from './mobile-native-chat-render-data' + +function assistant(id: string, text: string): NativeChatMessage { + return { + id, + role: 'assistant', + blocks: [{ type: 'text', text }], + timestamp: 0, + source: 'transcript' + } +} + +function user(id: string, text: string): NativeChatMessage { + return { id, role: 'user', blocks: [{ type: 'text', text }], timestamp: 0, source: 'transcript' } +} + +describe('mobileNativeChatEmptyState', () => { + it('invites a first message naming the agent, matching desktop copy', () => { + // waiting-session (live agent, no transcript) and ready (loaded, empty) both + // resolve to the shared "empty" copy with the agent label substituted. + const waiting = mobileNativeChatEmptyState('waiting-session', 'claude') + expect(waiting).toEqual({ + title: 'Start a chat with Claude', + subtitle: 'Ask Claude to inspect code, explain output, or make a change.' + }) + expect(mobileNativeChatEmptyState('ready', 'codex')?.title).toBe('Start a chat with Codex') + }) + + it('falls back to "the agent" when the agent is unknown', () => { + expect(mobileNativeChatEmptyState('waiting-session', null)?.title).toBe( + 'Start a chat with the agent' + ) + }) + + it('prefers the provided error message over the default subtitle', () => { + expect(mobileNativeChatEmptyState('error', 'claude', 'boom')?.subtitle).toBe('boom') + expect(mobileNativeChatEmptyState('error', 'claude')?.subtitle).toBe( + 'The transcript could not be read. Toggle back to the terminal to keep working.' + ) + }) + + it('returns null for states that show no empty copy', () => { + expect(mobileNativeChatEmptyState('loading', 'claude')).toBeNull() + expect(mobileNativeChatEmptyState('idle', 'claude')).toBeNull() + }) +}) + +describe('buildMobileNativeChatData', () => { + it('appends pending optimistic messages at the tail as user turns', () => { + const { data } = buildMobileNativeChatData({ + messages: [assistant('a1', 'hello')], + streamingText: undefined, + pending: [{ id: 'p1', text: 'queued' }] + }) + const last = data[data.length - 1] + expect(last.id).toBe('p1') + expect(last.role).toBe('user') + expect(last.blocks).toEqual([{ type: 'text', text: 'queued' }]) + }) + + it('adds a synthetic streaming bubble while the partial text leads the transcript', () => { + const { streaming, data } = buildMobileNativeChatData({ + messages: [user('u1', 'hi')], + streamingText: 'thinking out loud', + pending: [] + }) + expect(streaming).toBe('thinking out loud') + expect(data.some((m) => m.id === 'streaming')).toBe(true) + }) + + it('shows a short new streaming reply even after a longer previous turn', () => { + // The last folded turn is a long completed reply; a short new stream must not + // be suppressed just for being shorter than the prior turn. + const { streaming, data } = buildMobileNativeChatData({ + messages: [assistant('a1', 'This is a long completed previous answer that ran on a while')], + streamingText: 'Ok', + pending: [] + }) + expect(streaming).toBe('Ok') + expect(data.some((m) => m.id === 'streaming')).toBe(true) + }) + + it('drops the streaming bubble once the real assistant turn already contains it', () => { + const { streaming, data } = buildMobileNativeChatData({ + messages: [assistant('a1', 'done answer')], + streamingText: 'done', + pending: [] + }) + expect(streaming).toBeNull() + expect(data.some((m) => m.id === 'streaming')).toBe(false) + }) + + it('returns no streaming bubble for empty/whitespace streaming text', () => { + expect( + buildMobileNativeChatData({ messages: [], streamingText: ' ', pending: [] }).streaming + ).toBeNull() + expect(buildMobileNativeChatData({ messages: [], pending: [] }).streaming).toBeNull() + }) +}) diff --git a/mobile/src/session/mobile-native-chat-render-data.ts b/mobile/src/session/mobile-native-chat-render-data.ts new file mode 100644 index 00000000000..74ca01939ba --- /dev/null +++ b/mobile/src/session/mobile-native-chat-render-data.ts @@ -0,0 +1,115 @@ +import { formatAgentTypeLabel } from '../../../src/shared/agent-type-label' +import { + formatNativeChatEmptyStateCopy, + type NativeChatEmptyStateCopy +} from '../../../src/shared/native-chat-empty-state' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { foldToolMessages } from './mobile-native-chat-blocks' +import { stripNoiseMessages } from './mobile-native-chat-noise' +import type { MobileNativeChatStatus } from './use-mobile-native-chat-session' + +/** The centered empty-state copy for a chat with no messages, mirroring the + * desktop `NativeChatEmptyState` (shared copy + agent label) so the two surfaces + * stay in lockstep. Returns null when the list should stay bare (idle, or the + * loading spinner owns the view). */ +export function mobileNativeChatEmptyState( + status: MobileNativeChatStatus, + agent: string | null, + error?: string +): NativeChatEmptyStateCopy | null { + const agentLabel = agent ? formatAgentTypeLabel(agent) : 'the agent' + switch (status) { + // A live agent with no transcript yet — and a loaded-but-empty transcript — + // are both "start a chat"; invite the first message instead of implying the + // agent is still starting up. + case 'waiting-session': + case 'ready': + return formatNativeChatEmptyStateCopy('empty', agentLabel) + case 'error': { + const copy = formatNativeChatEmptyStateCopy('error', agentLabel) + return error ? { ...copy, subtitle: error } : copy + } + default: + return null + } +} + +/** Derive the list data from the raw transcript: fold tool turns into the + * assistant turn, optionally append a synthetic streaming bubble, then the + * route-owned optimistic "queued" messages at the tail. Returns the + * intermediate `folded`/`streaming` so the caller can memoize on them. */ +export function buildMobileNativeChatData({ + messages, + streamingText, + pending +}: { + messages: NativeChatMessage[] + streamingText?: string + pending: Array<{ id: string; text: string }> +}): { folded: NativeChatMessage[]; streaming: string | null; data: NativeChatMessage[] } { + const folded = foldMobileNativeChatMessages(messages) + return buildMobileNativeChatTransientData({ folded, streamingText, pending }) +} + +export function foldMobileNativeChatMessages(messages: NativeChatMessage[]): NativeChatMessage[] { + return foldToolMessages(stripNoiseMessages(messages)) +} + +export function buildMobileNativeChatTransientData({ + folded, + streamingText, + pending +}: { + folded: NativeChatMessage[] + streamingText?: string + pending: Array<{ id: string; text: string }> +}): { folded: NativeChatMessage[]; streaming: string | null; data: NativeChatMessage[] } { + // Only show the streaming bubble while its text leads the transcript — once the + // real assistant turn lands with the same text, drop the synthetic one. + const streaming = deriveStreaming(folded, streamingText) + const data: NativeChatMessage[] = [ + ...folded, + ...(streaming + ? [ + { + id: 'streaming', + role: 'assistant' as const, + blocks: [{ type: 'text' as const, text: streaming }], + timestamp: null, + source: 'hook' as const + } + ] + : []), + ...pending.map((p) => ({ + id: p.id, + role: 'user' as const, + blocks: [{ type: 'text' as const, text: p.text }], + timestamp: null, + source: 'transcript' as const + })) + ] + return { folded, streaming, data } +} + +function deriveStreaming(folded: NativeChatMessage[], streamingText?: string): string | null { + const text = streamingText?.trim() + if (!text) { + return null + } + const last = folded[folded.length - 1] + const lastText = + last?.role === 'assistant' + ? last.blocks + .filter((b) => b.type === 'text') + .map((b) => (b.type === 'text' ? b.text : '')) + .join('') + .trim() + : '' + // Hide the synthetic bubble only once the real turn has landed leading with the + // streamed text. A bare length compare would suppress a short new reply behind a + // longer previous turn; a completed prior turn won't start with the new prefix. + if (lastText.startsWith(text)) { + return null + } + return text +} diff --git a/mobile/src/session/mobile-native-chat-send.test.ts b/mobile/src/session/mobile-native-chat-send.test.ts new file mode 100644 index 00000000000..01f4d5289ba --- /dev/null +++ b/mobile/src/session/mobile-native-chat-send.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { sendMobileNativeChatMessage } from './mobile-native-chat-send' + +function clientWithResponse(response: unknown): RpcClient { + return { + sendRequest: vi.fn().mockResolvedValue(response) + } as unknown as RpcClient +} + +describe('sendMobileNativeChatMessage', () => { + it('returns true only when the terminal accepts the send', async () => { + const client = clientWithResponse({ + id: 'request', + ok: true, + result: { send: { accepted: true } }, + _meta: { runtimeId: 'runtime' } + }) + + await expect( + sendMobileNativeChatMessage({ + client, + terminal: 'term', + text: 'hello', + mobileClient: { id: 'device', type: 'mobile' } + }) + ).resolves.toBe(true) + expect(client.sendRequest).toHaveBeenCalledWith('terminal.send', { + terminal: 'term', + text: 'hello', + enter: true, + client: { id: 'device', type: 'mobile' } + }) + }) + + it('returns false when the terminal rejects the send', async () => { + const client = clientWithResponse({ + id: 'request', + ok: true, + result: { send: { accepted: false } }, + _meta: { runtimeId: 'runtime' } + }) + + await expect( + sendMobileNativeChatMessage({ client, terminal: 'term', text: 'hello' }) + ).resolves.toBe(false) + }) + + it('returns false when the RPC fails', async () => { + const client = { + sendRequest: vi.fn().mockRejectedValue(new Error('disconnected')) + } as unknown as RpcClient + + await expect( + sendMobileNativeChatMessage({ client, terminal: 'term', text: 'hello' }) + ).resolves.toBe(false) + }) + + it('sends a single non-submitting Escape for prompt cancellation', async () => { + const client = clientWithResponse({ + id: 'request', + ok: true, + result: { send: { accepted: true } }, + _meta: { runtimeId: 'runtime' } + }) + + await sendMobileNativeChatMessage({ + client, + terminal: 'term', + text: String.fromCharCode(27), + enter: false + }) + expect(client.sendRequest).toHaveBeenCalledWith('terminal.send', { + terminal: 'term', + text: String.fromCharCode(27), + enter: false + }) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-send.ts b/mobile/src/session/mobile-native-chat-send.ts new file mode 100644 index 00000000000..4cf1776843e --- /dev/null +++ b/mobile/src/session/mobile-native-chat-send.ts @@ -0,0 +1,27 @@ +import type { RpcClient } from '../transport/rpc-client' +import { isTerminalSendRpcAccepted } from '../terminal/terminal-send-rpc-response' + +type MobileTerminalClient = { + id: string + type: 'mobile' +} + +export async function sendMobileNativeChatMessage(args: { + client: RpcClient + terminal: string + text: string + enter?: boolean + mobileClient?: MobileTerminalClient +}): Promise { + try { + const response = await args.client.sendRequest('terminal.send', { + terminal: args.terminal, + text: args.text, + enter: args.enter ?? true, + ...(args.mobileClient ? { client: args.mobileClient } : {}) + }) + return isTerminalSendRpcAccepted(response) + } catch { + return false + } +} diff --git a/mobile/src/session/mobile-native-chat-stream-frame.test.ts b/mobile/src/session/mobile-native-chat-stream-frame.test.ts new file mode 100644 index 00000000000..8d7d1e7e07a --- /dev/null +++ b/mobile/src/session/mobile-native-chat-stream-frame.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { createNativeChatMerger, replaceList } from './mobile-native-chat-merge' +import { applyMobileNativeChatStreamFrame } from './mobile-native-chat-stream-frame' + +function message(id: string): NativeChatMessage { + return { + id, + role: 'assistant', + blocks: [{ type: 'text', text: id }], + timestamp: 0, + source: 'transcript' + } +} + +describe('applyMobileNativeChatStreamFrame', () => { + it('uses the first snapshot as the ordered base and carries pagination state', () => { + const merger = createNativeChatMerger() + const result = applyMobileNativeChatStreamFrame({ + merger, + frame: { + type: 'snapshot', + messages: [message('a'), message('b')], + hasMore: true, + beforeOffset: 123 + }, + limit: 40, + replaceSnapshot: true + }) + + expect(result).toEqual({ + kind: 'messages', + messages: [message('a'), message('b')], + hasMore: true, + beforeOffset: 123 + }) + }) + + it('merges reconnect snapshots and live appends into the bounded window', () => { + const merger = createNativeChatMerger() + replaceList(merger, [message('a'), message('b')]) + + const result = applyMobileNativeChatStreamFrame({ + merger, + frame: { type: 'snapshot', messages: [message('b'), message('c'), message('d')] }, + limit: 3, + replaceSnapshot: false + }) + + expect(result).toMatchObject({ + kind: 'messages', + messages: [message('b'), message('c'), message('d')], + cursorInvalidated: true + }) + }) + + it('keeps the pagination cursor when an append does not trim history', () => { + const merger = createNativeChatMerger() + replaceList(merger, [message('a')]) + + expect( + applyMobileNativeChatStreamFrame({ + merger, + frame: { type: 'appended', messages: [message('b')] }, + limit: 3, + replaceSnapshot: false + }) + ).toEqual({ kind: 'messages', messages: [message('a'), message('b')] }) + }) + + it('replaces stale history for an explicit transcript replacement frame', () => { + const merger = createNativeChatMerger() + replaceList(merger, [message('old')]) + + expect( + applyMobileNativeChatStreamFrame({ + merger, + frame: { type: 'replacement', messages: [message('new')], hasMore: false }, + limit: 40, + replaceSnapshot: false + }) + ).toEqual({ kind: 'messages', messages: [message('new')], hasMore: false }) + }) + + it('surfaces snapshot errors and ignores unrelated frames', () => { + const merger = createNativeChatMerger() + expect( + applyMobileNativeChatStreamFrame({ + merger, + frame: { type: 'snapshot', error: 'Transcript unavailable' }, + limit: 40, + replaceSnapshot: true + }) + ).toEqual({ kind: 'error', error: 'Transcript unavailable' }) + expect( + applyMobileNativeChatStreamFrame({ + merger, + frame: { type: 'error', message: 'Socket closed' }, + limit: 40, + replaceSnapshot: false + }) + ).toEqual({ kind: 'error', error: 'Socket closed' }) + expect( + applyMobileNativeChatStreamFrame({ + merger, + frame: { type: 'end' }, + limit: 40, + replaceSnapshot: true + }) + ).toEqual({ kind: 'ignored' }) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-stream-frame.ts b/mobile/src/session/mobile-native-chat-stream-frame.ts new file mode 100644 index 00000000000..690994f02f3 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-stream-frame.ts @@ -0,0 +1,63 @@ +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { applyAppend, replaceList, type NativeChatMerger } from './mobile-native-chat-merge' + +export type MobileNativeChatStreamFrame = { + type?: string + messages?: NativeChatMessage[] + hasMore?: boolean + beforeOffset?: number + error?: string + message?: string +} + +export type AppliedMobileNativeChatFrame = + | { kind: 'ignored' } + | { kind: 'error'; error: string } + | { + kind: 'messages' + messages: NativeChatMessage[] + hasMore?: boolean + beforeOffset?: number + cursorInvalidated?: boolean + } + +/** Applies runtime stream frames while preserving the initial-snapshot versus + * reconnect-replay distinction owned by the session hook. */ +export function applyMobileNativeChatStreamFrame(args: { + merger: NativeChatMerger + frame: MobileNativeChatStreamFrame + limit: number + replaceSnapshot: boolean +}): AppliedMobileNativeChatFrame { + const { merger, frame, limit, replaceSnapshot } = args + if (frame.type === 'error') { + return { kind: 'error', error: frame.message ?? frame.error ?? 'Transcript stream failed' } + } + if (frame.type !== 'snapshot' && frame.type !== 'replacement' && frame.type !== 'appended') { + return { kind: 'ignored' } + } + if (frame.error) { + return { kind: 'error', error: frame.error } + } + if (!Array.isArray(frame.messages)) { + return { kind: 'ignored' } + } + if (frame.type === 'replacement' || (frame.type === 'snapshot' && replaceSnapshot)) { + replaceList(merger, frame.messages) + return { + kind: 'messages', + messages: merger.list, + hasMore: frame.hasMore, + ...(frame.beforeOffset == null ? {} : { beforeOffset: frame.beforeOffset }) + } + } + const previousFirstId = merger.list[0]?.id + const messages = applyAppend(merger, frame.messages, limit) + return { + kind: 'messages', + messages, + // Why: once the bounded live window drops its oldest row, the snapshot's + // byte cursor no longer describes the oldest retained message. + ...(previousFirstId && messages[0]?.id !== previousFirstId ? { cursorInvalidated: true } : {}) + } +} diff --git a/mobile/src/session/mobile-native-chat-terminal-stream.test.ts b/mobile/src/session/mobile-native-chat-terminal-stream.test.ts new file mode 100644 index 00000000000..cc92c6b3aca --- /dev/null +++ b/mobile/src/session/mobile-native-chat-terminal-stream.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest' +import { + isTerminalCoveredByNativeChat, + mobileNativeChatTerminalCapabilities, + resolveMobileNativeChatTerminalStreamAction +} from './mobile-native-chat-terminal-stream' + +const base = { + showNativeChat: false, + activeHandle: 'pty-1', + activeTabType: 'terminal', + streamActive: true, + streamCovered: false, + webViewReady: true +} + +describe('mobile native-chat terminal stream lifecycle', () => { + it('pauses an active terminal stream while chat covers it', () => { + expect(resolveMobileNativeChatTerminalStreamAction({ ...base, showNativeChat: true })).toBe( + 'pause' + ) + expect( + resolveMobileNativeChatTerminalStreamAction({ + ...base, + showNativeChat: true, + streamActive: false + }) + ).toBe('pause') + expect(isTerminalCoveredByNativeChat(true, 'pty-1', 'pty-1')).toBe(true) + expect(mobileNativeChatTerminalCapabilities(true)).toEqual({ + terminalBinaryStream: 1, + mobileInputLeaseOnly: 1 + }) + expect(mobileNativeChatTerminalCapabilities(false)).toEqual({ terminalBinaryStream: 1 }) + }) + + it('records a cold-start cover before WebView readiness so return refreshes', () => { + expect( + resolveMobileNativeChatTerminalStreamAction({ + ...base, + showNativeChat: true, + streamActive: false, + webViewReady: false + }) + ).toBe('pause') + expect( + resolveMobileNativeChatTerminalStreamAction({ + ...base, + streamActive: true, + streamCovered: true + }) + ).toBe('resume') + }) + + it('resumes only the ready active terminal after chat closes', () => { + expect(resolveMobileNativeChatTerminalStreamAction({ ...base, streamActive: false })).toBe( + 'resume' + ) + expect( + resolveMobileNativeChatTerminalStreamAction({ + ...base, + streamActive: false, + webViewReady: false + }) + ).toBe('none') + expect(resolveMobileNativeChatTerminalStreamAction({ ...base, streamCovered: true })).toBe( + 'resume' + ) + }) + + it('does nothing for non-terminal tabs, missing handles, or settled states', () => { + expect(resolveMobileNativeChatTerminalStreamAction(base)).toBe('none') + expect( + resolveMobileNativeChatTerminalStreamAction({ + ...base, + showNativeChat: true, + streamActive: false, + streamCovered: true + }) + ).toBe('none') + expect(resolveMobileNativeChatTerminalStreamAction({ ...base, activeTabType: 'browser' })).toBe( + 'none' + ) + expect(resolveMobileNativeChatTerminalStreamAction({ ...base, activeHandle: null })).toBe( + 'none' + ) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-terminal-stream.ts b/mobile/src/session/mobile-native-chat-terminal-stream.ts new file mode 100644 index 00000000000..387fe9c3580 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-terminal-stream.ts @@ -0,0 +1,37 @@ +export type MobileNativeChatTerminalStreamAction = 'pause' | 'resume' | 'none' + +/** Decides whether the active mobile terminal stream should run while native chat + * covers its WebView. Resume is allowed only once the mounted WebView is ready. */ +export function resolveMobileNativeChatTerminalStreamAction(args: { + showNativeChat: boolean + activeHandle: string | null + activeTabType: string | null + streamActive: boolean + streamCovered: boolean + webViewReady: boolean +}): MobileNativeChatTerminalStreamAction { + if (!args.activeHandle || args.activeTabType !== 'terminal') { + return 'none' + } + if (args.showNativeChat) { + return !args.streamCovered ? 'pause' : 'none' + } + return (args.streamCovered || !args.streamActive) && args.webViewReady ? 'resume' : 'none' +} + +export function isTerminalCoveredByNativeChat( + showNativeChat: boolean, + activeHandle: string | null, + handle: string +): boolean { + return showNativeChat && activeHandle === handle +} + +export function mobileNativeChatTerminalCapabilities(covered: boolean): { + terminalBinaryStream: 1 + mobileInputLeaseOnly?: 1 +} { + return covered + ? { terminalBinaryStream: 1, mobileInputLeaseOnly: 1 } + : { terminalBinaryStream: 1 } +} diff --git a/mobile/src/session/mobile-native-chat-toggle-action.ts b/mobile/src/session/mobile-native-chat-toggle-action.ts new file mode 100644 index 00000000000..32ae700671a --- /dev/null +++ b/mobile/src/session/mobile-native-chat-toggle-action.ts @@ -0,0 +1,37 @@ +import { MessageSquare, SquareTerminal } from 'lucide-react-native' +import type { ActionSheetAction } from '../components/ActionSheetModal' +import { resolveMobileNativeChat, type MobileNativeChatTab } from './mobile-native-chat-eligibility' + +type ToggleTab = MobileNativeChatTab & { + id: string + terminal: string | null +} + +/** Builds the optional terminal/chat switch shown in a terminal's long-press menu. */ +export function getMobileNativeChatToggleActions(args: { + terminalHandle: string | null + tabs: readonly ToggleTab[] + chatTabIds: ReadonlySet + nativeChatTranscriptIsLocalReadable: boolean + onClose: () => void + onToggle: (tabId: string) => void +}): ActionSheetAction[] { + const { terminalHandle, tabs, chatTabIds, onClose, onToggle } = args + const tab = terminalHandle + ? tabs.find((candidate) => candidate.terminal === terminalHandle) + : null + if (!tab || !resolveMobileNativeChat(tab, args.nativeChatTranscriptIsLocalReadable)) { + return [] + } + const isChat = chatTabIds.has(tab.id) + return [ + { + label: isChat ? 'Switch to terminal view' : 'Switch to chat view', + icon: isChat ? SquareTerminal : MessageSquare, + onPress: () => { + onClose() + onToggle(tab.id) + } + } + ] +} diff --git a/mobile/src/session/mobile-native-chat-tool-summary.test.ts b/mobile/src/session/mobile-native-chat-tool-summary.test.ts new file mode 100644 index 00000000000..d08deec4b31 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-tool-summary.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from 'vitest' +import { briefToolArg, summarizeToolInput, toolFilePath } from './mobile-native-chat-tool-summary' + +describe('summarizeToolInput', () => { + it('passes short strings through, collapsing whitespace', () => { + expect(summarizeToolInput('ls -la\n/tmp')).toBe('ls -la /tmp') + }) + + it('serializes objects to compact JSON', () => { + expect(summarizeToolInput({ path: 'a.ts', limit: 5 })).toBe('{"path":"a.ts","limit":5}') + }) + + it('truncates long previews with an ellipsis', () => { + const long = 'x'.repeat(200) + const out = summarizeToolInput(long) + expect(out.length).toBe(80) + expect(out.endsWith('…')).toBe(true) + }) + + it('returns empty string for null/undefined', () => { + expect(summarizeToolInput(null)).toBe('') + expect(summarizeToolInput(undefined)).toBe('') + }) + + it('handles numbers and booleans', () => { + expect(summarizeToolInput(42)).toBe('42') + expect(summarizeToolInput(true)).toBe('true') + }) + + it('does not traverse an unbounded tool-input object for a short preview', () => { + const input: Record = Object.fromEntries( + Array.from({ length: 8 }, (_unused, index) => [`key${index}`, index]) + ) + const lateGetter = vi.fn(() => 'should not be read') + Object.defineProperty(input, 'late', { enumerable: true, get: lateGetter }) + + expect(summarizeToolInput(input)).not.toBe('') + expect(lateGetter).not.toHaveBeenCalled() + }) +}) + +describe('toolFilePath', () => { + it('extracts file_path / filePath / path / notebook_path', () => { + expect(toolFilePath({ file_path: 'src/a.ts' })).toBe('src/a.ts') + expect(toolFilePath({ filePath: 'src/b.ts' })).toBe('src/b.ts') + expect(toolFilePath({ path: 'src/c.ts' })).toBe('src/c.ts') + expect(toolFilePath({ notebook_path: 'n.ipynb' })).toBe('n.ipynb') + }) + it('returns null when there is no file target', () => { + expect(toolFilePath({ command: 'ls' })).toBeNull() + expect(toolFilePath('x')).toBeNull() + expect(toolFilePath(null)).toBeNull() + }) +}) + +describe('briefToolArg', () => { + it('takes the basename of a forward-slash path', () => { + expect(briefToolArg({ path: 'src/session/app.ts' })).toBe('app.ts') + }) + + it('takes the basename of a Windows backslash path from a raw transcript', () => { + expect(briefToolArg({ file_path: 'C:\\Users\\me\\project\\app.tsx' })).toBe('app.tsx') + }) +}) diff --git a/mobile/src/session/mobile-native-chat-tool-summary.ts b/mobile/src/session/mobile-native-chat-tool-summary.ts new file mode 100644 index 00000000000..bc3f7a2f89a --- /dev/null +++ b/mobile/src/session/mobile-native-chat-tool-summary.ts @@ -0,0 +1,7 @@ +export { + briefToolArg, + countToolCalls, + summarizeToolInput, + summarizeToolRun, + toolFilePath +} from '../../../src/shared/native-chat-tool-summary' diff --git a/mobile/src/session/mobile-native-chat-view-styles.ts b/mobile/src/session/mobile-native-chat-view-styles.ts new file mode 100644 index 00000000000..160477cfad8 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-view-styles.ts @@ -0,0 +1,109 @@ +import { StyleSheet } from 'react-native' +import { colors, spacing, typography } from '../theme/mobile-theme' + +export const styles = StyleSheet.create({ + root: { + flex: 1, + backgroundColor: colors.bgBase + }, + chromeRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + minHeight: 28, + paddingHorizontal: spacing.md + }, + chromeLeft: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + }, + stopButton: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs + }, + stopLabel: { + color: colors.statusRed, + fontSize: typography.metaSize, + fontWeight: '700' + }, + sendError: { + alignItems: 'center', + paddingHorizontal: spacing.md, + paddingBottom: spacing.xs + }, + sendErrorText: { + color: colors.statusRed, + fontSize: typography.metaSize, + fontWeight: '600' + }, + chromeToggle: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + paddingVertical: 4, + paddingHorizontal: spacing.xs + }, + chromeToggleLabel: { + color: colors.textMuted, + fontSize: typography.metaSize, + fontWeight: '600' + }, + pressed: { + opacity: 0.6 + }, + listWrap: { + flex: 1, + position: 'relative' + }, + listContent: { + paddingVertical: spacing.sm, + flexGrow: 1 + }, + center: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + padding: spacing.xl + }, + emptyTitle: { + color: colors.textSecondary, + fontSize: typography.bodySize, + fontWeight: '600', + textAlign: 'center', + marginBottom: spacing.xs + }, + emptySubtitle: { + color: colors.textMuted, + fontSize: typography.metaSize, + textAlign: 'center' + }, + fab: { + position: 'absolute', + right: spacing.md, + width: 38, + height: 38, + borderRadius: 19, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.bgRaised, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.borderSubtle + }, + fabBottom: { + bottom: spacing.md + }, + loadEarlier: { + alignItems: 'center', + justifyContent: 'center', + paddingVertical: spacing.md, + minHeight: 36 + }, + loadEarlierText: { + color: colors.textMuted, + fontSize: typography.metaSize, + fontWeight: '600' + } +}) diff --git a/mobile/src/session/mobile-terminal-action-sheet-actions.ts b/mobile/src/session/mobile-terminal-action-sheet-actions.ts new file mode 100644 index 00000000000..3b29a6dfaf7 --- /dev/null +++ b/mobile/src/session/mobile-terminal-action-sheet-actions.ts @@ -0,0 +1,69 @@ +import { Eraser, Monitor, Smartphone } from 'lucide-react-native' +import type { ActionSheetAction } from '../components/ActionSheetModal' +import type { MobileNativeChatTab } from './mobile-native-chat-eligibility' +import { getMobileNativeChatToggleActions } from './mobile-native-chat-toggle-action' + +type TerminalTab = MobileNativeChatTab & { id: string; terminal: string | null } + +/** Builds the terminal long-press menu without adding another action block to the + * already dense session route. Native chat stays first as the view switch. */ +export function getMobileTerminalActionSheetActions(args: { + target: Target | null + tabs: readonly TerminalTab[] + chatTabIds: ReadonlySet + nativeChatTranscriptIsLocalReadable: boolean + onDismiss: () => void + onToggleChat: (tabId: string) => void + isPhoneMode: (handle: string) => boolean + onToggleDisplayMode: (handle: string) => void + onRename: (target: Target) => void + onClear: (target: Target) => void + onClose: (target: Target) => void +}): ActionSheetAction[] { + const { target } = args + if (!target) { + return [] + } + const phoneMode = args.isPhoneMode(target.handle) + return [ + ...getMobileNativeChatToggleActions({ + terminalHandle: target.handle, + tabs: args.tabs, + chatTabIds: args.chatTabIds, + nativeChatTranscriptIsLocalReadable: args.nativeChatTranscriptIsLocalReadable, + onClose: args.onDismiss, + onToggle: args.onToggleChat + }), + { + label: phoneMode ? 'Switch to Desktop' : 'Switch to Phone', + icon: phoneMode ? Monitor : Smartphone, + onPress: () => { + args.onDismiss() + args.onToggleDisplayMode(target.handle) + } + }, + { + label: 'Rename', + onPress: () => { + args.onDismiss() + args.onRename(target) + } + }, + { + label: 'Clear Terminal', + icon: Eraser, + onPress: () => { + args.onDismiss() + args.onClear(target) + } + }, + { + label: 'Close', + destructive: true, + onPress: () => { + args.onDismiss() + args.onClose(target) + } + } + ] +} diff --git a/mobile/src/session/mobile-terminal-stream-subscribe.test.ts b/mobile/src/session/mobile-terminal-stream-subscribe.test.ts new file mode 100644 index 00000000000..8d3785efdcf --- /dev/null +++ b/mobile/src/session/mobile-terminal-stream-subscribe.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { subscribeMobileTerminalSafely } from './mobile-terminal-stream-subscribe' + +describe('subscribeMobileTerminalSafely', () => { + it('reports a synchronous subscribe failure and returns a safe no-op cleanup', () => { + const onSynchronousError = vi.fn() + const client = { + subscribe: vi.fn(() => { + throw new Error('socket closed') + }) + } as unknown as Pick + + const unsubscribe = subscribeMobileTerminalSafely( + client, + { terminal: 'terminal-1' }, + vi.fn(), + onSynchronousError + ) + + expect(onSynchronousError).toHaveBeenCalledOnce() + expect(unsubscribe).not.toThrow() + }) +}) diff --git a/mobile/src/session/mobile-terminal-stream-subscribe.ts b/mobile/src/session/mobile-terminal-stream-subscribe.ts new file mode 100644 index 00000000000..189b07a74f0 --- /dev/null +++ b/mobile/src/session/mobile-terminal-stream-subscribe.ts @@ -0,0 +1,17 @@ +import type { RpcClient } from '../transport/rpc-client' + +export function subscribeMobileTerminalSafely( + client: Pick, + params: unknown, + onData: Parameters[2], + onSynchronousError: () => void +): () => void { + try { + return client.subscribe('terminal.subscribe', params, onData) + } catch { + // Why: a transport mock or closing socket can reject before returning an + // unsubscribe handle; callers must still release their subscribing marker. + onSynchronousError() + return () => {} + } +} diff --git a/mobile/src/session/use-mobile-attachment-input-lease-gate.test.ts b/mobile/src/session/use-mobile-attachment-input-lease-gate.test.ts new file mode 100644 index 00000000000..a69d66accdd --- /dev/null +++ b/mobile/src/session/use-mobile-attachment-input-lease-gate.test.ts @@ -0,0 +1,134 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useMobileAttachmentInputLeaseGate } from './use-mobile-attachment-input-lease-gate' + +type Gate = (targetHandle: string) => Promise + +describe('useMobileAttachmentInputLeaseGate', () => { + let renderer: ReactTestRenderer | null = null + let errorSpy: ReturnType | null = null + + beforeEach(() => { + const original = console.error + errorSpy = vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...(args as Parameters)) + }) + vi.useFakeTimers() + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + vi.useRealTimers() + errorSpy?.mockRestore() + }) + + function renderGate(args: { + connState: { current: string } + activeHandle: { current: string | null } + tabType: { current: string | null } + leaseReady: { current: boolean } + showToast: (message: string, durationMs?: number) => void + }): { gate: () => Gate } { + let gate: Gate = () => Promise.resolve(false) + function Probe(): null { + gate = useMobileAttachmentInputLeaseGate({ + flushPendingLiveInputBeforeExternalSend: () => Promise.resolve(true), + connStateRef: args.connState, + activeHandleRef: args.activeHandle, + activeSessionTabTypeRef: args.tabType, + nativeChatInputLeaseReadyRef: args.leaseReady, + showToast: args.showToast + }) + return null + } + act(() => { + renderer = create(createElement(Probe)) + }) + return { gate: () => gate } + } + + function baseRefs(): { + connState: { current: string } + activeHandle: { current: string | null } + tabType: { current: string | null } + leaseReady: { current: boolean } + } { + return { + connState: { current: 'connected' }, + activeHandle: { current: 'terminal-1' }, + tabType: { current: 'terminal' }, + leaseReady: { current: true } + } + } + + it('passes immediately when the lease is ready and the target is active', async () => { + const refs = baseRefs() + const showToast = vi.fn() + const { gate } = renderGate({ ...refs, showToast }) + + await expect(gate()('terminal-1')).resolves.toBe(true) + expect(showToast).not.toHaveBeenCalled() + }) + + it('waits out a lease-not-ready window and then sends', async () => { + const refs = baseRefs() + refs.leaseReady.current = false + const showToast = vi.fn() + const { gate } = renderGate({ ...refs, showToast }) + + const result = gate()('terminal-1') + await vi.advanceTimersByTimeAsync(200) + refs.leaseReady.current = true + await vi.advanceTimersByTimeAsync(100) + await expect(result).resolves.toBe(true) + expect(showToast).not.toHaveBeenCalled() + }) + + it('surfaces a toast when the lease never recovers', async () => { + const refs = baseRefs() + refs.leaseReady.current = false + const showToast = vi.fn() + const { gate } = renderGate({ ...refs, showToast }) + + const result = gate()('terminal-1') + await vi.advanceTimersByTimeAsync(3200) + await expect(result).resolves.toBe(false) + expect(showToast).toHaveBeenCalledWith('Attach failed (reconnecting)', 1500) + }) + + it('drops silently when the target changes while waiting for the lease', async () => { + const refs = baseRefs() + refs.leaseReady.current = false + const showToast = vi.fn() + const { gate } = renderGate({ ...refs, showToast }) + + const result = gate()('terminal-1') + // Mid-wait the user switches tabs: the lease recovers, but for a different + // target — the attach must not proceed against the stale handle. + await vi.advanceTimersByTimeAsync(200) + refs.activeHandle.current = 'terminal-2' + refs.leaseReady.current = true + await vi.advanceTimersByTimeAsync(100) + await expect(result).resolves.toBe(false) + expect(showToast).not.toHaveBeenCalled() + }) + + it('drops silently when the connection is lost while waiting', async () => { + const refs = baseRefs() + refs.leaseReady.current = false + const showToast = vi.fn() + const { gate } = renderGate({ ...refs, showToast }) + + const result = gate()('terminal-1') + await vi.advanceTimersByTimeAsync(200) + refs.connState.current = 'reconnecting' + await vi.advanceTimersByTimeAsync(3200) + await expect(result).resolves.toBe(false) + expect(showToast).not.toHaveBeenCalled() + }) +}) diff --git a/mobile/src/session/use-mobile-attachment-input-lease-gate.ts b/mobile/src/session/use-mobile-attachment-input-lease-gate.ts new file mode 100644 index 00000000000..a0ae29b6c51 --- /dev/null +++ b/mobile/src/session/use-mobile-attachment-input-lease-gate.ts @@ -0,0 +1,75 @@ +import { useCallback } from 'react' + +type CurrentRef = { readonly current: T } + +type AttachmentInputLeaseGateArgs = { + readonly flushPendingLiveInputBeforeExternalSend: (handle: string) => Promise + readonly connStateRef: CurrentRef + readonly activeHandleRef: CurrentRef + readonly activeSessionTabTypeRef: CurrentRef + readonly nativeChatInputLeaseReadyRef: CurrentRef + readonly showToast: (message: string, durationMs?: number) => void +} + +// Poll cadence + ceiling for riding out a terminal resubscribe (WS reconnect or +// return-to-terminal) during which the input lease is briefly not ready. +const LEASE_READY_POLL_MS = 100 +const LEASE_READY_TIMEOUT_MS = 3000 + +/** Gates an image attachment's terminal.send on a ready input lease. Flushes any + * pending IME/live input, confirms the send still targets the connected terminal + * tab, then waits out a short lease-not-ready window so a finished upload isn't + * dropped as if the picker were cancelled. Returns false only when the lease + * never recovers — surfacing a toast, since the caller treats a bare false as a + * silent picker-cancel (no error path). */ +export function useMobileAttachmentInputLeaseGate({ + flushPendingLiveInputBeforeExternalSend, + connStateRef, + activeHandleRef, + activeSessionTabTypeRef, + nativeChatInputLeaseReadyRef, + showToast +}: AttachmentInputLeaseGateArgs): (targetHandle: string) => Promise { + return useCallback( + async (targetHandle: string): Promise => { + const flushedPendingInput = await flushPendingLiveInputBeforeExternalSend(targetHandle) + // Why: image picking/upload and IME flushing can outlive the original tab. + if ( + !flushedPendingInput || + connStateRef.current !== 'connected' || + targetHandle !== activeHandleRef.current || + activeSessionTabTypeRef.current !== 'terminal' + ) { + return false + } + const deadline = Date.now() + LEASE_READY_TIMEOUT_MS + while (!nativeChatInputLeaseReadyRef.current && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, LEASE_READY_POLL_MS)) + } + // Why: the wait can outlive the target too — re-check so a tab/host switch + // or disconnect mid-wait doesn't send into the wrong (or dead) terminal. + // A moved-away target drops silently like the pre-wait guard; only a lease + // that never recovered warrants the toast. + if ( + connStateRef.current !== 'connected' || + targetHandle !== activeHandleRef.current || + activeSessionTabTypeRef.current !== 'terminal' + ) { + return false + } + if (nativeChatInputLeaseReadyRef.current) { + return true + } + showToast('Attach failed (reconnecting)', 1500) + return false + }, + [ + activeHandleRef, + activeSessionTabTypeRef, + connStateRef, + flushPendingLiveInputBeforeExternalSend, + nativeChatInputLeaseReadyRef, + showToast + ] + ) +} diff --git a/mobile/src/session/use-mobile-native-chat-answer-send.test.ts b/mobile/src/session/use-mobile-native-chat-answer-send.test.ts new file mode 100644 index 00000000000..79a11b6d723 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-answer-send.test.ts @@ -0,0 +1,243 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentType } from '../../../src/shared/native-chat-types' +import type { RpcClient } from '../transport/rpc-client' +import { MOBILE_NATIVE_CHAT_QUESTION_STEP_MS } from './mobile-native-chat-answer-stepping' +import type { AskPrompt } from './mobile-native-chat-ask' +import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send' + +type AnswerSend = ReturnType + +function acceptedResponse() { + return { + id: 'send', + ok: true as const, + result: { send: { accepted: true } }, + _meta: { runtimeId: 'runtime' } + } +} + +const TABS_OR_SPACES: AskPrompt = { + questions: [ + { + question: 'Tabs or spaces?', + multiSelect: false, + options: [{ label: 'Tabs' }, { label: 'Spaces' }] + } + ] +} + +describe('useMobileNativeChatAnswerSend', () => { + let renderer: ReactTestRenderer | null = null + let answerSend: AnswerSend | null = null + let mountedClient: RpcClient | null = null + let mountedOnSendError: ((message: string) => void) | null = null + let mountedAgent: AgentType = 'claude' + + beforeEach(() => { + vi.useFakeTimers() + globalThis.IS_REACT_ACT_ENVIRONMENT = true + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + answerSend = null + mountedClient = null + mountedOnSendError = null + mountedAgent = 'claude' + vi.useRealTimers() + }) + + function Harness({ enabled }: { enabled: boolean }): null { + answerSend = useMobileNativeChatAnswerSend({ + client: mountedClient, + enabled, + handleRef: { current: 'terminal' }, + deviceTokenRef: { current: 'device' }, + agentRef: { current: mountedAgent }, + sessionId: 'session', + streamIdentity: 'host\0worktree\0tab\0session', + onSendError: mountedOnSendError! + }) + return null + } + + async function mount( + client: RpcClient, + onSendError: (message: string) => void, + agent: AgentType = 'claude' + ): Promise { + mountedClient = client + mountedOnSendError = onSendError + mountedAgent = agent + const original = console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + try { + await act(async () => { + renderer = create(createElement(Harness, { enabled: true })) + }) + } finally { + consoleSpy.mockRestore() + } + } + + async function setEnabled(enabled: boolean): Promise { + await act(async () => { + renderer?.update(createElement(Harness, { enabled })) + }) + } + + it('single-select: sends the picked option NUMBER (not the label), no trailing Enter', async () => { + const sendRequest = vi.fn().mockResolvedValue(acceptedResponse()) + await mount({ sendRequest } as unknown as RpcClient, vi.fn()) + + // Spaces is option 2 — the STA-1860 case where label text committed Tabs. + await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(true) + expect(sendRequest).toHaveBeenCalledTimes(1) + expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '2', enter: false }) + }) + + it('multi-select: toggles each option number, steps to Submit, confirms — paced apart', async () => { + const sendRequest = vi.fn().mockResolvedValue(acceptedResponse()) + await mount({ sendRequest } as unknown as RpcClient, vi.fn()) + + const prompt: AskPrompt = { + questions: [ + { + question: 'Pick fruits', + multiSelect: true, + options: [{ label: 'Apple' }, { label: 'Banana' }, { label: 'Cherry' }] + } + ] + } + let result: Promise | undefined + await act(async () => { + result = answerSend?.answerAsk(prompt, [{ indices: [0, 2] }]) + }) + expect(sendRequest).toHaveBeenCalledTimes(1) + expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '1', enter: false }) + + await act(async () => vi.advanceTimersByTimeAsync(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS)) + expect(sendRequest.mock.calls[1]?.[1]).toMatchObject({ text: '3', enter: false }) + await act(async () => vi.advanceTimersByTimeAsync(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS)) + expect(sendRequest.mock.calls[2]?.[1]).toMatchObject({ text: '\x1b[C', enter: false }) + await act(async () => vi.advanceTimersByTimeAsync(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS)) + await expect(result).resolves.toBe(true) + expect(sendRequest.mock.calls[3]?.[1]).toMatchObject({ text: '\r', enter: false }) + }) + + it('multi-question: option numbers auto-advance, one final submit Enter', async () => { + const sendRequest = vi.fn().mockResolvedValue(acceptedResponse()) + await mount({ sendRequest } as unknown as RpcClient, vi.fn()) + + const prompt: AskPrompt = { + questions: [ + { question: 'q1', multiSelect: false, options: [{ label: 'A' }, { label: 'B' }] }, + { question: 'q2', multiSelect: false, options: [{ label: 'C' }, { label: 'D' }] } + ] + } + let result: Promise | undefined + await act(async () => { + result = answerSend?.answerAsk(prompt, [{ indices: [1] }, { indices: [0] }]) + }) + await act(async () => vi.runAllTimersAsync()) + + await expect(result).resolves.toBe(true) + expect(sendRequest.mock.calls.map((call) => call[1])).toEqual([ + expect.objectContaining({ text: '2', enter: false }), + expect.objectContaining({ text: '1', enter: false }), + expect.objectContaining({ text: '\r', enter: false }) + ]) + }) + + it('free text: opens "Type something", types the sanitized answer, then Enter', async () => { + const sendRequest = vi.fn().mockResolvedValue(acceptedResponse()) + await mount({ sendRequest } as unknown as RpcClient, vi.fn()) + + let result: Promise | undefined + await act(async () => { + // A newline in raw keystrokes would submit early — must collapse to space. + result = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [], other: 'zeta\nspaces' }]) + }) + await act(async () => vi.runAllTimersAsync()) + + await expect(result).resolves.toBe(true) + expect(sendRequest.mock.calls.map((call) => call[1])).toEqual([ + expect.objectContaining({ text: '3', enter: false }), + expect.objectContaining({ text: 'zeta spaces', enter: false }), + expect.objectContaining({ text: '\r', enter: false }) + ]) + }) + + it('answers OpenClaude asks with Claude selector keystrokes', async () => { + const sendRequest = vi.fn().mockResolvedValue(acceptedResponse()) + await mount({ sendRequest } as unknown as RpcClient, vi.fn(), 'openclaude') + + await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(true) + expect(sendRequest).toHaveBeenCalledTimes(1) + expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '2', enter: false }) + }) + + it('submits a non-Claude answer as pasted label text with a single Enter', async () => { + const sendRequest = vi.fn().mockResolvedValue(acceptedResponse()) + await mount({ sendRequest } as unknown as RpcClient, vi.fn(), 'codex') + + await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(true) + // Codex's question tool commits the pasted answer: label text + one Enter. + expect(sendRequest).toHaveBeenCalledTimes(1) + expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: 'Spaces', enter: true }) + }) + + it('stops at the first rejected write and reports failure', async () => { + const onSendError = vi.fn() + const sendRequest = vi.fn().mockResolvedValue({ + id: 'send', + ok: true, + result: { send: { accepted: false } }, + _meta: { runtimeId: 'runtime' } + }) + await mount({ sendRequest } as unknown as RpcClient, onSendError) + + await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(false) + expect(sendRequest).toHaveBeenCalledTimes(1) + expect(onSendError).toHaveBeenCalledWith('Answer not sent') + }) + + it('rejects an empty selection without writing anything', async () => { + const sendRequest = vi.fn().mockResolvedValue(acceptedResponse()) + await mount({ sendRequest } as unknown as RpcClient, vi.fn()) + + await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [] }])).resolves.toBe(false) + expect(sendRequest).not.toHaveBeenCalled() + }) + + it('cancels delayed keystrokes when the acknowledged input lease is lost', async () => { + const sendRequest = vi.fn().mockResolvedValue(acceptedResponse()) + await mount({ sendRequest } as unknown as RpcClient, vi.fn()) + + const prompt: AskPrompt = { + questions: [ + { question: 'q1', multiSelect: false, options: [{ label: 'A' }, { label: 'B' }] }, + { question: 'q2', multiSelect: false, options: [{ label: 'C' }, { label: 'D' }] } + ] + } + let result: Promise | undefined + await act(async () => { + result = answerSend?.answerAsk(prompt, [{ indices: [1] }, { indices: [0] }]) + }) + expect(sendRequest).toHaveBeenCalledTimes(1) + + await setEnabled(false) + await act(async () => vi.runAllTimersAsync()) + + await expect(result).resolves.toBe(false) + expect(sendRequest).toHaveBeenCalledTimes(1) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-answer-send.ts b/mobile/src/session/use-mobile-native-chat-answer-send.ts new file mode 100644 index 00000000000..255f60de3a7 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-answer-send.ts @@ -0,0 +1,176 @@ +import { useCallback, useEffect, useRef, type MutableRefObject } from 'react' +import type { RpcClient } from '../transport/rpc-client' +import { MOBILE_NATIVE_CHAT_QUESTION_STEP_MS } from './mobile-native-chat-answer-stepping' +import { + buildAskAnswerKeys, + formatAskAnswer, + hasAskAnswer, + type AskAnswerSelection, + type AskPrompt +} from './mobile-native-chat-ask' +import { sendMobileNativeChatMessage } from './mobile-native-chat-send' +import { shouldStepNativeChatAskAnswer } from '../../../src/shared/native-chat-agent-support' + +/** Sends an AskUserQuestion answer to the active chat pane. Claude's selector is + * answered by option-number keystrokes; other agents get pasted label text. + * Extracted from the session route to keep that file under its line cap and to + * own the pending-timer lifecycle in one place. */ +export type MobileNativeChatAnswerSend = { + /** Answer the current question(s) from the card's per-question selections. */ + answerAsk: (prompt: AskPrompt, selections: AskAnswerSelection[]) => Promise + /** Drop any in-flight per-keystroke writes (call on Stop). */ + cancelPending: () => void +} + +// A free-text answer is written as raw keystrokes into Claude's "Type something" +// input (terminal.send has no paste framing), so an embedded newline would +// submit it early — collapse line breaks to spaces. +function sanitizeAskFreeText(text: string): string { + return text.replace(/[\r\n]+/g, ' ') +} + +/** + * Owns the ask-answer send sequence for the mobile native chat. Reads the live + * pane/agent through refs (the route already keeps them current) so the returned + * callbacks stay stable. Claude answers are delivered as `buildAskAnswerKeys` + * keystroke groups written one selector-step apart over the EXISTING + * `terminal.send` passthrough (raw text, no enter) — same contract the + * permission card already uses, so old runtimes replay them verbatim (no new + * RPC; keystrokes are built client-side). The scheduled wait chain is cancelled + * on a new answer, on `cancelPending` (Stop), and on unmount / session swap — so + * a detached chain can never write PTY bytes to a stale pane. + */ +export function useMobileNativeChatAnswerSend(args: { + client: RpcClient | null + enabled: boolean + handleRef: MutableRefObject + deviceTokenRef: MutableRefObject + agentRef: MutableRefObject + /** Changes on chat session swap; cancels pending writes when it does. */ + sessionId: string | null + streamIdentity: string + onSendError: (message: string) => void +}): MobileNativeChatAnswerSend { + const { + client, + enabled, + handleRef, + deviceTokenRef, + agentRef, + sessionId, + streamIdentity, + onSendError + } = args + const generationRef = useRef(0) + const activeRouteRef = useRef({ client, enabled, sessionId, streamIdentity }) + activeRouteRef.current = { client, enabled, sessionId, streamIdentity } + const delaysRef = useRef< + Set<{ timer: ReturnType; resolve: (completed: boolean) => void }> + >(new Set()) + + const cancelPending = useCallback(() => { + generationRef.current += 1 + for (const delay of delaysRef.current) { + clearTimeout(delay.timer) + delay.resolve(false) + } + delaysRef.current.clear() + }, []) + + // Cancel pending writes on unmount and whenever the chat session swaps. + useEffect(() => { + if (!enabled) { + cancelPending() + } + return cancelPending + }, [client, enabled, sessionId, streamIdentity, cancelPending]) + + const answerAsk = useCallback( + async (prompt: AskPrompt, selections: AskAnswerSelection[]): Promise => { + const handle = handleRef.current + if (!client || !handle || !enabled) { + onSendError('Answer not sent (disconnected)') + return false + } + if (!hasAskAnswer(prompt, selections)) { + return false + } + // A new answer supersedes any still-pending keystroke writes. + cancelPending() + const generation = generationRef.current + const sendTerminal = (body: string, enter: boolean): Promise => { + const activeRoute = activeRouteRef.current + if ( + !activeRoute.enabled || + activeRoute.client !== client || + activeRoute.sessionId !== sessionId || + activeRoute.streamIdentity !== streamIdentity || + handleRef.current !== handle + ) { + return Promise.resolve(false) + } + return sendMobileNativeChatMessage({ + client, + terminal: handle, + text: body, + enter, + ...(deviceTokenRef.current + ? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } } + : {}) + }) + } + const wait = (ms: number): Promise => + new Promise((resolve) => { + const delay = { + timer: setTimeout(() => { + delaysRef.current.delete(delay) + resolve(generationRef.current === generation) + }, ms), + resolve + } + delaysRef.current.add(delay) + }) + const fail = (): false => { + if (generationRef.current === generation) { + onSendError('Answer not sent') + } + return false + } + // Non-Claude question tools commit a pasted answer, so send the label text + // with one Enter. Claude's arrow-navigate selector ignores pasted labels + // (STA-1860): drive it by option-number keystrokes instead, one group per + // selector step so each renders before the next lands. + if (!shouldStepNativeChatAskAnswer(agentRef.current)) { + return (await sendTerminal(formatAskAnswer(prompt, selections), true)) || fail() + } + const groups = buildAskAnswerKeys(prompt, selections) + for (let index = 0; index < groups.length; index += 1) { + if (generationRef.current !== generation) { + return false + } + const group = groups[index]! + const body = 'raw' in group ? group.raw : sanitizeAskFreeText(group.text) + if (!(await sendTerminal(body, false))) { + return fail() + } + if (index < groups.length - 1 && !(await wait(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS))) { + return false + } + } + return groups.length > 0 + }, + [ + agentRef, + enabled, + cancelPending, + client, + deviceTokenRef, + handleRef, + onSendError, + sessionId, + streamIdentity + ] + ) + + return { answerAsk, cancelPending } +} diff --git a/mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts b/mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts new file mode 100644 index 00000000000..8c81f0255c2 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts @@ -0,0 +1,60 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AskPrompt } from './mobile-native-chat-ask' +import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dismiss' + +describe('useMobileNativeChatAskDismiss', () => { + let renderer: ReactTestRenderer | null = null + let state: ReturnType | null = null + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + state = null + }) + + function Harness({ prompt }: { prompt: AskPrompt }): null { + state = useMobileNativeChatAskDismiss(prompt) + return null + } + + const first: AskPrompt = { + questions: [ + { question: 'same first', multiSelect: false, options: [] }, + { question: 'old second', multiSelect: false, options: [] } + ] + } + const replacement: AskPrompt = { + questions: [ + { question: 'same first', multiSelect: false, options: [] }, + { question: 'new second', multiSelect: false, options: [] } + ] + } + + it('shows a structurally different replacement without an intervening null', async () => { + const original = console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + try { + await act(async () => { + renderer = create(createElement(Harness, { prompt: first })) + }) + } finally { + consoleSpy.mockRestore() + } + act(() => state?.dismissAsk()) + expect(state?.showAsk).toBe(false) + + await act(async () => renderer?.update(createElement(Harness, { prompt: replacement }))) + expect(state?.showAsk).toBe(true) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-ask-dismiss.ts b/mobile/src/session/use-mobile-native-chat-ask-dismiss.ts new file mode 100644 index 00000000000..8f7289f0753 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-ask-dismiss.ts @@ -0,0 +1,26 @@ +import { useEffect, useState } from 'react' +import type { AskPrompt } from './mobile-native-chat-ask' + +/** Track the answered-ask key so the lingering live status doesn't re-show the + * same card. The agent emits a post-tool event with the same prompt right after + * an answer, so the card is hidden until a genuinely different question arrives. */ +export function useMobileNativeChatAskDismiss(ask?: AskPrompt | null): { + askKey: string | null + showAsk: boolean + dismissAsk: () => void +} { + const askKey = ask ? JSON.stringify(ask.questions) : null + const [dismissedAskKey, setDismissedAskKey] = useState(null) + // Once the prompt clears (agent moved on), forget the dismissal so a later + // question — even an identical one — shows again instead of staying hidden. + const askPresent = ask != null + useEffect(() => { + if (!askPresent) { + setDismissedAskKey(null) + } + }, [askPresent]) + const showAsk = askPresent && askKey !== dismissedAskKey + const dismissAsk = (): void => setDismissedAskKey(askKey) + + return { askKey, showAsk, dismissAsk } +} diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts new file mode 100644 index 00000000000..cca9ae3f923 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -0,0 +1,314 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type Dispatch, + type MutableRefObject, + type SetStateAction +} from 'react' +import { loadNativeChatTabIds, saveNativeChatTabIds } from '../storage/preferences' +import type { RpcClient } from '../transport/rpc-client' +import { + parseAskFromStatus, + type AskAnswerSelection, + type AskPrompt +} from './mobile-native-chat-ask' +import { type MobileNativeChatTab, resolveMobileNativeChat } from './mobile-native-chat-eligibility' +import { detectAgentPermission } from './mobile-native-chat-permission' +import { parseAgentQuestion } from './mobile-native-chat-question' +import { openMobileNativeChatFile } from './mobile-native-chat-open-file' +import { useMobileNativeChatPermissionSend } from './mobile-native-chat-permission-send' +import { sendMobileNativeChatMessage } from './mobile-native-chat-send' +import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send' +import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts' +import { useMobileNativeChatFileSearch } from './use-mobile-native-chat-file-search' +import { useMobileNativeChatSession } from './use-mobile-native-chat-session' +import { useMobileNativeChatPrompts } from './use-mobile-native-chat-prompts' +import { useMobileNativeChatStop } from './use-mobile-native-chat-stop' +import { useThrottledLatestValue } from './use-throttled-latest-value' + +const NATIVE_CHAT_STREAM_THROTTLE_MS = 50 + +export type MobileNativeChatController = { + chatTabIds: Set + toggleTabChatView: (tabId: string) => void + showNativeChat: boolean + showNativeChatRef: MutableRefObject + /** Resolved agent for the active chat tab (names the empty-state copy). */ + nativeChatAgent: string | null + chatComposerText: string + setChatComposerText: Dispatch> + chatPending: Array<{ id: string; text: string }> + nativeChatSession: ReturnType + nativeChatAgentWorking: boolean + nativeChatStreamingText?: string + nativeChatPermission: ReturnType + nativeChatQuestion: ReturnType + nativeChatAsk: ReturnType + handleNativeChatOpenFile: (relativePath: string) => void + handleNativeChatAnswerAsk: ( + prompt: AskPrompt, + selections: AskAnswerSelection[] + ) => Promise + handleNativeChatCancelAsk: () => Promise + handleNativeChatRespondPermission: (text: string) => Promise + handleNativeChatStop: () => void + nativeChatFilePaths: string[] + loadNativeChatFiles: (query: string) => void + handleNativeChatSend: (text: string) => Promise +} + +/** Owns mobile native-chat state and teardown outside the already dense session + * route. The route remains responsible only for choosing and rendering the view. */ +export function useMobileNativeChatController(args: { + client: RpcClient | null + hostId: string + worktreeId: string + activeSessionTab: MobileNativeChatTab | null + activeSessionTabId: string | null + activeHandleRef: MutableRefObject + deviceTokenRef: MutableRefObject + nativeChatTranscriptIsLocalReadable: boolean + nativeChatInputLeaseReady: boolean + onSendError: (message: string) => void +}): MobileNativeChatController { + const { + client, + hostId, + worktreeId, + activeSessionTab, + activeSessionTabId, + activeHandleRef, + deviceTokenRef, + nativeChatTranscriptIsLocalReadable, + nativeChatInputLeaseReady, + onSendError + } = args + const [chatTabIds, setChatTabIds] = useState>(new Set()) + const chatTabIdsToggledRef = useRef(false) + + useEffect(() => { + let active = true + // Re-arm per host/worktree so the fresh load can seed the new selection. + chatTabIdsToggledRef.current = false + void loadNativeChatTabIds(hostId, worktreeId).then((ids) => { + // Why: a toggle before this load resolves is authoritative; the load must + // not revert the user's in-memory choice back to the persisted state. + if (active && !chatTabIdsToggledRef.current) { + // Why: route reuse must clear the prior worktree's selection even when + // the newly loaded worktree has no saved chat tabs. + setChatTabIds(new Set(ids)) + } + }) + return () => { + active = false + } + }, [hostId, worktreeId]) + + const toggleTabChatView = useCallback( + (tabId: string) => { + chatTabIdsToggledRef.current = true + setChatTabIds((previous) => { + const next = new Set(previous) + if (next.has(tabId)) { + next.delete(tabId) + } else { + next.add(tabId) + } + void saveNativeChatTabIds(hostId, worktreeId, [...next]) + return next + }) + }, + [hostId, worktreeId] + ) + + const activeChatResolution = + activeSessionTab && activeSessionTabId && chatTabIds.has(activeSessionTabId) + ? resolveMobileNativeChat(activeSessionTab, nativeChatTranscriptIsLocalReadable) + : null + const showNativeChat = activeChatResolution != null + const showNativeChatRef = useRef(showNativeChat) + showNativeChatRef.current = showNativeChat + const activeChatAgentRef = useRef(activeChatResolution?.agent ?? null) + activeChatAgentRef.current = activeChatResolution?.agent ?? null + + const activeChatSessionId = activeChatResolution?.sessionId ?? null + const streamIdentity = `${hostId}\0${worktreeId}\0${activeSessionTabId ?? ''}\0${activeChatSessionId ?? ''}\0${activeHandleRef.current ?? ''}` + + const nativeChatSession = useMobileNativeChatSession({ + client, + agent: activeChatResolution?.agent ?? null, + sessionId: activeChatSessionId, + transcriptPath: activeChatResolution?.transcriptPath ?? null + }) + const { + composerText: chatComposerText, + setComposerText: setChatComposerText, + pending: chatPending, + captureSendOrigin, + acceptSend + } = useMobileNativeChatDrafts({ + hostId, + worktreeId, + tabId: activeSessionTabId, + sessionId: activeChatSessionId, + messages: nativeChatSession.messages + }) + + const nativeChatStatus = activeChatResolution ? activeSessionTab?.agentStatus : null + const nativeChatAgentWorking = nativeChatStatus?.state === 'working' + // Throttle the streaming bubble: OpenCode emits a status frame per streamed + // part, and each one re-renders and re-parses the whole accumulated markdown. + const nativeChatStreamingText = useThrottledLatestValue( + nativeChatAgentWorking ? nativeChatStatus?.lastAssistantMessage : undefined, + NATIVE_CHAT_STREAM_THROTTLE_MS + ) + const { + permission: nativeChatPermission, + question: nativeChatQuestion, + ask: nativeChatAsk + } = useMobileNativeChatPrompts({ + enabled: activeChatResolution != null, + status: nativeChatStatus, + messages: nativeChatSession.messages + }) + + const handleNativeChatOpenFile = useCallback( + (pathText: string) => { + if (!client) { + return + } + void openMobileNativeChatFile({ + client, + worktreeId, + pathText, + terminal: activeHandleRef.current + }) + }, + [activeHandleRef, client, worktreeId] + ) + + const { answerAsk: handleNativeChatAnswerAsk, cancelPending: cancelNativeChatAnswer } = + useMobileNativeChatAnswerSend({ + client, + enabled: nativeChatInputLeaseReady, + handleRef: activeHandleRef, + deviceTokenRef, + agentRef: activeChatAgentRef, + sessionId: activeChatSessionId, + streamIdentity, + onSendError + }) + + const handleNativeChatCancelAsk = useCallback(async (): Promise => { + const handle = activeHandleRef.current + if (!client || !handle || !nativeChatInputLeaseReady) { + onSendError('Cancel not sent (disconnected)') + return false + } + cancelNativeChatAnswer() + const accepted = await sendMobileNativeChatMessage({ + client, + terminal: handle, + text: String.fromCharCode(27), + enter: false, + ...(deviceTokenRef.current + ? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } } + : {}) + }) + if (!accepted) { + onSendError('Cancel not sent') + } + return accepted + }, [ + activeHandleRef, + cancelNativeChatAnswer, + client, + deviceTokenRef, + nativeChatInputLeaseReady, + onSendError + ]) + + const handleNativeChatRespondPermission = useMobileNativeChatPermissionSend({ + client, + enabled: nativeChatInputLeaseReady, + handleRef: activeHandleRef, + deviceTokenRef, + onSendError + }) + + const handleNativeChatStop = useMobileNativeChatStop({ + client, + enabled: nativeChatInputLeaseReady, + handleRef: activeHandleRef, + deviceTokenRef, + streamIdentity, + cancelPending: cancelNativeChatAnswer, + onSendError + }) + + const { nativeChatFilePaths, loadNativeChatFiles } = useMobileNativeChatFileSearch({ + client, + worktreeId + }) + + const handleNativeChatSend = useCallback( + async (text: string): Promise => { + const handle = activeHandleRef.current + const origin = captureSendOrigin(text) + if (!client || !handle || !origin || !nativeChatInputLeaseReady) { + onSendError('Message not sent (disconnected)') + return false + } + const accepted = await sendMobileNativeChatMessage({ + client, + terminal: handle, + text, + ...(deviceTokenRef.current + ? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } } + : {}) + }) + if (!accepted) { + onSendError('Message not sent') + return false + } + acceptSend(origin, text) + return true + }, + [ + acceptSend, + activeHandleRef, + captureSendOrigin, + client, + deviceTokenRef, + nativeChatInputLeaseReady, + onSendError + ] + ) + + return { + chatTabIds, + toggleTabChatView, + showNativeChat, + showNativeChatRef, + nativeChatAgent: activeChatResolution?.agent ?? null, + chatComposerText, + setChatComposerText, + chatPending, + nativeChatSession, + nativeChatAgentWorking, + nativeChatStreamingText, + nativeChatPermission, + nativeChatQuestion, + nativeChatAsk, + handleNativeChatOpenFile, + handleNativeChatAnswerAsk, + handleNativeChatCancelAsk, + handleNativeChatRespondPermission, + handleNativeChatStop, + nativeChatFilePaths, + loadNativeChatFiles, + handleNativeChatSend + } +} diff --git a/mobile/src/session/use-mobile-native-chat-drafts.test.ts b/mobile/src/session/use-mobile-native-chat-drafts.test.ts new file mode 100644 index 00000000000..1505c9d1dc4 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-drafts.test.ts @@ -0,0 +1,192 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts' + +type DraftState = ReturnType + +function userTextMessage(id: string, text: string): NativeChatMessage { + return { + id, + role: 'user', + blocks: [{ type: 'text', text }], + timestamp: null, + source: 'transcript' + } +} + +function assistantTextMessage(id: string, text: string): NativeChatMessage { + return { + id, + role: 'assistant', + blocks: [{ type: 'text', text }], + timestamp: null, + source: 'transcript' + } +} + +describe('useMobileNativeChatDrafts', () => { + let renderer: ReactTestRenderer | null = null + let state: DraftState | null = null + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + state = null + }) + + function Harness({ + tabId, + sessionId = `session-${tabId}`, + messages = [] + }: { + tabId: string + sessionId?: string | null + messages?: NativeChatMessage[] + }): null { + state = useMobileNativeChatDrafts({ + hostId: 'host', + worktreeId: 'worktree', + tabId, + sessionId, + messages + }) + return null + } + + async function mount(tabId: string): Promise { + const original = console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + try { + await act(async () => { + renderer = create(createElement(Harness, { tabId })) + }) + } finally { + consoleSpy.mockRestore() + } + } + + async function switchTo(tabId: string): Promise { + await act(async () => renderer?.update(createElement(Harness, { tabId }))) + } + + it('keeps drafts and accepted pending messages on their originating tabs', async () => { + await mount('a') + act(() => state?.setComposerText('from a')) + const originA = state?.captureSendOrigin('from a') + expect(originA).not.toBeNull() + + await switchTo('b') + act(() => state?.setComposerText('from b')) + act(() => { + if (originA) { + state?.acceptSend(originA, 'from a') + } + }) + expect(state?.composerText).toBe('from b') + expect(state?.pending).toEqual([]) + + await switchTo('a') + expect(state?.composerText).toBe('') + expect(state?.pending.map((pending) => pending.text)).toEqual(['from a']) + }) + + it('clears one pending per landed message so duplicate sends are not all dropped', async () => { + await mount('a') + const origin = state?.captureSendOrigin('ping') + act(() => { + if (origin) { + state?.acceptSend(origin, 'ping') + state?.acceptSend(origin, 'ping') + } + }) + expect(state?.pending.map((pending) => pending.text)).toEqual(['ping', 'ping']) + + await act(async () => + renderer?.update( + createElement(Harness, { tabId: 'a', messages: [userTextMessage('m1', 'ping')] }) + ) + ) + expect(state?.pending.map((pending) => pending.text)).toEqual(['ping']) + }) + + it('does not reconcile a repeated send against an older identical turn', async () => { + await mount('a') + await act(async () => + renderer?.update( + createElement(Harness, { tabId: 'a', messages: [userTextMessage('old', 'ping')] }) + ) + ) + const origin = state?.captureSendOrigin('ping') + act(() => { + if (origin) { + state?.acceptSend(origin, 'ping') + } + }) + + await act(async () => + renderer?.update( + createElement(Harness, { + tabId: 'a', + messages: [userTextMessage('old', 'ping'), assistantTextMessage('other', 'working')] + }) + ) + ) + expect(state?.pending.map((pending) => pending.text)).toEqual(['ping']) + + await act(async () => + renderer?.update( + createElement(Harness, { + tabId: 'a', + messages: [ + userTextMessage('old', 'ping'), + assistantTextMessage('other', 'working'), + userTextMessage('new', 'ping') + ] + }) + ) + ) + expect(state?.pending).toEqual([]) + }) + + it('does not erase newer edits when an older send settles', async () => { + await mount('a') + act(() => state?.setComposerText('submitted')) + const origin = state?.captureSendOrigin('submitted') + act(() => state?.setComposerText('new edit')) + act(() => { + if (origin) { + state?.acceptSend(origin, 'submitted') + } + }) + + expect(state?.composerText).toBe('new edit') + }) + + it('accepts and clears the first send before a provider session id exists', async () => { + await mount('a') + await act(async () => renderer?.update(createElement(Harness, { tabId: 'a', sessionId: null }))) + act(() => state?.setComposerText('start the session')) + + const origin = state?.captureSendOrigin('start the session') + expect(origin).toMatchObject({ pendingKey: null }) + act(() => { + if (origin) { + state?.acceptSend(origin, 'start the session') + } + }) + + expect(state?.composerText).toBe('') + expect(state?.pending).toEqual([]) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-drafts.ts b/mobile/src/session/use-mobile-native-chat-drafts.ts new file mode 100644 index 00000000000..ddd730e227c --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-drafts.ts @@ -0,0 +1,165 @@ +import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' + +export type MobileNativeChatPendingMessage = { + id: string + text: string + expectedOccurrence: number +} +export type MobileNativeChatSendOrigin = { + draftKey: string + pendingKey: string | null + normalizedText: string + baselineOccurrences: number +} + +const NO_PENDING_MESSAGES: MobileNativeChatPendingMessage[] = [] + +function normalizedUserText(message: NativeChatMessage): string | null { + if (message.role !== 'user') { + return null + } + const text = message.blocks + .filter((block) => block.type === 'text') + .map((block) => (block.type === 'text' ? block.text : '')) + .join('') + .trim() + return text || null +} + +function countUserTextOccurrences(messages: readonly NativeChatMessage[], text: string): number { + let count = 0 + for (const message of messages) { + if (normalizedUserText(message) === text) { + count++ + } + } + return count +} + +export function useMobileNativeChatDrafts(args: { + hostId: string + worktreeId: string + tabId: string | null + sessionId: string | null + messages: readonly NativeChatMessage[] +}): { + composerText: string + setComposerText: Dispatch> + pending: MobileNativeChatPendingMessage[] + captureSendOrigin: (text: string) => MobileNativeChatSendOrigin | null + acceptSend: (origin: MobileNativeChatSendOrigin, text: string) => void +} { + const { hostId, worktreeId, tabId, sessionId, messages } = args + const draftKey = tabId ? `${hostId}\0${worktreeId}\0${tabId}` : null + const pendingKey = draftKey && sessionId ? `${draftKey}\0${sessionId}` : null + const [drafts, setDrafts] = useState>({}) + const [pendingBySession, setPendingBySession] = useState< + Record + >({}) + const pendingCounterRef = useRef(0) + const messagesRef = useRef(messages) + messagesRef.current = messages + + const setComposerText: Dispatch> = useCallback( + (value) => { + if (!draftKey) { + return + } + setDrafts((previous) => { + const current = previous[draftKey] ?? '' + const next = typeof value === 'function' ? value(current) : value + return next === current ? previous : { ...previous, [draftKey]: next } + }) + }, + [draftKey] + ) + + const captureSendOrigin = useCallback( + (text: string) => { + if (!draftKey) { + return null + } + const normalizedText = text.trim() + return { + draftKey, + pendingKey, + normalizedText, + baselineOccurrences: countUserTextOccurrences(messagesRef.current, normalizedText) + } + }, + [draftKey, pendingKey] + ) + + const acceptSend = useCallback((origin: MobileNativeChatSendOrigin, text: string) => { + // Why: an RPC may settle after a tab switch; mutate only the tab that + // originated the send, without erasing edits typed after it began. + setDrafts((previous) => + (previous[origin.draftKey] ?? '').trim() === text.trim() + ? { ...previous, [origin.draftKey]: '' } + : previous + ) + // Why: the first prompt can be sent before the provider reports a session + // id; clear its draft, but wait for an id before keying an optimistic echo. + if (!origin.pendingKey) { + return + } + const pendingKey = origin.pendingKey + pendingCounterRef.current += 1 + setPendingBySession((previous) => { + const current = previous[pendingKey] ?? NO_PENDING_MESSAGES + const earlierOutstanding = current.filter( + (pending) => + pending.text.trim() === origin.normalizedText && + pending.expectedOccurrence > origin.baselineOccurrences + ).length + const pending = { + id: `pending-${pendingCounterRef.current}`, + text, + expectedOccurrence: origin.baselineOccurrences + earlierOutstanding + 1 + } + return { ...previous, [pendingKey]: [...current, pending] } + }) + }, []) + + const pending = pendingKey + ? (pendingBySession[pendingKey] ?? NO_PENDING_MESSAGES) + : NO_PENDING_MESSAGES + useEffect(() => { + if (!pendingKey || pending.length === 0) { + return + } + setPendingBySession((previous) => { + const current = previous[pendingKey] ?? [] + const landedCounts = new Map() + for (const message of messages) { + const text = normalizedUserText(message) + if (text) { + landedCounts.set(text, (landedCounts.get(text) ?? 0) + 1) + } + } + // Why: compare against the count captured before send; historical equal + // turns cannot clear a new echo, while duplicates land one occurrence each. + const next = current.filter( + (item) => (landedCounts.get(item.text.trim()) ?? 0) < item.expectedOccurrence + ) + if (next.length === current.length) { + return previous + } + if (next.length > 0) { + return { ...previous, [pendingKey]: next } + } + const remaining = { ...previous } + delete remaining[pendingKey] + return remaining + }) + }, [messages, pending, pendingKey]) + + return { + composerText: draftKey ? (drafts[draftKey] ?? '') : '', + setComposerText, + pending, + captureSendOrigin, + acceptSend + } +} diff --git a/mobile/src/session/use-mobile-native-chat-file-search.test.ts b/mobile/src/session/use-mobile-native-chat-file-search.test.ts new file mode 100644 index 00000000000..e0500019844 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-file-search.test.ts @@ -0,0 +1,165 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { useMobileNativeChatFileSearch } from './use-mobile-native-chat-file-search' + +type SearchState = ReturnType + +function rpcSuccess(files: string[]): Awaited> { + return { + id: 'files', + ok: true, + result: { files: files.map((relativePath) => ({ relativePath })) }, + _meta: { runtimeId: 'runtime-1' } + } +} + +function suppressRendererWarning(): () => void { + const original = console.error + const spy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + return () => spy.mockRestore() +} + +describe('useMobileNativeChatFileSearch', () => { + let renderer: ReactTestRenderer | null = null + let state: SearchState | null = null + + async function mount(client: RpcClient): Promise { + function Harness(): null { + state = useMobileNativeChatFileSearch({ client, worktreeId: 'wt-1' }) + return null + } + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create(createElement(Harness)) + }) + } finally { + restore() + } + } + + beforeEach(() => { + vi.useFakeTimers() + globalThis.IS_REACT_ACT_ENVIRONMENT = true + state = null + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + vi.useRealTimers() + }) + + it('coalesces rapid queries and retains only the bounded host result', async () => { + const sendRequest = vi.fn().mockResolvedValue(rpcSuccess(['src/app.ts', 'src/app.test.ts'])) + await mount({ sendRequest } as unknown as RpcClient) + + act(() => { + state?.loadNativeChatFiles('a') + state?.loadNativeChatFiles('app') + }) + await act(async () => vi.advanceTimersByTimeAsync(119)) + expect(sendRequest).not.toHaveBeenCalled() + + await act(async () => vi.advanceTimersByTimeAsync(1)) + expect(sendRequest).toHaveBeenCalledTimes(1) + expect(sendRequest).toHaveBeenCalledWith('files.searchPaths', { + worktree: 'id:wt-1', + query: 'app', + limit: 16 + }) + expect(state?.nativeChatFilePaths).toEqual(['src/app.ts', 'src/app.test.ts']) + }) + + it('loads the legacy inventory once when an older host lacks searchPaths', async () => { + const sendRequest = vi.fn(async (method: string) => { + if (method === 'files.searchPaths') { + return { + id: 'missing', + ok: false as const, + error: { code: 'method_not_found', message: 'Unknown method' }, + _meta: { runtimeId: 'runtime-1' } + } + } + return rpcSuccess(['src/apple.ts', 'docs/readme.md']) + }) + await mount({ sendRequest } as unknown as RpcClient) + + act(() => state?.loadNativeChatFiles('apple')) + await act(async () => vi.advanceTimersByTimeAsync(120)) + expect(state?.nativeChatFilePaths).toEqual(['src/apple.ts']) + + act(() => state?.loadNativeChatFiles('readme')) + await act(async () => vi.advanceTimersByTimeAsync(120)) + expect(state?.nativeChatFilePaths).toEqual(['docs/readme.md']) + expect(sendRequest.mock.calls.map(([method]) => method)).toEqual([ + 'files.searchPaths', + 'files.list' + ]) + }) + + it('cancels an in-flight query on a cache hit so a stale result cannot clobber it', async () => { + const sendRequest = vi.fn(async (_method: string, params: { query: string }) => + rpcSuccess(params.query === 'app' ? ['src/app.ts'] : ['src/beta.ts']) + ) + await mount({ sendRequest } as unknown as RpcClient) + + // Populate the cache for 'app'. + act(() => state?.loadNativeChatFiles('app')) + await act(async () => vi.advanceTimersByTimeAsync(120)) + expect(state?.nativeChatFilePaths).toEqual(['src/app.ts']) + + // Schedule 'beta' (debounced, unresolved), then hit the cache for 'app'. + act(() => { + state?.loadNativeChatFiles('beta') + state?.loadNativeChatFiles('app') + }) + expect(state?.nativeChatFilePaths).toEqual(['src/app.ts']) + + // The cancelled 'beta' request must never fire and overwrite the cached result. + await act(async () => vi.advanceTimersByTimeAsync(120)) + expect(state?.nativeChatFilePaths).toEqual(['src/app.ts']) + expect( + sendRequest.mock.calls.filter(([, params]) => (params as { query: string }).query === 'beta') + ).toHaveLength(0) + }) + + it('coalesces overlapping legacy inventory requests on a slow host', async () => { + let resolveList: (value: Awaited>) => void = () => {} + const listResponse = new Promise>>((resolve) => { + resolveList = resolve + }) + const sendRequest = vi.fn((method: string) => { + if (method === 'files.searchPaths') { + return Promise.resolve({ + id: 'missing', + ok: false as const, + error: { code: 'method_not_found', message: 'Unknown method' }, + _meta: { runtimeId: 'runtime-1' } + }) + } + return listResponse + }) + await mount({ sendRequest } as unknown as RpcClient) + + act(() => state?.loadNativeChatFiles('apple')) + await act(async () => vi.advanceTimersByTimeAsync(120)) + act(() => state?.loadNativeChatFiles('readme')) + await act(async () => vi.advanceTimersByTimeAsync(120)) + expect(sendRequest.mock.calls.filter(([method]) => method === 'files.list')).toHaveLength(1) + + await act(async () => { + resolveList(rpcSuccess(['src/apple.ts', 'docs/readme.md'])) + await Promise.resolve() + await Promise.resolve() + }) + expect(state?.nativeChatFilePaths).toEqual(['docs/readme.md']) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-file-search.ts b/mobile/src/session/use-mobile-native-chat-file-search.ts new file mode 100644 index 00000000000..53df98e3ee0 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-file-search.ts @@ -0,0 +1,146 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import type { RpcClient } from '../transport/rpc-client' +import { rankSuggestions } from './mobile-native-chat-autocomplete' + +const FILE_SEARCH_DEBOUNCE_MS = 120 +const FILE_SEARCH_RESULT_LIMIT = 16 +const FILE_SEARCH_QUERY_CACHE_LIMIT = 20 + +function extractPaths(result: unknown): string[] { + const files = (result as { files?: Array<{ relativePath?: string }> }).files ?? [] + return files + .map((file) => file.relativePath ?? '') + .filter((path): path is string => path.length > 0) +} + +/** Debounces current-host path searches, bounds the mobile result/cache, and + * falls back to the legacy one-time full list when paired to an older host. */ +export function useMobileNativeChatFileSearch(args: { + client: RpcClient | null + worktreeId: string +}): { nativeChatFilePaths: string[]; loadNativeChatFiles: (query: string) => void } { + const { client, worktreeId } = args + const [nativeChatFilePaths, setNativeChatFilePaths] = useState([]) + const timerRef = useRef | null>(null) + const sequenceRef = useRef(0) + const generationRef = useRef(0) + const queryCacheRef = useRef(new Map()) + const legacyPathsRef = useRef(null) + const legacyLoadRef = useRef | null>(null) + const searchSupportedRef = useRef(null) + + useEffect(() => { + sequenceRef.current++ + generationRef.current++ + queryCacheRef.current.clear() + legacyPathsRef.current = null + legacyLoadRef.current = null + searchSupportedRef.current = null + setNativeChatFilePaths([]) + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current) + timerRef.current = null + } + } + }, [client, worktreeId]) + + const loadNativeChatFiles = useCallback( + (query: string) => { + if (!client) { + return + } + const normalizedQuery = query.trim().toLowerCase().slice(0, 256) + const cached = queryCacheRef.current.get(normalizedQuery) + if (cached) { + // Why: cancel and stale-out any in-flight debounced query so an older + // request cannot later clobber this displayed cached result. + if (timerRef.current) { + clearTimeout(timerRef.current) + timerRef.current = null + } + sequenceRef.current++ + setNativeChatFilePaths(cached) + return + } + if (timerRef.current) { + clearTimeout(timerRef.current) + } + const sequence = ++sequenceRef.current + const generation = generationRef.current + setNativeChatFilePaths([]) + timerRef.current = setTimeout(() => { + timerRef.current = null + const applyPaths = (paths: string[]): void => { + if (sequenceRef.current !== sequence) { + return + } + queryCacheRef.current.set(normalizedQuery, paths) + while (queryCacheRef.current.size > FILE_SEARCH_QUERY_CACHE_LIMIT) { + const oldest = queryCacheRef.current.keys().next().value as string | undefined + if (!oldest) { + break + } + queryCacheRef.current.delete(oldest) + } + setNativeChatFilePaths(paths) + } + const loadLegacyPaths = async (): Promise => { + if (!legacyPathsRef.current) { + if (!legacyLoadRef.current) { + const request = client + .sendRequest('files.list', { worktree: `id:${worktreeId}` }) + .then((response) => { + if (!response.ok || generationRef.current !== generation) { + return null + } + const paths = extractPaths(response.result) + legacyPathsRef.current = paths + return paths + }) + .finally(() => { + if (legacyLoadRef.current === request && !legacyPathsRef.current) { + legacyLoadRef.current = null + } + }) + // Why: older hosts expose only the full inventory RPC; queries that + // overlap its slow local/SSH read must share one request. + legacyLoadRef.current = request + } + const paths = await legacyLoadRef.current + if (!paths) { + return + } + } + const legacyPaths = legacyPathsRef.current + if (legacyPaths) { + applyPaths(rankSuggestions(legacyPaths, normalizedQuery, FILE_SEARCH_RESULT_LIMIT)) + } + } + void (async () => { + if (searchSupportedRef.current === false) { + await loadLegacyPaths() + return + } + const response = await client.sendRequest('files.searchPaths', { + worktree: `id:${worktreeId}`, + query: normalizedQuery, + limit: FILE_SEARCH_RESULT_LIMIT + }) + if (response.ok) { + searchSupportedRef.current = true + applyPaths(extractPaths(response.result)) + return + } + if (response.error.code === 'method_not_found') { + searchSupportedRef.current = false + await loadLegacyPaths() + } + })().catch(() => {}) + }, FILE_SEARCH_DEBOUNCE_MS) + }, + [client, worktreeId] + ) + + return { nativeChatFilePaths, loadNativeChatFiles } +} diff --git a/mobile/src/session/use-mobile-native-chat-input-lease.test.ts b/mobile/src/session/use-mobile-native-chat-input-lease.test.ts new file mode 100644 index 00000000000..bb90327c5b9 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-input-lease.test.ts @@ -0,0 +1,57 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useMobileNativeChatInputLease } from './use-mobile-native-chat-input-lease' + +type Lease = ReturnType + +describe('useMobileNativeChatInputLease', () => { + let renderer: ReactTestRenderer | null = null + let lease: Lease | null = null + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + lease = null + }) + + function Harness({ connected }: { connected: boolean }): null { + lease = useMobileNativeChatInputLease({ activeHandle: 'terminal', connected }) + return null + } + + it('unlocks only after acknowledgement and clears on disconnect', async () => { + const original = console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + try { + await act(async () => { + renderer = create(createElement(Harness, { connected: true })) + }) + expect(lease?.ready).toBe(false) + expect(lease?.lockReason).toBe('waiting') + act(() => lease?.markReady('terminal')) + expect(lease?.ready).toBe(true) + expect(lease?.lockReason).toBeNull() + + act(() => lease?.clear()) + expect(lease?.ready).toBe(false) + act(() => lease?.markReady('terminal')) + expect(lease?.ready).toBe(true) + + await act(async () => renderer?.update(createElement(Harness, { connected: false }))) + expect(lease?.ready).toBe(false) + expect(lease?.lockReason).toBe('disconnected') + } finally { + consoleSpy.mockRestore() + } + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-input-lease.ts b/mobile/src/session/use-mobile-native-chat-input-lease.ts new file mode 100644 index 00000000000..02dfeff84a8 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-input-lease.ts @@ -0,0 +1,53 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import type { MobileNativeChatInputLockReason } from './MobileNativeChatView' + +export function useMobileNativeChatInputLease(args: { + activeHandle: string | null + connected: boolean +}): { + ready: boolean + readyRef: { readonly current: boolean } + lockReason: MobileNativeChatInputLockReason | null + markReady: (handle: string) => void + clear: (handle?: string) => void +} { + const [readyHandles, setReadyHandles] = useState>(new Set()) + const ready = args.activeHandle != null && readyHandles.has(args.activeHandle) + // Why: absence of an acknowledgement proves only that setup is still pending; + // the protocol does not report evidence that another client owns the floor. + const lockReason: MobileNativeChatInputLockReason | null = !args.connected + ? 'disconnected' + : ready + ? null + : 'waiting' + const readyRef = useRef(ready) + readyRef.current = ready + useEffect(() => { + if (!args.connected) { + setReadyHandles(new Set()) + } + }, [args.connected]) + const markReady = useCallback((handle: string) => { + setReadyHandles((current) => new Set(current).add(handle)) + }, []) + const clear = useCallback((handle?: string) => { + setReadyHandles((current) => { + if (handle === undefined) { + return new Set() + } + if (!current.has(handle)) { + return current + } + const next = new Set(current) + next.delete(handle) + return next + }) + }, []) + return { + ready, + readyRef, + lockReason, + markReady, + clear + } +} diff --git a/mobile/src/session/use-mobile-native-chat-pinch-gesture.ts b/mobile/src/session/use-mobile-native-chat-pinch-gesture.ts new file mode 100644 index 00000000000..3b3cdb2e8a7 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-pinch-gesture.ts @@ -0,0 +1,39 @@ +import { useMemo, useRef, useState } from 'react' +import { Gesture } from 'react-native-gesture-handler' +import type { ComposedGesture } from 'react-native-gesture-handler' +import { clampFontScale } from './mobile-native-chat-message-text' + +/** Pinch-to-zoom chat font. `fontScale` is the committed size; `pinchBase` + * anchors the live gesture so successive pinches compound rather than reset. */ +export function useMobileNativeChatPinchGesture(): { + fontScale: number + pinchGesture: ComposedGesture +} { + const [fontScale, setFontScale] = useState(1) + const fontScaleRef = useRef(1) + fontScaleRef.current = fontScale + const pinchBase = useRef(1) + // Why: run the gesture callbacks on the JS thread (not a reanimated worklet) so + // they can touch React refs/state and clampFontScale directly — accessing those + // from the UI-thread worklet crashes the app. + // Compose the pinch with the list's native scroll as Simultaneous so a + // two-finger pinch is recognized even while the scroll view is active — + // otherwise the scroll grabs the gesture first and the pinch never fires. + const pinchGesture = useMemo( + () => + Gesture.Simultaneous( + Gesture.Native(), + Gesture.Pinch() + .runOnJS(true) + .onStart(() => { + pinchBase.current = fontScaleRef.current + }) + .onUpdate((e) => { + setFontScale(clampFontScale(pinchBase.current * e.scale)) + }) + ), + [] + ) + + return { fontScale, pinchGesture } +} diff --git a/mobile/src/session/use-mobile-native-chat-prompts.ts b/mobile/src/session/use-mobile-native-chat-prompts.ts new file mode 100644 index 00000000000..11a17696bfc --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-prompts.ts @@ -0,0 +1,47 @@ +import { useMemo } from 'react' +import type { AgentStatusEntry } from '../../../src/shared/agent-status-types' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { extractPendingAsk, parseAskFromStatus } from './mobile-native-chat-ask' +import { detectAgentPermission, parseApprovalFromStatus } from './mobile-native-chat-permission' +import { parseAgentQuestion } from './mobile-native-chat-question' + +export type MobileNativeChatPrompts = { + permission: ReturnType + question: ReturnType + ask: ReturnType +} + +/** Derives the prompt cards shown above the composer. */ +export function useMobileNativeChatPrompts(args: { + enabled: boolean + status: AgentStatusEntry | null | undefined + messages: readonly NativeChatMessage[] +}): MobileNativeChatPrompts { + const { enabled, status, messages } = args + const blocked = status?.state === 'waiting' || status?.state === 'blocked' + const permission = + (blocked && status + ? detectAgentPermission({ + state: status.state, + lastAssistantMessage: status.lastAssistantMessage, + toolName: status.toolName, + toolInput: status.toolInput + }) + : null) ?? parseApprovalFromStatus(status?.interactivePrompt) + const question = + blocked && status && !permission ? parseAgentQuestion(status.lastAssistantMessage ?? '') : null + const askFromStatus = useMemo( + () => parseAskFromStatus(status?.interactivePrompt, status?.toolName), + [status?.interactivePrompt, status?.toolName] + ) + const askFromMessages = useMemo( + () => (askFromStatus ? null : extractPendingAsk(messages)), + [askFromStatus, messages] + ) + + return { + permission, + question, + ask: enabled ? (askFromStatus ?? askFromMessages) : null + } +} diff --git a/mobile/src/session/use-mobile-native-chat-readability.test.ts b/mobile/src/session/use-mobile-native-chat-readability.test.ts new file mode 100644 index 00000000000..46feaf79217 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-readability.test.ts @@ -0,0 +1,107 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { useMobileNativeChatReadability } from './use-mobile-native-chat-readability' + +describe('useMobileNativeChatReadability', () => { + let renderer: ReactTestRenderer | null = null + let readable = false + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + readable = false + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + async function mount(connectionId: string | null): Promise { + const client = { + sendRequest: vi.fn().mockResolvedValue({ + ok: true, + result: { repos: [{ id: 'repo', connectionId }] } + }) + } as unknown as RpcClient + function Harness(): null { + readable = useMobileNativeChatReadability(client, 'repo::/worktree') + return null + } + const original = console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + try { + await act(async () => { + renderer = create(createElement(Harness)) + await Promise.resolve() + }) + } finally { + consoleSpy.mockRestore() + } + } + + it('admits local and runtime-owned transcript hosts', async () => { + await mount(null) + expect(readable).toBe(true) + act(() => renderer?.unmount()) + renderer = null + + await mount('runtime-ssh-environment') + expect(readable).toBe(true) + }) + + it('fails closed for Model-A SSH transcript hosts', async () => { + await mount('model-a-ssh') + expect(readable).toBe(false) + }) + + it('fails closed immediately while a reused route resolves its new worktree', async () => { + let resolveNext: (response: unknown) => void = () => {} + const client = { + sendRequest: vi + .fn() + .mockResolvedValueOnce({ + ok: true, + result: { repos: [{ id: 'local-repo', connectionId: null }] } + }) + .mockImplementationOnce(() => new Promise((resolve) => (resolveNext = resolve))) + } as unknown as RpcClient + function Harness({ worktreeId }: { worktreeId: string }): null { + readable = useMobileNativeChatReadability(client, worktreeId) + return null + } + const original = console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + try { + await act(async () => { + renderer = create(createElement(Harness, { worktreeId: 'local-repo::/one' })) + await Promise.resolve() + }) + expect(readable).toBe(true) + + act(() => renderer?.update(createElement(Harness, { worktreeId: 'ssh-repo::/two' }))) + expect(readable).toBe(false) + await act(async () => { + resolveNext({ + ok: true, + result: { repos: [{ id: 'ssh-repo', connectionId: 'model-a-ssh' }] } + }) + await Promise.resolve() + }) + expect(readable).toBe(false) + } finally { + consoleSpy.mockRestore() + } + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-readability.ts b/mobile/src/session/use-mobile-native-chat-readability.ts new file mode 100644 index 00000000000..e952335fe23 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-readability.ts @@ -0,0 +1,53 @@ +import { useEffect, useState } from 'react' +import type { RpcClient } from '../transport/rpc-client' +import { isMobileNativeChatTranscriptReadable } from './mobile-native-chat-eligibility' +import { getRepoIdFromMobileWorktreeId } from './mobile-session-route-helpers' + +type RepoSummary = { id: string; connectionId?: string | null } +type ReadabilityState = { client: RpcClient | null; worktreeId: string; readable: boolean } + +export function useMobileNativeChatReadability( + client: RpcClient | null, + worktreeId: string +): boolean { + const [state, setState] = useState({ + client: null, + worktreeId: '', + readable: false + }) + useEffect(() => { + let active = true + if (!client) { + setState({ client, worktreeId, readable: false }) + return + } + void client + .sendRequest('repo.list') + .then((response) => { + if (!active) { + return + } + const repos = response.ok + ? ((response.result as { repos?: RepoSummary[] }).repos ?? []) + : [] + const repoId = getRepoIdFromMobileWorktreeId(worktreeId) + const repo = repos.find((candidate) => candidate.id === repoId) + setState({ + client, + worktreeId, + readable: repo ? isMobileNativeChatTranscriptReadable(repo.connectionId ?? null) : false + }) + }) + .catch(() => { + if (active) { + setState({ client, worktreeId, readable: false }) + } + }) + return () => { + active = false + } + }, [client, worktreeId]) + // Why: route reuse renders before its new effect resolves; never expose the + // previous repo's readability under a different client/worktree key. + return state.client === client && state.worktreeId === worktreeId ? state.readable : false +} diff --git a/mobile/src/session/use-mobile-native-chat-session.test.ts b/mobile/src/session/use-mobile-native-chat-session.test.ts new file mode 100644 index 00000000000..584f34624f2 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-session.test.ts @@ -0,0 +1,220 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import type { RpcClient } from '../transport/rpc-client' +import { + useMobileNativeChatSession, + type MobileNativeChatSession +} from './use-mobile-native-chat-session' + +function message(id: string): NativeChatMessage { + return { + id, + role: 'assistant', + blocks: [{ type: 'text', text: id }], + timestamp: 1, + source: 'transcript' + } +} + +describe('useMobileNativeChatSession', () => { + let renderer: ReactTestRenderer | null = null + let state: MobileNativeChatSession | null = null + let emit: (frame: unknown) => void = () => {} + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + state = null + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + function Harness({ client }: { client: RpcClient | null }): null { + state = useMobileNativeChatSession({ + client, + agent: 'claude', + sessionId: 'session', + transcriptPath: null + }) + return null + } + + async function mount(client: RpcClient): Promise { + const original = console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + try { + await act(async () => { + renderer = create(createElement(Harness, { client })) + }) + } finally { + consoleSpy.mockRestore() + } + } + + it('drops an older-page response captured before transcript replacement', async () => { + let resolveEarlier: (response: unknown) => void = () => {} + const sendRequest = vi.fn( + () => new Promise((resolve) => (resolveEarlier = resolve)) + ) as unknown as RpcClient['sendRequest'] + const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => { + emit = onData + onData({ + type: 'snapshot', + messages: Array.from({ length: 40 }, (_unused, index) => message(`old-${index}`)), + hasMore: true, + beforeOffset: 100 + }) + return () => {} + }) + await mount({ sendRequest, subscribe } as unknown as RpcClient) + act(() => state?.loadEarlier()) + + await act(async () => { + emit({ + type: 'replacement', + messages: [message('replacement')], + hasMore: false, + beforeOffset: 0 + }) + }) + await act(async () => { + resolveEarlier({ + ok: true, + result: { messages: [message('stale-page')], hasMore: false, beforeOffset: 0 } + }) + await Promise.resolve() + }) + + expect(state?.messages.map((entry) => entry.id)).toEqual(['replacement']) + expect(state?.loadingEarlier).toBe(false) + }) + + it('drops an older-page response after the client source disappears', async () => { + let resolveEarlier: (response: unknown) => void = () => {} + const sendRequest = vi.fn( + () => new Promise((resolve) => (resolveEarlier = resolve)) + ) as unknown as RpcClient['sendRequest'] + const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => { + onData({ + type: 'snapshot', + messages: Array.from({ length: 40 }, (_unused, index) => message(`old-${index}`)), + hasMore: true, + beforeOffset: 100 + }) + return () => {} + }) + await mount({ sendRequest, subscribe } as unknown as RpcClient) + act(() => state?.loadEarlier()) + await act(async () => renderer?.update(createElement(Harness, { client: null }))) + await act(async () => { + resolveEarlier({ ok: true, result: { messages: [message('stale-page')] } }) + await Promise.resolve() + }) + + expect(state?.messages).toEqual([]) + expect(state?.status).toBe('idle') + expect(state?.loadingEarlier).toBe(false) + }) + + it.each(['replacement', 'snapshot'] as const)( + 'can page again after an authoritative %s resets a maxed-out read window', + async (frameType) => { + const sendRequest = vi.fn().mockResolvedValue({ + ok: true, + result: { messages: [message('older')], hasMore: true, beforeOffset: 50 } + }) + const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => { + emit = onData + onData({ + type: 'snapshot', + messages: Array.from({ length: 40 }, (_unused, index) => message(`old-${index}`)), + hasMore: true, + beforeOffset: 100 + }) + return () => {} + }) + await mount({ sendRequest, subscribe } as unknown as RpcClient) + for (let page = 0; page < 33; page += 1) { + await act(async () => { + state?.loadEarlier() + await Promise.resolve() + }) + } + const requestsAtCap = sendRequest.mock.calls.length + + await act(async () => + emit({ + type: frameType, + messages: [message('authoritative')], + hasMore: true, + beforeOffset: 500 + }) + ) + await act(async () => { + state?.loadEarlier() + await Promise.resolve() + }) + + expect(sendRequest).toHaveBeenCalledTimes(requestsAtCap + 1) + expect(sendRequest).toHaveBeenLastCalledWith('nativeChat.readSession', { + agent: 'claude', + sessionId: 'session', + limit: 60, + beforeOffset: 500 + }) + } + ) + + it('rejects a cursor page invalidated by live trim and retries with a growing tail', async () => { + let resolveCursorPage: (response: unknown) => void = () => {} + const sendRequest = vi + .fn() + .mockImplementationOnce(() => new Promise((resolve) => (resolveCursorPage = resolve))) + .mockResolvedValueOnce({ + ok: true, + result: { messages: [message('fresh-growing-tail')], hasMore: false } + }) + const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => { + emit = onData + onData({ + type: 'snapshot', + messages: Array.from({ length: 40 }, (_unused, index) => message(`window-${index}`)), + hasMore: true, + beforeOffset: 100 + }) + return () => {} + }) + await mount({ sendRequest, subscribe } as unknown as RpcClient) + act(() => state?.loadEarlier()) + await act(async () => emit({ type: 'appended', messages: [message('live-trim')] })) + await act(async () => { + resolveCursorPage({ + ok: true, + result: { messages: [message('stale-cursor-page')], hasMore: true, beforeOffset: 50 } + }) + await Promise.resolve() + }) + expect(state?.messages.map((entry) => entry.id)).not.toContain('stale-cursor-page') + + await act(async () => { + state?.loadEarlier() + await Promise.resolve() + }) + + expect(sendRequest).toHaveBeenLastCalledWith('nativeChat.readSession', { + agent: 'claude', + sessionId: 'session', + limit: 100 + }) + expect(state?.messages.map((entry) => entry.id)).toEqual(['fresh-growing-tail']) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-session.ts b/mobile/src/session/use-mobile-native-chat-session.ts new file mode 100644 index 00000000000..2d381fdd602 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-session.ts @@ -0,0 +1,219 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { buildNativeChatSubscriptionId } from '../../../src/shared/native-chat-stream-unsubscribe' +import type { RpcClient } from '../transport/rpc-client' +import { createNativeChatMerger, replaceList } from './mobile-native-chat-merge' +import { + applyMobileNativeChatStreamFrame, + type MobileNativeChatStreamFrame +} from './mobile-native-chat-stream-frame' + +export type MobileNativeChatStatus = 'idle' | 'loading' | 'waiting-session' | 'ready' | 'error' + +export type MobileNativeChatSession = { + messages: NativeChatMessage[] + status: MobileNativeChatStatus + error?: string + /** True when an older page may exist (the last read filled the window). */ + hasMore: boolean + /** Whether an older-history page is currently loading. */ + loadingEarlier: boolean + /** Grow the window to page in older history. */ + loadEarlier: () => void +} + +// Small first page for a fast first paint; grows by a page as the user scrolls. +const INITIAL_LIMIT = 40 +const PAGE = 60 +const MAX_MESSAGES = 2000 + +type ReadSessionResult = + | { messages: NativeChatMessage[]; hasMore?: boolean; beforeOffset?: number } + | { error: string } +/** Subscribe to an agent's native-chat transcript over the paired connection. + * Reads a small recent window for a fast first paint, tails it for live turns, + * and pages in older history on demand. Read results replace the list (they are + * an ordered tail); live appends merge by id so order stays stable. */ +export function useMobileNativeChatSession(args: { + client: RpcClient | null + agent: string | null + sessionId: string | null + transcriptPath: string | null +}): MobileNativeChatSession { + const { client, agent, sessionId, transcriptPath } = args + const [messages, setMessages] = useState([]) + const [status, setStatus] = useState('idle') + const [error, setError] = useState(undefined) + const [hasMore, setHasMore] = useState(false) + const [loadingEarlier, setLoadingEarlier] = useState(false) + const loadingEarlierRef = useRef(false) + const beforeOffsetRef = useRef(null) + // Stateful id-dedup merger: caches the id→index map so each live append frame + // costs O(incoming), not O(existing+incoming) (#18). `replaceList` resets the + // base (read / loadEarlier ordered tails); `applyAppend` folds live frames in. + const mergerRef = useRef(createNativeChatMerger()) + const limitRef = useRef(INITIAL_LIMIT) + // Tracks the live session so a late loadEarlier resolve can detect a swap. + const sessionIdRef = useRef(sessionId) + sessionIdRef.current = sessionId + const streamGenerationRef = useRef(0) + + // Replace the base list (read results are an ordered tail). Resets the merger + // cache so the index is rebuilt once over the new base. + const setList = useCallback((next: readonly NativeChatMessage[]) => { + replaceList(mergerRef.current, next) + setMessages(mergerRef.current.list) + }, []) + + useEffect(() => { + let cancelled = false + // Why: disconnect/agent/session loss must invalidate a page request before + // the early idle/waiting return can clear the visible source. + streamGenerationRef.current += 1 + limitRef.current = INITIAL_LIMIT + loadingEarlierRef.current = false + setLoadingEarlier(false) + setList([]) + setError(undefined) + setHasMore(false) + beforeOffsetRef.current = null + if (!client || !agent) { + setStatus('idle') + return + } + if (!sessionId) { + setStatus('waiting-session') + return + } + + setStatus('loading') + + const unsubscribe = client.subscribe( + 'nativeChat.subscribe', + { + agent, + sessionId, + limit: limitRef.current, + subscriptionId: buildNativeChatSubscriptionId(agent, sessionId), + ...(transcriptPath ? { transcriptPath } : {}) + }, + (raw) => { + if (cancelled) { + return + } + const frame = raw as MobileNativeChatStreamFrame + if (frame.type === 'replacement' || frame.type === 'snapshot') { + // Why: replacement and reconnect snapshots are authoritative windows; + // stale page limits/results must not constrain the fresh generation. + streamGenerationRef.current += 1 + limitRef.current = INITIAL_LIMIT + loadingEarlierRef.current = false + setLoadingEarlier(false) + } + const replaceSnapshot = frame.type === 'snapshot' + const applied = applyMobileNativeChatStreamFrame({ + merger: mergerRef.current, + frame, + limit: limitRef.current, + replaceSnapshot + }) + if (applied.kind === 'ignored') { + return + } + if (applied.kind === 'error') { + setStatus('error') + setError(applied.error) + return + } + setMessages(applied.messages) + if (applied.hasMore != null) { + setHasMore(applied.hasMore) + } + if (applied.beforeOffset != null) { + beforeOffsetRef.current = applied.beforeOffset + } + if (applied.cursorInvalidated) { + // Fall back to a growing-tail read so history trimmed by live appends + // cannot leave a gap between the retained window and the old cursor. + streamGenerationRef.current += 1 + loadingEarlierRef.current = false + setLoadingEarlier(false) + beforeOffsetRef.current = null + } + setStatus('ready') + } + ) + + return () => { + cancelled = true + unsubscribe() + } + }, [client, agent, sessionId, transcriptPath, setList]) + + const loadEarlier = useCallback(() => { + if (!client || !agent || !sessionId || loadingEarlierRef.current || !hasMore) { + return + } + // Capture the session this page belongs to; a swap underneath us must not + // apply this read's result onto the new session (mirrors desktop's guard). + const requestSessionId = sessionId + const requestGeneration = streamGenerationRef.current + const nextLimit = Math.min(limitRef.current + PAGE, MAX_MESSAGES) + const pageLimit = nextLimit - limitRef.current + if (pageLimit <= 0) { + setHasMore(false) + return + } + const beforeOffset = beforeOffsetRef.current + loadingEarlierRef.current = true + setLoadingEarlier(true) + void (async () => { + try { + const response = await client.sendRequest('nativeChat.readSession', { + agent, + sessionId, + limit: beforeOffset === null ? nextLimit : pageLimit, + ...(beforeOffset === null ? {} : { beforeOffset }), + ...(transcriptPath ? { transcriptPath } : {}) + }) + if (!response.ok) { + return + } + const result = response.result as ReadSessionResult + if ('error' in result) { + return + } + // Drop a stale resolve from a session that swapped underneath us. + if ( + sessionIdRef.current !== requestSessionId || + streamGenerationRef.current !== requestGeneration + ) { + return + } + limitRef.current = nextLimit + if (beforeOffset !== null && result.beforeOffset != null) { + beforeOffsetRef.current = result.beforeOffset + setList([...result.messages, ...mergerRef.current.list]) + setHasMore( + nextLimit < MAX_MESSAGES && (result.hasMore ?? result.messages.length >= pageLimit) + ) + } else { + // Older runtimes ignore the cursor and return the growing tail. + setList(result.messages) + setHasMore(result.messages.length >= nextLimit) + } + } finally { + // A late page from a prior tab must not unlock the current tab's request. + if ( + sessionIdRef.current === requestSessionId && + streamGenerationRef.current === requestGeneration + ) { + loadingEarlierRef.current = false + setLoadingEarlier(false) + } + } + })() + }, [client, agent, sessionId, transcriptPath, hasMore, setList]) + + return { messages, status, error, hasMore, loadingEarlier, loadEarlier } +} diff --git a/mobile/src/session/use-mobile-native-chat-stop.test.ts b/mobile/src/session/use-mobile-native-chat-stop.test.ts new file mode 100644 index 00000000000..27385f8d33e --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-stop.test.ts @@ -0,0 +1,85 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { useMobileNativeChatStop } from './use-mobile-native-chat-stop' + +describe('useMobileNativeChatStop', () => { + let renderer: ReactTestRenderer | null = null + let stop: (() => void) | null = null + const sendRequest = vi.fn() + const onSendError = vi.fn() + + beforeEach(() => { + vi.useFakeTimers() + globalThis.IS_REACT_ACT_ENVIRONMENT = true + sendRequest.mockReset().mockResolvedValue({ ok: true }) + onSendError.mockReset() + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + stop = null + vi.useRealTimers() + }) + + function Harness({ + enabled, + streamIdentity + }: { + enabled: boolean + streamIdentity: string + }): null { + stop = useMobileNativeChatStop({ + client: { sendRequest } as unknown as RpcClient, + enabled, + handleRef: { current: 'terminal-1' }, + deviceTokenRef: { current: 'mobile-1' }, + streamIdentity, + cancelPending: vi.fn(), + onSendError + }) + return null + } + + async function render(enabled: boolean, streamIdentity: string): Promise { + await act(async () => { + const element = createElement(Harness, { enabled, streamIdentity }) + if (renderer) { + renderer.update(element) + } else { + renderer = create(element) + } + }) + } + + it.each([ + ['the acknowledged input lease is lost', false, 'stream-1'], + ['the active stream changes', true, 'stream-2'] + ])('cancels the delayed second Escape when %s', async (_case, enabled, streamIdentity) => { + await render(true, 'stream-1') + + act(() => stop?.()) + expect(sendRequest).toHaveBeenCalledTimes(1) + + await render(enabled as boolean, streamIdentity as string) + await act(async () => vi.runAllTimersAsync()) + + expect(sendRequest).toHaveBeenCalledTimes(1) + }) + + it('handles a rejected Escape without leaking an unhandled rejection', async () => { + sendRequest.mockRejectedValue(new Error('disconnected')) + await render(true, 'stream-1') + + act(() => stop?.()) + await act(async () => { + await Promise.resolve() + await vi.runAllTimersAsync() + }) + + expect(onSendError).toHaveBeenCalledOnce() + expect(onSendError).toHaveBeenCalledWith('Stop not sent') + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-stop.ts b/mobile/src/session/use-mobile-native-chat-stop.ts new file mode 100644 index 00000000000..c6d36ea3436 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-stop.ts @@ -0,0 +1,76 @@ +import { useCallback, useEffect, useRef, type MutableRefObject } from 'react' +import type { RpcClient } from '../transport/rpc-client' + +export function useMobileNativeChatStop(args: { + client: RpcClient | null + enabled: boolean + handleRef: MutableRefObject + deviceTokenRef: MutableRefObject + streamIdentity: string + cancelPending: () => void + onSendError: (message: string) => void +}): () => void { + const { client, enabled, handleRef, deviceTokenRef, streamIdentity, cancelPending, onSendError } = + args + const timerRef = useRef | null>(null) + const activeRouteRef = useRef({ client, enabled, streamIdentity }) + activeRouteRef.current = { client, enabled, streamIdentity } + useEffect(() => { + if (!enabled && timerRef.current) { + clearTimeout(timerRef.current) + timerRef.current = null + } + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current) + timerRef.current = null + } + } + }, [client, enabled, streamIdentity]) + return useCallback(() => { + const handle = handleRef.current + if (!client || !handle || !enabled) { + onSendError('Stop not sent (terminal not ready)') + return + } + cancelPending() + if (timerRef.current) { + clearTimeout(timerRef.current) + } + const stopStreamIdentity = streamIdentity + let failureReported = false + const sendEscape = (): void => { + const activeRoute = activeRouteRef.current + if ( + !activeRoute.enabled || + activeRoute.client !== client || + activeRoute.streamIdentity !== stopStreamIdentity || + handleRef.current !== handle + ) { + return + } + void client + .sendRequest('terminal.send', { + terminal: handle, + text: String.fromCharCode(27), + ...(deviceTokenRef.current + ? { client: { id: deviceTokenRef.current, type: 'mobile' as const } } + : {}) + }) + .catch(() => { + // Why: disconnect can race either fire-and-forget Escape; surface one + // failure instead of leaking an unhandled RPC rejection. + if (!failureReported) { + failureReported = true + onSendError('Stop not sent') + } + }) + } + sendEscape() + // Why: two paced Escape bytes reliably stop TUIs without remote coalescing. + timerRef.current = setTimeout(() => { + timerRef.current = null + sendEscape() + }, 80) + }, [cancelPending, client, deviceTokenRef, enabled, handleRef, onSendError, streamIdentity]) +} diff --git a/mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts b/mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts new file mode 100644 index 00000000000..92320b9ff86 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts @@ -0,0 +1,115 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useMobileNativeChatTerminalStream } from './use-mobile-native-chat-terminal-stream' + +describe('useMobileNativeChatTerminalStream', () => { + let renderer: ReactTestRenderer | null = null + let harnessRenderCount = 0 + const subscriptionsRef = { current: new Map void>() } + const subscribingRef = { current: new Set() } + const webReadyRef = { current: new Set(['terminal-1']) } + const initializedRef = { current: new Set(['terminal-1']) } + const subscribe = vi.fn((handle: string) => subscriptionsRef.current.set(handle, () => {})) + const unsubscribe = vi.fn((handle: string) => subscriptionsRef.current.delete(handle)) + const notifyWebReadyRef = { current: (_handle: string, _wasAlreadyReady: boolean): void => {} } + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + subscriptionsRef.current = new Map([['terminal-1', () => {}]]) + subscribingRef.current = new Set() + webReadyRef.current = new Set(['terminal-1']) + initializedRef.current = new Set(['terminal-1']) + harnessRenderCount = 0 + subscribe.mockClear() + unsubscribe.mockClear() + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + function Harness({ showNativeChat }: { showNativeChat: boolean }): null { + harnessRenderCount += 1 + notifyWebReadyRef.current = useMobileNativeChatTerminalStream({ + showNativeChat, + activeHandle: 'terminal-1', + activeTabType: 'terminal', + subscriptionsRef, + subscribingRef, + webReadyRef, + initializedRef, + subscribe, + unsubscribe + }) + return null + } + + it('replaces output with a lease-only stream while covered, then restores output', async () => { + const original = console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + try { + await act(async () => { + renderer = create(createElement(Harness, { showNativeChat: false })) + }) + await act(async () => { + notifyWebReadyRef.current('terminal-1', false) + }) + expect(harnessRenderCount).toBe(1) + await act(async () => { + renderer?.update(createElement(Harness, { showNativeChat: true })) + }) + + expect(unsubscribe).toHaveBeenNthCalledWith(1, 'terminal-1') + expect(subscribe).toHaveBeenNthCalledWith(1, 'terminal-1') + expect(initializedRef.current.has('terminal-1')).toBe(false) + + await act(async () => { + renderer?.update(createElement(Harness, { showNativeChat: false })) + }) + + expect(unsubscribe).toHaveBeenNthCalledWith(2, 'terminal-1') + expect(subscribe).toHaveBeenNthCalledWith(2, 'terminal-1') + } finally { + consoleSpy.mockRestore() + } + }) + + it('resumes a cold-start lease-only stream when WebView readiness arrives late', async () => { + const original = console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + try { + webReadyRef.current.clear() + await act(async () => { + renderer = create(createElement(Harness, { showNativeChat: true })) + }) + await act(async () => { + renderer?.update(createElement(Harness, { showNativeChat: false })) + }) + + expect(unsubscribe).toHaveBeenCalledOnce() + expect(subscribe).toHaveBeenCalledOnce() + + webReadyRef.current.add('terminal-1') + await act(async () => { + notifyWebReadyRef.current('terminal-1', false) + }) + + expect(unsubscribe).toHaveBeenNthCalledWith(2, 'terminal-1') + expect(subscribe).toHaveBeenNthCalledWith(2, 'terminal-1') + } finally { + consoleSpy.mockRestore() + } + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-terminal-stream.ts b/mobile/src/session/use-mobile-native-chat-terminal-stream.ts new file mode 100644 index 00000000000..c45980ae6e3 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-terminal-stream.ts @@ -0,0 +1,76 @@ +import { useCallback, useEffect, useRef, useState, type MutableRefObject } from 'react' +import { resolveMobileNativeChatTerminalStreamAction } from './mobile-native-chat-terminal-stream' + +/** Pauses the active terminal stream while native chat covers its mounted WebView, + * then resumes from a fresh scrollback snapshot when terminal view returns. */ +export function useMobileNativeChatTerminalStream(args: { + showNativeChat: boolean + activeHandle: string | null + activeTabType: string | null + subscriptionsRef: MutableRefObject void>> + subscribingRef: MutableRefObject> + webReadyRef: MutableRefObject> + initializedRef: MutableRefObject> + subscribe: (handle: string) => void + unsubscribe: (handle: string) => void +}): (handle: string, wasAlreadyReady: boolean) => void { + const coveredHandleRef = useRef(null) + const [webReadyRevision, setWebReadyRevision] = useState(0) + const notifyWebReady = useCallback((handle: string, wasAlreadyReady: boolean) => { + // Why: ordinary WebView startups must not rerender the large session route; + // only readiness that can release a native-chat lease needs reconciliation. + if (!wasAlreadyReady && coveredHandleRef.current === handle) { + setWebReadyRevision((revision) => revision + 1) + } + }, []) + useEffect(() => { + const handle = args.activeHandle + if (coveredHandleRef.current && coveredHandleRef.current !== handle) { + coveredHandleRef.current = null + } + const streamActive = + handle != null && + (args.subscriptionsRef.current.has(handle) || args.subscribingRef.current.has(handle)) + const action = resolveMobileNativeChatTerminalStreamAction({ + showNativeChat: args.showNativeChat, + activeHandle: handle, + activeTabType: args.activeTabType, + streamActive, + streamCovered: coveredHandleRef.current === handle, + webViewReady: handle != null && args.webReadyRef.current.has(handle) + }) + if (!handle || action === 'none') { + return + } + if (action === 'pause') { + coveredHandleRef.current = handle + // Why: returning to terminal must accept the fresh scrollback snapshot; + // the stream was paused while chat covered output that xterm never saw. + args.initializedRef.current.delete(handle) + // Why: covered chat needs the input-floor lease without paying to stream + // duplicate PTY output. Replace any view stream with a lease-only stream. + if (streamActive) { + args.unsubscribe(handle) + } + args.subscribe(handle) + return + } + if (coveredHandleRef.current === handle) { + args.unsubscribe(handle) + coveredHandleRef.current = null + } + args.subscribe(handle) + }, [ + args.activeHandle, + args.activeTabType, + args.initializedRef, + args.showNativeChat, + args.subscribe, + args.subscribingRef, + args.subscriptionsRef, + args.unsubscribe, + args.webReadyRef, + webReadyRevision + ]) + return notifyWebReady +} diff --git a/mobile/src/session/use-throttled-latest-value.test.ts b/mobile/src/session/use-throttled-latest-value.test.ts new file mode 100644 index 00000000000..bdea29c7459 --- /dev/null +++ b/mobile/src/session/use-throttled-latest-value.test.ts @@ -0,0 +1,69 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' +import { useThrottledLatestValue } from './use-throttled-latest-value' + +describe('useThrottledLatestValue', () => { + let renderer: ReactTestRenderer | null = null + let latest: string | undefined + let consoleSpy: MockInstance + + function Harness({ value }: { value: string | undefined }): null { + latest = useThrottledLatestValue(value, 50) + return null + } + + function render(value: string | undefined): void { + act(() => { + renderer = create(createElement(Harness, { value })) + }) + } + + function update(value: string | undefined): void { + act(() => renderer?.update(createElement(Harness, { value }))) + } + + beforeEach(() => { + vi.useFakeTimers() + globalThis.IS_REACT_ACT_ENVIRONMENT = true + latest = undefined + const original = console.error + consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + vi.useRealTimers() + consoleSpy.mockRestore() + }) + + it('emits the first frame immediately', () => { + render('a') + expect(latest).toBe('a') + }) + + it('holds rapid updates but eventually emits the latest value', () => { + render('a') + update('ab') + update('abc') + expect(latest).toBe('a') + act(() => vi.advanceTimersByTime(50)) + expect(latest).toBe('abc') + }) + + it('clears immediately and drops the trailing emit when the value goes undefined', () => { + render('a') + update('ab') + expect(latest).toBe('a') + update(undefined) + expect(latest).toBeUndefined() + act(() => vi.advanceTimersByTime(50)) + expect(latest).toBeUndefined() + }) +}) diff --git a/mobile/src/session/use-throttled-latest-value.ts b/mobile/src/session/use-throttled-latest-value.ts new file mode 100644 index 00000000000..652b1e79119 --- /dev/null +++ b/mobile/src/session/use-throttled-latest-value.ts @@ -0,0 +1,52 @@ +import { useEffect, useRef, useState } from 'react' + +/** Rate-limits a rapidly-changing value to at most one emit per `intervalMs` + * while always surfacing the latest value. OpenCode publishes a streaming + * assistant frame per part; unthrottled, each re-parses the whole bubble. */ +export function useThrottledLatestValue(value: T, intervalMs: number): T { + const [throttled, setThrottled] = useState(value) + const valueRef = useRef(value) + valueRef.current = value + const lastEmitRef = useRef(0) + const timerRef = useRef | null>(null) + + useEffect(() => { + if (value == null) { + // Turn ended: drop any trailing emit and reset so the next stream's first + // frame shows at once instead of the stale bubble lingering. + if (timerRef.current) { + clearTimeout(timerRef.current) + timerRef.current = null + } + lastEmitRef.current = 0 + setThrottled(value) + return + } + const elapsed = Date.now() - lastEmitRef.current + if (elapsed >= intervalMs) { + lastEmitRef.current = Date.now() + setThrottled(value) + return + } + if (timerRef.current) { + return + } + timerRef.current = setTimeout(() => { + timerRef.current = null + lastEmitRef.current = Date.now() + setThrottled(valueRef.current) + }, intervalMs - elapsed) + }, [value, intervalMs]) + + useEffect( + () => () => { + if (timerRef.current) { + clearTimeout(timerRef.current) + timerRef.current = null + } + }, + [] + ) + + return throttled +} diff --git a/mobile/src/storage/preferences.test.ts b/mobile/src/storage/preferences.test.ts index f8e07fb4d98..881391db0bd 100644 --- a/mobile/src/storage/preferences.test.ts +++ b/mobile/src/storage/preferences.test.ts @@ -8,6 +8,7 @@ import { HOST_SIDEBAR_MIN_WIDTH, clampHostDockWidth, clampHostSidebarWidth, + loadNativeChatTabIds, loadDisabledTerminalLiveInputHandles, loadHostSidebarWidth, loadPushNotificationsEnabled, @@ -17,6 +18,7 @@ import { readDisabledTerminalLiveInputHandlesPreference, saveDisabledTerminalLiveInputHandles, saveHostSidebarWidth, + saveNativeChatTabIds, savePushNotificationsEnabled, saveTerminalAutocompleteEnabled, saveTerminalLinkOpenMode @@ -29,6 +31,31 @@ vi.mock('@react-native-async-storage/async-storage', () => ({ } })) +describe('native chat tab preference', () => { + beforeEach(() => { + vi.mocked(AsyncStorage.getItem).mockReset() + vi.mocked(AsyncStorage.setItem).mockReset() + }) + + it('loads and saves tab ids under a host-and-worktree scoped key', async () => { + vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(['tab-1', 42, 'tab-2'])) + + await expect(loadNativeChatTabIds('host/one', 'folder:C:\\repo')).resolves.toEqual([ + 'tab-1', + 'tab-2' + ]) + expect(AsyncStorage.getItem).toHaveBeenCalledWith( + 'orca:nativeChatTabs:host%2Fone:folder%3AC%3A%5Crepo' + ) + + await saveNativeChatTabIds('host/one', 'folder:C:\\repo', ['tab-2']) + expect(AsyncStorage.setItem).toHaveBeenCalledWith( + 'orca:nativeChatTabs:host%2Fone:folder%3AC%3A%5Crepo', + JSON.stringify(['tab-2']) + ) + }) +}) + describe('push notification preference', () => { beforeEach(() => { vi.mocked(AsyncStorage.getItem).mockReset() diff --git a/mobile/src/storage/preferences.ts b/mobile/src/storage/preferences.ts index 5173ac5bc8a..a5af9bf350f 100644 --- a/mobile/src/storage/preferences.ts +++ b/mobile/src/storage/preferences.ts @@ -2,6 +2,35 @@ import AsyncStorage from '@react-native-async-storage/async-storage' const PINS_PREFIX = 'orca:pins:' const NOTIF_KEY = 'orca:pushNotificationsEnabled' +const NATIVE_CHAT_TABS_PREFIX = 'orca:nativeChatTabs:' + +function nativeChatTabIdsKey(hostId: string, worktreeId: string): string { + return `${NATIVE_CHAT_TABS_PREFIX}${encodeURIComponent(hostId)}:${encodeURIComponent(worktreeId)}` +} + +/** Tab ids currently showing native chat, scoped to the paired host and worktree + * so colliding remote ids cannot activate a transcript watcher on another host. */ +export async function loadNativeChatTabIds(hostId: string, worktreeId: string): Promise { + try { + const raw = await AsyncStorage.getItem(nativeChatTabIdsKey(hostId, worktreeId)) + const parsed = raw ? (JSON.parse(raw) as unknown) : null + return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : [] + } catch { + return [] + } +} + +export async function saveNativeChatTabIds( + hostId: string, + worktreeId: string, + ids: string[] +): Promise { + try { + await AsyncStorage.setItem(nativeChatTabIdsKey(hostId, worktreeId), JSON.stringify(ids)) + } catch { + // Best-effort persistence; a failed write just forgets the view choice. + } +} export type PushNotificationsPreference = { readonly value: boolean | null diff --git a/mobile/src/theme/mobile-theme.ts b/mobile/src/theme/mobile-theme.ts index 76b14415a32..dbf09b745b9 100644 --- a/mobile/src/theme/mobile-theme.ts +++ b/mobile/src/theme/mobile-theme.ts @@ -18,6 +18,9 @@ export const colors = { surfaceBright: '#f5f5f5', accentBlue: '#3b82f6', + // Text/icon color on a filled accent (accentBlue) button, where the muted + // textPrimary would lack contrast against the saturated fill. + onAccent: '#ffffff', statusGreen: '#22c55e', statusAmber: '#f59e0b', diff --git a/mobile/src/transport/rpc-client-terminal-subscription.ts b/mobile/src/transport/rpc-client-terminal-subscription.ts index 9afc4a1fd0c..471bc3c4a4e 100644 --- a/mobile/src/transport/rpc-client-terminal-subscription.ts +++ b/mobile/src/transport/rpc-client-terminal-subscription.ts @@ -1,3 +1,5 @@ +import { buildNativeChatUnsubscribe } from '../../../src/shared/native-chat-stream-unsubscribe' + type TerminalStreamParams = { terminal?: unknown } @@ -31,6 +33,37 @@ export function updateTerminalSubscriptionViewport( } } +/** Build the unsubscribe RPC for a streaming method that needs the host told to + * tear down (session tabs, native chat), or null when none is required. Keeps + * the per-method echo logic out of the rpc-client teardown closure. */ +export function buildStreamUnsubscribe( + method: string | undefined, + params: unknown +): { method: string; params: Record } | null { + if (!params || typeof params !== 'object') { + return null + } + if (method === 'session.tabs.subscribe') { + const worktree = (params as { worktree?: unknown }).worktree + return typeof worktree === 'string' + ? { method: 'session.tabs.unsubscribe', params: { worktree } } + : null + } + if (method === 'nativeChat.subscribe') { + const subscriptionId = (params as { subscriptionId?: unknown }).subscriptionId + if (typeof subscriptionId === 'string') { + return { method: 'nativeChat.unsubscribe', params: { subscriptionId } } + } + // Backward compatibility for callers that predate explicit cleanup tokens. + const agent = (params as { agent?: unknown }).agent + const sessionId = (params as { sessionId?: unknown }).sessionId + return typeof agent === 'string' && typeof sessionId === 'string' + ? buildNativeChatUnsubscribe(agent, sessionId) + : null + } + return null +} + export function buildTerminalUnsubscribeParams( params: unknown ): { subscriptionId: string; client?: { id: string } } | null { diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts index 014bea955cd..5420ba679a6 100644 --- a/mobile/src/transport/rpc-client.ts +++ b/mobile/src/transport/rpc-client.ts @@ -23,6 +23,7 @@ import { type BrowserScreencastFrame } from './browser-screencast-protocol' import { + buildStreamUnsubscribe, buildTerminalUnsubscribeParams, updateTerminalSubscriptionViewport as updateCachedTerminalSubscriptionViewport } from './rpc-client-terminal-subscription' @@ -1171,18 +1172,11 @@ export function connect( params: unsubscribeParams }) } - } else if ( - stream?.method === 'session.tabs.subscribe' && - stream.params && - typeof stream.params === 'object' && - typeof (stream.params as { worktree?: unknown }).worktree === 'string' - ) { - sendEncrypted({ - id: nextId(), - deviceToken, - method: 'session.tabs.unsubscribe', - params: { worktree: (stream.params as { worktree: string }).worktree } - }) + } else { + const unsub = buildStreamUnsubscribe(stream?.method, stream?.params) + if (unsub) { + sendEncrypted({ id: nextId(), deviceToken, method: unsub.method, params: unsub.params }) + } } removeStreamListener(id) } diff --git a/notes/skill-freshness-design.md b/notes/skill-freshness-design.md index 5da3b242817..8d12f8915a7 100644 --- a/notes/skill-freshness-design.md +++ b/notes/skill-freshness-design.md @@ -126,6 +126,7 @@ Rules: ### C. Read-only detection (kept from Phase 1, slimmed) Kept as-is: + - Bundled `skills/` packages + current manifest + released-snapshot registry + release mapping, with the generation script and merge-queue monotonicity gate (static data + CI, not runtime machinery). @@ -135,9 +136,12 @@ Kept as-is: plugin caches and repo scopes excluded), and the launch / focus / post-install triggers. - The skills-CLI round-trip CI on macOS/Linux/Windows — extended from current-install tests to historical-fat-install → targeted global update → stub migration. The matrix covers - copy/symlink shapes, LF/CRLF, supported lock migrations, and post-update identity. + LF/CRLF and provider aliases as a positive convergence contract, plus independent-copy + observation that accepts only unchanged historical or exact-current bytes. Post-update + bytes, not exit status, decide. Slimmed: + - Statuses collapse to: `current`, `outdated` (exact match of an older released snapshot), `newer-known`, `unrecognized`, and `inaccessible`. Without a ledger, Orca cannot honestly distinguish a locally modified official copy from unrelated same-named content; @@ -151,32 +155,51 @@ Slimmed: ### D. Surfacing -- **Settings rows** (read-only): name, status badge, one-line explanation. `newer-known`, - `unrecognized`, `inaccessible`, and unsupported-topology rows are informational. -- **Name-scoped update eligibility:** the skills CLI reinstalls every placement of a selected - skill name, so eligibility is computed across all discovered placements of that name, not - per row. Offer a name only when at least one placement is `outdated` and every placement is - an exact `current` or `outdated` official snapshot in a supported global topology. One - `newer-known`, unrecognized, external, read-only, inaccessible, or otherwise unsupported - provider copy poisons the update offer for that name entirely. +- **Surfaces (venue decision 2026-07-14):** a lingering toast, an update modal, and the + existing Settings setup rails for CLI, Orchestration, Computer Use, and Per-Workspace + Environments. Their installed pills carry safe freshness status, while their existing + Update and Re-check actions remain the per-skill path. The Skills page was + de-linked by #4535 (2026-06-02) — its only entry, the sidebar toolbox menu, was removed — + so it is no longer a venue; the freshness surface moved off it entirely. The behavior + contracts below (name-scoped eligibility, no auto-run, dismissal keys, re-inventory + triggers) are unchanged; only the venue moved. +- **Per-placement rows** (read-only): name, status badge, one-line explanation. `newer-known`, + `unrecognized`, `inaccessible`, and unsupported-topology rows are informational. They live in + the modal's collapsed **Details** section (auto-expanded when a placement is blocked). +- **Name-scoped update eligibility:** eligibility is computed across all discovered placements + of a name, not per row. Offer a name only when at least one placement is `outdated` and every + placement is an exact `current` or `outdated` official snapshot in a topology the validated + rail actually converges. With skills CLI 1.5.17 that means the canonical global copy and + provider aliases to it. Independent provider copies are informational and poison the offer: + empirical copy-mode testing produced both stale and converged provider copies in otherwise + equivalent 1.5.17 environments, so that topology is not deterministic enough to offer. One + `newer-known`, unrecognized, external, read-only, + inaccessible, repo/plugin, independent-copy, or otherwise unsupported placement poisons the + update offer for that name entirely. - The action combines only eligible outdated Orca names into - `npx skills update --global`, opens the existing run-command terminal with that - command pre-filled, and leaves execution to the user. Never use an unscoped bulk update and - never auto-submit the command. Re-inventory after terminal exit or focus; only observed - bytes, not the skills CLI exit status, determine success. -- **One non-repeating nudge**: count only eligible outdated skill names and offer the same - targeted run-the-command action. An outdated name poisoned by another placement remains - visible in settings but never produces an unsafe nudge action. Dismissal is recorded per - (install, bundled revision), so a newly outdated official placement or genuinely newer - stub revision may prompt once more. No toggle — nothing automatic happens that would need - one. + `npx skills update --global` and opens the update modal's editable terminal with + that command pre-filled, leaving execution to the user. Never use an unscoped bulk update and + never auto-submit the command. Re-inventory after terminal exit, modal close, or focus; only + observed bytes, not the skills CLI exit status, determine success. When the eligible set + empties and every placement is `current`, the modal shows an up-to-date state; if placements + remain outdated-but-blocked or unrecognized, it says so honestly instead. +- **One lingering, non-repeating nudge**: count only eligible outdated skill names and offer the + same targeted action, which opens the update modal. The toast lingers (no auto-close) until the + user opens the modal or explicitly dismisses it; ignoring it (app quit) records nothing, so a + still-outdated skill may prompt once more next launch. A later inventory that resolves or blocks + the offered tuple retracts the stale toast without recording a dismissal. An outdated name + poisoned by another placement remains visible in the modal's Details but never produces an + unsafe nudge action. Dismissal is recorded per (physical identity, name, bundled revision) only + on explicit dismissal, so a newly outdated official placement or genuinely newer stub revision + may prompt once more. No + toggle — nothing automatic happens that would need one. ### E. Migration (fat → stub) 1. **Implemented, pending release:** from a fresh main-based PR, add authoritative guide sources, generated embedded data, `orca skills list/get`, aliases, generated-output checks, and local/SSH/WSL/dev tests. Keep distributed skills fat and ship this release first. -2. From a separate PR, land slim read-only detection and settings/nudge UI, including the +2. From a separate PR, land slim read-only detection and Skills-page/nudge UI, including the name-scoped targeted update action and the real migration-rail CI. Keep distributed skills fat. 3. Run the pointer-compliance spike against the released guide-serving binary, not a checkout @@ -211,15 +234,17 @@ not thin, until the relevant variant passes. global Windows failures, missing global lock tracking, lossy lock migration, and copy-mode topology changes. The historical-fat → targeted-global-update → stub CI is a release gate, not an early-warning job. Detection always re-checks bytes after the user updates, so a - failed or no-op update re-surfaces `outdated` instead of lying. Choose and document a - validated CLI-version policy before rollout; monitor and contribute upstream fixes. + failed or no-op update re-surfaces `outdated` instead of lying. Minimum validated version is + 1.5.17: 1.5.16 failed the provider-alias convergence contract, while 1.5.17 copy convergence + still varies by environment. CI pins 1.5.17 and probes latest; monitor and contribute upstream + fixes before broadening eligibility. - **Trigger-copy iteration slows.** Improvements to stub descriptions reach existing installs only when users run the npx command. Acceptable at stub-change cadence; the compiled guides (the content that matters) are exempt by construction. - **Multi-file skills.** Current shipped packages are single-file. If a future skill needs scripts/assets, either the binary serves them (`--full` / `--script`) or that skill accepts the fat-file decay model. Decide when it happens. -- **Remote hosts.** Detection ships local-host-only. Stubs make remote *content* a non-issue: +- **Remote hosts.** Detection ships local-host-only. Stubs make remote _content_ a non-issue: SSH/WSL launchers forward to the host's bundled CLI, so the guide matches the command surface that will handle subsequent requests. Remote stub installs can lag on trigger copy, which is the accepted residual. The WSL/SSH reconciler phases of the old design are diff --git a/package.json b/package.json index ca641723c64..292873687f1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.143-rc.0", + "version": "1.4.144-rc.2", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", @@ -11,7 +11,7 @@ "main": "./out/main/index.js", "scripts": { "format": "oxfmt --write .", - "lint": "oxlint && pnpm run lint:switch-exhaustiveness && node config/scripts/check-styled-scrollbars.mjs && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run verify:bundled-skill-guides && pnpm run verify:localization-catalog && pnpm run verify:localization-coverage", + "lint": "oxlint && pnpm run lint:switch-exhaustiveness && node config/scripts/check-styled-scrollbars.mjs && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalog && pnpm run verify:localization-coverage", "lint:react-doctor": "oxlint --config config/oxlint-react-doctor.json", "lint:react-doctor:changed": "node config/scripts/lint-react-doctor-changed.mjs", "lint:switch-exhaustiveness": "oxlint --type-aware --config config/oxlint-switch-exhaustiveness.json src/main src/preload src/shared src/relay src/cli src/renderer/src config tests --quiet", @@ -23,6 +23,8 @@ "check:feature-wall-assets": "node config/scripts/check-feature-wall-assets.mjs", "generate:bundled-skill-guides": "node config/scripts/generate-bundled-skill-guides.mjs --write", "verify:bundled-skill-guides": "node config/scripts/generate-bundled-skill-guides.mjs --check", + "generate:skill-bundle-manifest": "node config/scripts/generate-skill-bundle-manifest.mjs --write", + "verify:skill-bundle-manifest": "node config/scripts/generate-skill-bundle-manifest.mjs", "verify:macos-entitlements": "node config/scripts/verify-macos-entitlements.mjs", "vendor:feature-wall-assets": "node config/scripts/vendor-feature-wall-assets.mjs", "tc:node": "pnpm run typecheck:node", diff --git a/pty-exhaustion-agent-session-leak.md b/pty-exhaustion-agent-session-leak.md deleted file mode 100644 index 64cbb3d93ce..00000000000 --- a/pty-exhaustion-agent-session-leak.md +++ /dev/null @@ -1,178 +0,0 @@ -# Design Doc: PTY Exhaustion from Leaked Agent Sessions - -**Date:** 2026-07-13 -**Status:** Root cause identified; fixes proposed -**Severity:** P0 — machine-wide outage. Once the pty cap is hit, _nothing_ on the host can open a terminal (Orca, Ghostty, IDEs, `ssh`, agent spawns all fail with "cannot allocate pty"). -**Source refs:** file:line references below are against the main checkout at `~/orca/orca` as of 2026-07-13. - ---- - -## 1. Summary - -An Orca desktop instance exhausted the macOS pseudo-terminal limit (`kern.tty.ptmx_max = 511`). Investigation on the live machine found **526 allocated ptys**, of which **~476 belonged to three `Orca Helper` processes** — hundreds of idle `login → zsh → codex resume ` chains, some alive for almost 4 days, all at 0% CPU. Sessions the user had explicitly closed in the UI were still running, and closed sessions kept _coming back_ in bulk waves after restarts. - -The root cause is **two independent defects that compound into a self-perpetuating leak**: - -- **Bug A (teardown):** Closing a session tab never kills the underlying pty. Tab close is pure renderer-state cleanup; the actual `pty.kill` only happens as a side effect of a mounted `TerminalPane` unmounting. For "cold-parked" tabs in background worktrees — whose panes are unmounted by design — closing the tab silently orphans the live process chain. -- **Bug B (resume):** Closing a tab never removes the session's record from the persisted resume list (`sleepingAgentSessionsByPaneKey`), and the record staleness check is a no-op (it compares `capturedAt - updatedAt`, which is always ≈ 0, instead of `now - updatedAt`). Every workspace open / app restart therefore bulk-launches `codex resume` for the entire orphaned backlog — and a periodic capture timer re-persists all of them, so the backlog only grows. - -Every restart makes it worse: Bug B resurrects the dead-but-remembered sessions (each burning a fresh pty), Bug A guarantees closing them doesn't actually free anything, and the capture loop re-persists the ever-larger set. - ---- - -## 2. Observed incident (live-machine evidence) - -Measurements taken 2026-07-13 on the affected desktop: - -| Metric | Value | -| ------------------------------------------------- | ----------------------------------------------------------------------- | -| Allocated ptys (`/dev/ttys*`) | 526 (cap: `kern.tty.ptmx_max = 511`) | -| `login` + `zsh` session pairs | 506 | -| Sessions parented to installed `Orca.app` helpers | 476 (helper pids 53960: **377**, 31662: 58, 1510: 41) | -| Sessions from dev-build Orca instances | ~25 | -| `codex` agent processes | ~280 (~270 **unique** session IDs; a handful resumed 2–3× concurrently) | -| `claude` agent processes | ~38 | -| Processes using >1% CPU | 7 of 763 — everything else fully idle | - -Key observations: - -- **`Orca Helper` pid 53960 alone held 376 open ptmx master fds.** Every leaked chain was still fully parented: `Orca Helper → /usr/bin/login → zsh → node → codex`. Nothing was orphaned to launchd — the app-side master fd was simply never closed. -- **Working directories were overwhelmingly finished PR-review worktrees** (`review-p1-pr-7511-issue-7026` alone had 79 codex processes; `review-p0-pr-8370-issue-8212` had 76; `review-p0-pr-8279-issue-8260` had 67). -- **Start times cluster in same-second waves** — batches on Fri Jul 10 (~15:41–18:14) and large bursts on Sun Jul 12 (14:03, 14:27, 14:36–14:40, 15:09). These are bulk resumes on workspace open / app restart, not user-opened sessions. -- **The user had closed these tabs.** The tabs were gone from the UI; the processes survived (Bug A) and were then re-resumed in later waves (Bug B). -- Nearly all resumed with `codex --dangerously-bypass-approvals-and-sandbox resume ` — i.e., they idle at an interactive prompt and never exit on their own. - ---- - -## 3. Background: pty ownership architecture - -- Terminal sessions are spawned in an Electron utility/helper process which holds the **pty master** (hence the ptmx fds on `Orca Helper`). -- A **daemon** hosts sessions so they survive app restarts for warm reattach: it is forked `detached: true` + `unref()` and deliberately outlives the app (`daemon/daemon-init.ts:306-319`; comment at `:741` — "sessions stay alive for warm reattach"). -- Background (non-active) worktrees are **"cold-parked"**: their `TerminalPane` React components unmount, the transport calls `detach()` (`pty-transport.ts:929` — "keep the PTY exit observer alive"), and pane-less byte watchers observe the still-running pty. -- Agent sessions are additionally tracked in a persisted map, `sleepingAgentSessionsByPaneKey` (`src/renderer/src/store/slices/agent-status.ts:121`), so they can be resumed (`codex resume `) after sleep/quit. - -Keep-alive-on-detach is **intentional**. The defects are in what is supposed to end a session's life. - ---- - -## 4. Bug A: closing a tab never kills the pty - -### The close path contains no kill - -`closeTerminalTab()` (`terminal-tab-actions.ts:152`) → `closeLocalTerminalTabState` → `state.closeTab()` (`store/slices/terminals.ts:1192-1381`). `closeTab` scrubs ~20 per-tab maps — including `delete ptyIdsByTabId[tabId]` (`terminals.ts:1217-1218`) — but issues **no `pty.kill`, no shutdown, no IPC**. It just _forgets the pty id_. - -### The only kill is a React-unmount side effect - -The pty dies only when a _mounted_ `TerminalPane` unmounts with the tab gone: `shouldDetachPaneTransportOnUnmount()` (`use-terminal-pane-lifecycle.ts:456`) returns false → unmount cleanup (`use-terminal-pane-lifecycle.ts:1725-1826`, kill branch at `:1807`) → `transport.destroy()` (`pty-transport.ts:1031`) → `window.api.pty.kill(id)` → main `pty:kill` (`ipc/pty.ts:4953`) → `provider.shutdown(id, {immediate: true})`. - -This works for the **active** worktree. But for a **cold-parked** tab there is no mounted pane; closing it runs `syncParkedTerminalTabWatchers` → `disposeParkedTabWatchers` (`terminal-parked-watcher-registry.ts:53-63`), which stops the watchers and **never kills the pty**. The `login → zsh → codex` chain is orphaned with nothing pointing at it. **This is the zombie source.** - -### Nothing else ever reaps it - -Every existing reaper is scoped to an event _other than_ tab close: - -| Mechanism | Why it doesn't help | -| --------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Daemon `detach` handler | Logging no-op — "full implementation would track tokens" (`daemon/daemon-server.ts:508-512`). No idle timeout exists anywhere. | -| `reapSession` (`daemon/terminal-host.ts:215`) | Fires only on the child's natural exit or explicit kill; an idle interactive `codex` never exits. | -| App-quit `killAllPty()` (`ipc/pty.ts:5191`), reload `killOrphanedPtys` (`ipc/pty.ts:2851`) | Both guarded by `if (localProvider instanceof LocalPtyProvider)` — false for the daemon adapter, so daemon-hosted sessions are excluded _by design_ (warm reattach). | -| Worktree removal (`runtime/worktree-teardown.ts:39`, `killAllProcessesForWorktree`) | Works, but only on full worktree delete. | -| Sleep flow (`sidebar/sleep-worktree-flow.ts:148` → `shutdownWorktreeTerminals`, `terminals.ts:2331`, kill at `:2665`) | Works, but only when the user explicitly sleeps the worktree. | - -### The codebase already knows close ≠ kill - -`kill-all-terminal-surfaces.ts` performs daemon `killAll()`, then `closeTerminalTab({force: true})` per tab, **then an explicit `window.api.pty.kill(ptyId)` loop over `ptyIdsByTabId`** (`:140-147`, `:188-190`) — direct evidence that tab close alone is known not to kill. There is even a manual mop-up UI: **Manage Sessions → "Kill orphan terminals"** for sessions "that have no tab in this Orca instance" (`status-bar/ResourceUsageStatusSegment.tsx:~1114-1155`) — a hand-operated workaround for exactly this leak. - ---- - -## 5. Bug B: closed sessions are resurrected in bulk forever - -### The resume list never learns about tab close - -Agent sessions are recorded in `sleepingAgentSessionsByPaneKey` (`agent-status.ts:121`; record shape in `src/shared/agent-session-resume.ts:38-59`, with `state`, `capturedAt`, `updatedAt`, and `origin: 'worktree-sleep' | 'quit' | 'live'`). Records are written: - -- **Live:** on every non-done agent-status update, an `origin: 'live'` record is captured (`agent-status.ts:1492-1544`). -- **Quit + periodic:** `captureAllSleepingAgentSessions` writes an `origin: 'quit'` record for _every currently-live pane_ (`agent-status.ts:2312-2350`), called on quit (`App.tsx:1367`) **and on a periodic interval** (`App.tsx:1394`). - -`closeTab` (`terminals.ts:1192-1381`) calls `dropAgentStatusByTabPrefix` (`terminals.ts:1894-2010`), which removes the agent-_status_ entry but **never touches `sleepingAgentSessionsByPaneKey`**. There is no concept of "user closed this tab" in the resume store. The orphaned record persists to disk with the workspace session state (`workspace-session-sleeping-agents.ts` → `workspace-session.ts:393`). - -### The staleness gate is a no-op - -On workspace/worktree open, `resumeSleepingAgentSessionsForWorktree` (`resume-sleeping-agent-session.ts:143-218`) launches `codex resume ` (argv built in `agent-session-resume.ts:195-220`; full command in `tui-agent-startup.ts:189-238`; launched via `sleeping-agent-session-launch.ts:65-128`) for every record unless a filter rejects it: - -- `isPassiveCompletedHibernationEvidence` (`sleeping-agent-pane-ownership.ts:16-18`): only skips records that are `done` **and** not `live`/`quit` origin. Orphaned records are `live`/`quit`, so they pass. -- `isInvalidWorktreeActivationRecord` (`resume-sleeping-agent-session.ts:131-141`): rejects records where `state !== 'done' && (capturedAt - updatedAt) > 30 min` (`AGENT_STATUS_STALE_AFTER_MS`, `shared/agent-status-types.ts:256`). **But live records are captured with `capturedAt === updatedAt`** (`agent-status.ts:1501`) and quit records stamp `capturedAt = Date.now()` immediately after the last update — so the difference is always ≈ 0. The check should compare against **`now`**; as written, a record from three days ago is perpetually "fresh." -- `recordPaneIsOwnedByPreservedPane`: skips only if the pane still exists in the restored layout — but the user closed the tab, so it doesn't. - -Result: orphaned records are neither passive, nor stale, nor pane-owned → **all of them get `codex resume`'d**, dozens in the same second. Triggers: folder-workspace open (`worktree-activation.ts:229`), worktree open (`:363`), startup hydration of the active worktree (`Terminal.tsx:1062-1075`), and mobile wake (`wake-sleeping-agents-in-background.ts:165-212`, wired at `useIpcEvents.ts:836`). - -### The feedback loop - -1. Workspace opens → every orphaned record is resumed → N fresh ptys, N idle `codex` processes. -2. Resumed sessions idle at a prompt and never reach `done`, so done-cleanup (`agent-status.ts:1541-1544`) never runs. -3. The periodic/quit capture (`agent-status.ts:2312-2350`) re-persists a record for every one of them. -4. User closes the tabs → Bug A leaves the processes alive; Bug B leaves the records in place. -5. Next open/restart → goto 1, with a strictly larger backlog. - -Distinct historical session IDs produce distinct claim keys, so per-claim dedup doesn't collapse them — which is exactly why one worktree accumulated 79 live codex processes across ~270 unique session IDs machine-wide. - ---- - -## 6. Proposed fixes - -### Fix 1 (primary, Bug A): make tab close actually kill the pty - -In `closeTab` (`terminals.ts:1192`) / `closeTerminalTab` (`terminal-tab-actions.ts:152`), issue `window.api.pty.kill()` for the tab's pty ids **before** `delete ptyIdsByTabId[tabId]`, mirroring the explicit kill loop in `kill-all-terminal-surfaces.ts:140-147`. This closes the parked-pane gap directly: the kill no longer depends on a mounted `TerminalPane` unmounting. Cover the split-pane close paths (`use-terminal-pane-lifecycle.ts`, `TerminalPaneOverlayLayer.tsx`) as well. - -Design decision needed: if warm-reattach semantics should be preserved for _some_ tab closes, distinguish "close tab" (kill) from "sleep/detach" (keep alive) explicitly in the UI action — today the distinction exists in the code (Sleep uses `keepIdentifiers: true`, `sleep-worktree-flow.ts:148`) but plain tab close falls into an unintended third state: forgotten but alive. - -### Fix 2 (primary, Bug B): remove resume records on tab close - -Have `closeTab` (and the split-pane close paths) delete the closed panes' entries from `sleepingAgentSessionsByPaneKey`. A user closing a tab is expressing "I'm done with this session" — it must not remain resumable-by-default. - -### Fix 3 (secondary, Bug B): origin-aware wall-clock expiry - -Do not globally expire resume records by wall time: intentional `worktree-sleep` -checkpoints may remain valid for days. A defensive expiry can apply only to -unowned `live` or `quit` records after their origin and ownership are validated -at every wake entry point. Explicit tab close must delete its records -immediately and remains the primary fix. - -### Fix 4 (secondary, Bug A): daemon-side reaper - -Implement the daemon `detach` handler (`daemon-server.ts:508-512`) and an idle reaper for sessions that are detached with **no owning tab**. The daemon deliberately outlives the app and is excluded from every `instanceof LocalPtyProvider` sweep, so it needs its own lifecycle policy — otherwise any future client-side bookkeeping bug leaks forever again. - -### Defense in depth - -- Surface a pty-pressure warning (the data already exists for the "Kill orphan terminals" feature) when app-owned ptys exceed a threshold (e.g. 300), since exhaustion is a machine-wide outage. -- The `--dangerously-bypass-approvals-and-sandbox` resume flag amplifies blast radius: hundreds of unattended, sandbox-less agents idling for days. Consider whether bulk background resume should ever use it. - ---- - -## 7. Verification plan - -1. **Repro (pre-fix):** in a background worktree, open an agent session, switch to another worktree (parking the pane), close the tab → `ps` shows the `login/zsh/codex` chain still alive and the helper still holds its ptmx fd. Restart Orca → the session is resumed again. -2. **Post-fix:** same steps → chain dies on tab close (Fix 1); record removed so restart resumes nothing (Fix 2). Origin-aware defensive expiry is verified separately when implemented (Fix 3). -3. **Regression:** worktree Sleep → wake still resumes sessions; quit → relaunch still warm-reattaches sessions whose tabs were _not_ closed; daemon warm-reattach unaffected. -4. **Soak:** count `Orca Helper` ptmx fds (`lsof -p | grep -c ptmx`) across a day of open/close/restart cycles — should stay flat. - -## 8. Immediate mitigation (ops, no code) - -- Use **Manage Sessions → "Kill orphan terminals"** or exact-session termination - to release the leaked ptys. A normal Orca.app restart is insufficient because - the persistent terminal daemon deliberately survives for warm reattach. -- `sudo sysctl kern.tty.ptmx_max=999` raises the cap to its macOS hard max — buys headroom only, hours at current leak rate. -- Restart the terminal daemon only when exact-session cleanup is unavailable; - this terminates all daemon-backed sessions, including legitimate in-flight work. - ---- - -## Appendix: diagnostic commands used - -```sh -ls /dev/ttys* | wc -l && sysctl kern.tty.ptmx_max # allocation vs cap -ps -ax -o pid,tty,etime,comm | grep -E 'ttys[0-9]+' # who holds ptys -lsof -a -d cwd -c codex | awk '{print $NF}' | sort | uniq -c | sort -rn # per-worktree counts -lsof -p | grep -c ptmx # master fds per Orca Helper -ps -ax -o args | grep -oE '[0-9a-f-]{36}' | sort | uniq -c # duplicate session resumes -``` diff --git a/resources/skills/current-manifest.json b/resources/skills/current-manifest.json new file mode 100644 index 00000000000..fca1162fa5e --- /dev/null +++ b/resources/skills/current-manifest.json @@ -0,0 +1,158 @@ +{ + "schemaVersion": 1, + "appVersion": "1.4.144-rc.1", + "skills": [ + { + "name": "computer-use", + "sourcePath": "skills/computer-use", + "appVersion": "1.4.144-rc.1", + "releaseRevision": 5, + "packageDigest": "cd2809474d57fd7277adb277448e6fa446810d3cbad71ac0b473b9e8ff1bad68", + "gitTreeSha": "306c0f8cb63bcac265a5b7975dc2f855be4f1344", + "files": [ + { + "path": "SKILL.md", + "size": 11241, + "executable": false, + "classification": "text", + "exactSha256": "f49b29fb6b209956907688692387adcdc509fad344555f09badaf383106f5f39", + "textNormalizedSha256": "f49b29fb6b209956907688692387adcdc509fad344555f09badaf383106f5f39", + "identitySha256": "f49b29fb6b209956907688692387adcdc509fad344555f09badaf383106f5f39" + } + ] + }, + { + "name": "linear-tickets", + "sourcePath": "skills/linear-tickets", + "appVersion": "1.4.144-rc.1", + "releaseRevision": 4, + "packageDigest": "f198d7b22e5ee1673dac403f9cca0553b124e0a90e4fdd05d2c23b7344e32d2b", + "gitTreeSha": "de9fc106bbb4e313a90ff9a9513a720909bbd176", + "files": [ + { + "path": "SKILL.md", + "size": 10596, + "executable": false, + "classification": "text", + "exactSha256": "0c3077c93328b9965430cd6951f1a35889b8c0775037870ea3a8bcf303d9d2c5", + "textNormalizedSha256": "0c3077c93328b9965430cd6951f1a35889b8c0775037870ea3a8bcf303d9d2c5", + "identitySha256": "0c3077c93328b9965430cd6951f1a35889b8c0775037870ea3a8bcf303d9d2c5" + } + ] + }, + { + "name": "orca-cli", + "sourcePath": "skills/orca-cli", + "appVersion": "1.4.144-rc.1", + "releaseRevision": 32, + "packageDigest": "51740ff13f379ac5743d3fd28a14b17168dcef40f7048c20182dce166098c45f", + "gitTreeSha": "ded93000a5f654e2b4f324501282459bd56afe19", + "files": [ + { + "path": "SKILL.md", + "size": 21557, + "executable": false, + "classification": "text", + "exactSha256": "b4c36e19fc158fc8c286bdfdf05a537985cb2159a596c93862968c5417bf15be", + "textNormalizedSha256": "b4c36e19fc158fc8c286bdfdf05a537985cb2159a596c93862968c5417bf15be", + "identitySha256": "b4c36e19fc158fc8c286bdfdf05a537985cb2159a596c93862968c5417bf15be" + } + ] + }, + { + "name": "orca-emulator", + "sourcePath": "skills/orca-emulator", + "appVersion": "1.4.144-rc.1", + "releaseRevision": 4, + "packageDigest": "453b1d9aa20b51b8a4d32c7b6def6a93f7ef9c730de32abbcbc1788ad1b1820b", + "gitTreeSha": "66be6abe99f1807da85934aee0e22daefc8f7656", + "files": [ + { + "path": "SKILL.md", + "size": 11527, + "executable": false, + "classification": "text", + "exactSha256": "84dbfacf6854874e369840c011e78603e533273fb848d21dac3cfb08e0346429", + "textNormalizedSha256": "84dbfacf6854874e369840c011e78603e533273fb848d21dac3cfb08e0346429", + "identitySha256": "84dbfacf6854874e369840c011e78603e533273fb848d21dac3cfb08e0346429" + } + ] + }, + { + "name": "orca-emulator-android", + "sourcePath": "skills/orca-emulator-android", + "appVersion": "1.4.144-rc.1", + "releaseRevision": 2, + "packageDigest": "12272cf82e0731f11e424822b961882457034e730358cc65ea28e4eb9c8ff7f5", + "gitTreeSha": "f7b0fc8cbf5cd78ca5156f6bbe3a20f1462d8f83", + "files": [ + { + "path": "SKILL.md", + "size": 8886, + "executable": false, + "classification": "text", + "exactSha256": "1035d4db357923e98d5075c0c21bc9995b00a36a3739543516fe45ae5ded0332", + "textNormalizedSha256": "1035d4db357923e98d5075c0c21bc9995b00a36a3739543516fe45ae5ded0332", + "identitySha256": "1035d4db357923e98d5075c0c21bc9995b00a36a3739543516fe45ae5ded0332" + } + ] + }, + { + "name": "orca-linear", + "sourcePath": "skills/orca-linear", + "appVersion": "1.4.144-rc.1", + "releaseRevision": 2, + "packageDigest": "d44d09e6ecb6a64da177083aad26a95f031cd1cf26ba059fdc888c2628aef64f", + "gitTreeSha": "c34f42030f43e5a85737996fa375bbd79cb5bea8", + "files": [ + { + "path": "SKILL.md", + "size": 10320, + "executable": false, + "classification": "text", + "exactSha256": "48ded55ec3842ce65105e6db7adf9bc9ed263ece08555cec056e68e90321c3d5", + "textNormalizedSha256": "48ded55ec3842ce65105e6db7adf9bc9ed263ece08555cec056e68e90321c3d5", + "identitySha256": "48ded55ec3842ce65105e6db7adf9bc9ed263ece08555cec056e68e90321c3d5" + } + ] + }, + { + "name": "orca-per-workspace-env", + "sourcePath": "skills/orca-per-workspace-env", + "appVersion": "1.4.144-rc.1", + "releaseRevision": 2, + "packageDigest": "fa3b65a1a107fca3f0375c696852477b62f58c154b9eb5c0663c41edc4bcd30d", + "gitTreeSha": "354e775b79ea6952ec63acac4d3ee8a9ae07a650", + "files": [ + { + "path": "SKILL.md", + "size": 43769, + "executable": false, + "classification": "text", + "exactSha256": "58e479bd18c4c553df0dfcb408eece2fbe550a0f9688bc289414420f72ed7ea7", + "textNormalizedSha256": "58e479bd18c4c553df0dfcb408eece2fbe550a0f9688bc289414420f72ed7ea7", + "identitySha256": "58e479bd18c4c553df0dfcb408eece2fbe550a0f9688bc289414420f72ed7ea7" + } + ] + }, + { + "name": "orchestration", + "sourcePath": "skills/orchestration", + "appVersion": "1.4.144-rc.1", + "releaseRevision": 24, + "packageDigest": "9fbfa2ae3f3f99441563a4b8b1c6302107944480db8718ddb326a862a51f7ab9", + "gitTreeSha": "086c41e0b353b4908d2963694b4a7c791d4b3982", + "files": [ + { + "path": "SKILL.md", + "size": 22861, + "executable": false, + "classification": "text", + "exactSha256": "0cb898d37560ab7ac4cb477a2d7e8ba3e593d45affd6d7e9f99151879aaeb1a5", + "textNormalizedSha256": "0cb898d37560ab7ac4cb477a2d7e8ba3e593d45affd6d7e9f99151879aaeb1a5", + "identitySha256": "0cb898d37560ab7ac4cb477a2d7e8ba3e593d45affd6d7e9f99151879aaeb1a5" + } + ] + } + ] +} diff --git a/resources/skills/release-mapping.json b/resources/skills/release-mapping.json new file mode 100644 index 00000000000..12baa4b6e7f --- /dev/null +++ b/resources/skills/release-mapping.json @@ -0,0 +1,501 @@ +{ + "schemaVersion": 1, + "releases": [ + { + "appVersion": "1.0.81", + "skills": { + "orca-cli": 1 + } + }, + { + "appVersion": "1.0.86", + "skills": { + "orca-cli": 2 + } + }, + { + "appVersion": "1.0.94-rc.0", + "skills": { + "orca-cli": 3 + } + }, + { + "appVersion": "1.3.8-rc.1", + "skills": { + "orca-cli": 4 + } + }, + { + "appVersion": "1.3.12-rc.4", + "skills": { + "orca-cli": 5 + } + }, + { + "appVersion": "1.3.24-rc.2", + "skills": { + "orca-cli": 6, + "orchestration": 1 + } + }, + { + "appVersion": "1.3.33-rc.3", + "skills": { + "mobile-fit-debug": 1, + "orca-cli": 6, + "orchestration": 1 + } + }, + { + "appVersion": "0.0.1", + "skills": { + "orca-cli": 6, + "orchestration": 1 + } + }, + { + "appVersion": "1.3.35-rc.0", + "skills": { + "mobile-fit-debug": 1, + "orca-cli": 6, + "orchestration": 1 + } + }, + { + "appVersion": "1.3.38-rc.3", + "skills": { + "orca-cli": 6, + "orchestration": 1 + } + }, + { + "appVersion": "1.3.38", + "skills": { + "mobile-fit-debug": 1, + "orca-cli": 6, + "orchestration": 1 + } + }, + { + "appVersion": "1.3.39", + "skills": { + "orca-cli": 6, + "orchestration": 1 + } + }, + { + "appVersion": "1.3.46-rc.3", + "skills": { + "orca-cli": 7, + "orchestration": 2 + } + }, + { + "appVersion": "1.3.47-rc.0", + "skills": { + "orca-cli": 8, + "orchestration": 3 + } + }, + { + "appVersion": "1.3.49-rc.0", + "skills": { + "computer-use": 1, + "orca-cli": 8, + "orchestration": 3 + } + }, + { + "appVersion": "1.4.2-rc.10", + "skills": { + "computer-use": 1, + "orca-cli": 9, + "orchestration": 4 + } + }, + { + "appVersion": "1.4.2", + "skills": { + "computer-use": 1, + "orca-cli": 10, + "orchestration": 5 + } + }, + { + "appVersion": "1.4.3-rc.0", + "skills": { + "computer-use": 1, + "orca-cli": 11, + "orchestration": 6 + } + }, + { + "appVersion": "1.4.9-rc.1", + "skills": { + "computer-use": 1, + "orca-cli": 12, + "orchestration": 6 + } + }, + { + "appVersion": "1.4.10-rc.3", + "skills": { + "computer-use": 1, + "orca-cli": 13, + "orchestration": 6 + } + }, + { + "appVersion": "1.4.11-rc.0", + "skills": { + "computer-use": 1, + "orca-cli": 14, + "orchestration": 6 + } + }, + { + "appVersion": "1.4.18-rc.2", + "skills": { + "computer-use": 1, + "orca-cli": 15, + "orchestration": 6 + } + }, + { + "appVersion": "1.4.18-rc.3", + "skills": { + "computer-use": 1, + "orca-cli": 16, + "orchestration": 6 + } + }, + { + "appVersion": "1.4.22", + "skills": { + "computer-use": 1, + "orca-cli": 16, + "orchestration": 7 + } + }, + { + "appVersion": "1.4.36-rc.2", + "skills": { + "computer-use": 1, + "orca-cli": 17, + "orchestration": 8 + } + }, + { + "appVersion": "1.4.36-rc.6", + "skills": { + "computer-use": 1, + "orca-cli": 18, + "orchestration": 9 + } + }, + { + "appVersion": "1.4.42-rc.8", + "skills": { + "computer-use": 2, + "orca-cli": 19, + "orchestration": 10 + } + }, + { + "appVersion": "1.4.46-rc.0", + "skills": { + "computer-use": 2, + "orca-cli": 19, + "orchestration": 11 + } + }, + { + "appVersion": "1.4.51-rc.8", + "skills": { + "computer-use": 3, + "orca-cli": 19, + "orchestration": 11 + } + }, + { + "appVersion": "1.4.51-rc.9", + "skills": { + "computer-use": 3, + "orca-cli": 20, + "orca-emulator": 1, + "orchestration": 12 + } + }, + { + "appVersion": "1.4.65-rc.3", + "skills": { + "computer-use": 3, + "linear-tickets": 1, + "orca-cli": 20, + "orca-emulator": 1, + "orchestration": 12 + } + }, + { + "appVersion": "1.4.65", + "skills": { + "computer-use": 3, + "orca-cli": 20, + "orca-emulator": 1, + "orchestration": 12 + } + }, + { + "appVersion": "1.4.66-rc.0", + "skills": { + "computer-use": 3, + "linear-tickets": 1, + "orca-cli": 20, + "orca-emulator": 1, + "orchestration": 12 + } + }, + { + "appVersion": "1.4.67-rc.0", + "skills": { + "computer-use": 3, + "linear-tickets": 2, + "orca-cli": 20, + "orca-emulator": 1, + "orchestration": 12 + } + }, + { + "appVersion": "1.4.68", + "skills": { + "computer-use": 3, + "linear-tickets": 2, + "orca-cli": 20, + "orca-emulator": 1, + "orchestration": 13 + } + }, + { + "appVersion": "1.4.74-rc.0", + "skills": { + "computer-use": 3, + "linear-tickets": 2, + "orca-cli": 21, + "orca-emulator": 1, + "orchestration": 14 + } + }, + { + "appVersion": "1.4.78-rc.1", + "skills": { + "computer-use": 3, + "linear-tickets": 2, + "orca-cli": 22, + "orca-emulator": 1, + "orchestration": 15 + } + }, + { + "appVersion": "1.4.78-rc.2", + "skills": { + "computer-use": 3, + "linear-tickets": 2, + "orca-cli": 23, + "orca-emulator": 1, + "orchestration": 16 + } + }, + { + "appVersion": "1.4.81-rc.2", + "skills": { + "computer-use": 3, + "linear-tickets": 2, + "orca-cli": 24, + "orca-emulator": 1, + "orchestration": 16 + } + }, + { + "appVersion": "1.4.81-rc.3", + "skills": { + "computer-use": 3, + "linear-tickets": 2, + "orca-cli": 25, + "orca-emulator": 1, + "orchestration": 16 + } + }, + { + "appVersion": "1.4.88", + "skills": { + "computer-use": 3, + "linear-tickets": 2, + "orca-cli": 26, + "orca-emulator": 1, + "orchestration": 17 + } + }, + { + "appVersion": "1.4.90", + "skills": { + "computer-use": 3, + "linear-tickets": 3, + "orca-cli": 26, + "orca-emulator": 1, + "orca-linear": 1, + "orchestration": 17 + } + }, + { + "appVersion": "1.4.92-rc.1", + "skills": { + "computer-use": 3, + "linear-tickets": 3, + "orca-cli": 27, + "orca-emulator": 1, + "orca-linear": 1, + "orchestration": 17 + } + }, + { + "appVersion": "1.4.96-rc.1", + "skills": { + "computer-use": 3, + "linear-tickets": 3, + "orca-cli": 28, + "orca-emulator": 1, + "orca-linear": 1, + "orchestration": 17 + } + }, + { + "appVersion": "1.4.105-rc.2", + "skills": { + "computer-use": 3, + "linear-tickets": 3, + "orca-cli": 29, + "orca-emulator": 2, + "orca-emulator-android": 1, + "orca-linear": 1, + "orchestration": 18 + } + }, + { + "appVersion": "1.4.111-rc.0", + "skills": { + "computer-use": 3, + "linear-tickets": 3, + "orca-cli": 29, + "orca-emulator": 2, + "orca-emulator-android": 1, + "orca-linear": 1, + "orca-per-workspace-env": 1, + "orchestration": 18 + } + }, + { + "appVersion": "1.4.124-rc.6", + "skills": { + "computer-use": 3, + "linear-tickets": 3, + "orca-cli": 29, + "orca-emulator": 2, + "orca-emulator-android": 1, + "orca-linear": 1, + "orca-per-workspace-env": 2, + "orchestration": 18 + } + }, + { + "appVersion": "1.4.124-rc.9", + "skills": { + "computer-use": 3, + "linear-tickets": 3, + "orca-cli": 29, + "orca-emulator": 2, + "orca-emulator-android": 1, + "orca-linear": 1, + "orca-per-workspace-env": 2, + "orchestration": 19 + } + }, + { + "appVersion": "1.4.124", + "skills": { + "computer-use": 3, + "linear-tickets": 4, + "orca-cli": 29, + "orca-emulator": 2, + "orca-emulator-android": 1, + "orca-linear": 2, + "orca-per-workspace-env": 2, + "orchestration": 20 + } + }, + { + "appVersion": "1.4.137-rc.1", + "skills": { + "computer-use": 3, + "linear-tickets": 4, + "orca-cli": 30, + "orca-emulator": 2, + "orca-emulator-android": 1, + "orca-linear": 2, + "orca-per-workspace-env": 2, + "orchestration": 21 + } + }, + { + "appVersion": "1.4.138-rc.0", + "skills": { + "computer-use": 4, + "linear-tickets": 4, + "orca-cli": 31, + "orca-emulator": 3, + "orca-emulator-android": 1, + "orca-linear": 2, + "orca-per-workspace-env": 2, + "orchestration": 22 + } + }, + { + "appVersion": "1.4.138-rc.3", + "skills": { + "computer-use": 4, + "linear-tickets": 4, + "orca-cli": 31, + "orca-emulator": 3, + "orca-emulator-android": 1, + "orca-linear": 2, + "orca-per-workspace-env": 2, + "orchestration": 23 + } + }, + { + "appVersion": "1.4.138-rc.4", + "skills": { + "computer-use": 4, + "linear-tickets": 4, + "orca-cli": 31, + "orca-emulator": 3, + "orca-emulator-android": 1, + "orca-linear": 2, + "orca-per-workspace-env": 2, + "orchestration": 24 + } + }, + { + "appVersion": "1.4.141", + "skills": { + "computer-use": 5, + "linear-tickets": 4, + "orca-cli": 32, + "orca-emulator": 4, + "orca-emulator-android": 2, + "orca-linear": 2, + "orca-per-workspace-env": 2, + "orchestration": 24 + } + } + ] +} diff --git a/resources/skills/snapshot-registry.json b/resources/skills/snapshot-registry.json new file mode 100644 index 00000000000..58d6c7f6c0e --- /dev/null +++ b/resources/skills/snapshot-registry.json @@ -0,0 +1,1239 @@ +{ + "schemaVersion": 1, + "skills": { + "orca-cli": [ + { + "releaseRevision": 1, + "packageDigest": "a3831a8a5561b24f085cb3c185c23ccc368970898119d90723aa3406778aecb6", + "gitTreeSha": "aed90e508a1acac62d3f22ceba3afa5489cffb84", + "files": [ + { + "path": "SKILL.md", + "size": 5547, + "executable": false, + "classification": "text", + "exactSha256": "5a3111c5a5b0edcf53217a3b93a9dafd9ee6dbc77eaa78ab850e3706d27fb3b1", + "textNormalizedSha256": "5a3111c5a5b0edcf53217a3b93a9dafd9ee6dbc77eaa78ab850e3706d27fb3b1", + "identitySha256": "5a3111c5a5b0edcf53217a3b93a9dafd9ee6dbc77eaa78ab850e3706d27fb3b1" + } + ] + }, + { + "releaseRevision": 2, + "packageDigest": "e0ed74622f5f77a6339f6f2632dc6edb0b724c7f69a4d2ffecaa45bd0995865a", + "gitTreeSha": "d9d879b3040843ae434462db46939b1eac410304", + "files": [ + { + "path": "SKILL.md", + "size": 5850, + "executable": false, + "classification": "text", + "exactSha256": "6f85d7a0d4dde6bf8310d1db6373865143053aa97fbe69f7a3f96fc2be697aeb", + "textNormalizedSha256": "6f85d7a0d4dde6bf8310d1db6373865143053aa97fbe69f7a3f96fc2be697aeb", + "identitySha256": "6f85d7a0d4dde6bf8310d1db6373865143053aa97fbe69f7a3f96fc2be697aeb" + } + ] + }, + { + "releaseRevision": 3, + "packageDigest": "0032941edd38a56de97a56f50c7688e38bcb566735310a9321bb8a7959a06878", + "gitTreeSha": "57ab93e99a12dc947d4329ef7d1227d24bf3dd26", + "files": [ + { + "path": "SKILL.md", + "size": 8871, + "executable": false, + "classification": "text", + "exactSha256": "3637f50e20b0305d1b5b5962d117f121bd2323651023e74c6dfd1a2aa170f681", + "textNormalizedSha256": "3637f50e20b0305d1b5b5962d117f121bd2323651023e74c6dfd1a2aa170f681", + "identitySha256": "3637f50e20b0305d1b5b5962d117f121bd2323651023e74c6dfd1a2aa170f681" + } + ] + }, + { + "releaseRevision": 4, + "packageDigest": "d30e6b53ff35008ffe467e2557326326575ac5d37fab88019b1d8c2d6fb8eeba", + "gitTreeSha": "f43280abdf0e484966ce840600a6cae4963f9d64", + "files": [ + { + "path": "SKILL.md", + "size": 24622, + "executable": false, + "classification": "text", + "exactSha256": "baa87d5188d2cab8f0ffcfa694fdcbd6ca745c4b0690851241bfd81c2b038e56", + "textNormalizedSha256": "baa87d5188d2cab8f0ffcfa694fdcbd6ca745c4b0690851241bfd81c2b038e56", + "identitySha256": "baa87d5188d2cab8f0ffcfa694fdcbd6ca745c4b0690851241bfd81c2b038e56" + } + ] + }, + { + "releaseRevision": 5, + "packageDigest": "1f2ae2e3922a93e020feebfa799b993557fead2e8c83ce03b5646767ea354f19", + "gitTreeSha": "f8a840864ad01f1c3892a1bf05fb699c5f4543e5", + "files": [ + { + "path": "SKILL.md", + "size": 26576, + "executable": false, + "classification": "text", + "exactSha256": "5f7a6cdad62ebee25adad2a9e76d9623bee690846eaf8c7e1739b27c4a9e0efc", + "textNormalizedSha256": "5f7a6cdad62ebee25adad2a9e76d9623bee690846eaf8c7e1739b27c4a9e0efc", + "identitySha256": "5f7a6cdad62ebee25adad2a9e76d9623bee690846eaf8c7e1739b27c4a9e0efc" + } + ] + }, + { + "releaseRevision": 6, + "packageDigest": "6e77196af8e6b9123861d7f09c0727e3f982722940eaff2ca53521d76134362f", + "gitTreeSha": "35f50f11a68d2f8f532df28d677ba1b0bc71b740", + "files": [ + { + "path": "SKILL.md", + "size": 26970, + "executable": false, + "classification": "text", + "exactSha256": "640374995d830fd3607e3b162d7bf03237b8ee9afb846b8a753e45c91c8e322a", + "textNormalizedSha256": "640374995d830fd3607e3b162d7bf03237b8ee9afb846b8a753e45c91c8e322a", + "identitySha256": "640374995d830fd3607e3b162d7bf03237b8ee9afb846b8a753e45c91c8e322a" + } + ] + }, + { + "releaseRevision": 7, + "packageDigest": "d15b79aee7d15f1cc089467284f2454a8ad4f351c08e1e3758e8c58fdcf07b02", + "gitTreeSha": "d7788dc3cd1f1ed705981299c7b8ff9b26cdd5e6", + "files": [ + { + "path": "SKILL.md", + "size": 27011, + "executable": false, + "classification": "text", + "exactSha256": "28105318e676de18e27389002fdbe1b33c3c27f8c001ba82d769cef4adde1ef1", + "textNormalizedSha256": "28105318e676de18e27389002fdbe1b33c3c27f8c001ba82d769cef4adde1ef1", + "identitySha256": "28105318e676de18e27389002fdbe1b33c3c27f8c001ba82d769cef4adde1ef1" + } + ] + }, + { + "releaseRevision": 8, + "packageDigest": "6821844c4336d2673c85c968efd8cab14b4e34b8c798e4f11d6afb67b65f12b8", + "gitTreeSha": "a0f98aea5ecdafae6dff25d31d0925a4affae774", + "files": [ + { + "path": "SKILL.md", + "size": 27038, + "executable": false, + "classification": "text", + "exactSha256": "92f645b9db8bef6eb1278e87622cb18d5e146254039fc927a1f37313f32bf347", + "textNormalizedSha256": "92f645b9db8bef6eb1278e87622cb18d5e146254039fc927a1f37313f32bf347", + "identitySha256": "92f645b9db8bef6eb1278e87622cb18d5e146254039fc927a1f37313f32bf347" + } + ] + }, + { + "releaseRevision": 9, + "packageDigest": "7486314542c03f682e7640ad61825cc709004e8c076c2971f707bdf839018233", + "gitTreeSha": "c556268a21e785b01be2dfb7cd0988100c7d7cde", + "files": [ + { + "path": "SKILL.md", + "size": 27197, + "executable": false, + "classification": "text", + "exactSha256": "80f8b98301c0d4c0cce6d522d56ae248312d6a386a17b275078c618cc7ddaa5e", + "textNormalizedSha256": "80f8b98301c0d4c0cce6d522d56ae248312d6a386a17b275078c618cc7ddaa5e", + "identitySha256": "80f8b98301c0d4c0cce6d522d56ae248312d6a386a17b275078c618cc7ddaa5e" + } + ] + }, + { + "releaseRevision": 10, + "packageDigest": "6821844c4336d2673c85c968efd8cab14b4e34b8c798e4f11d6afb67b65f12b8", + "gitTreeSha": "a0f98aea5ecdafae6dff25d31d0925a4affae774", + "files": [ + { + "path": "SKILL.md", + "size": 27038, + "executable": false, + "classification": "text", + "exactSha256": "92f645b9db8bef6eb1278e87622cb18d5e146254039fc927a1f37313f32bf347", + "textNormalizedSha256": "92f645b9db8bef6eb1278e87622cb18d5e146254039fc927a1f37313f32bf347", + "identitySha256": "92f645b9db8bef6eb1278e87622cb18d5e146254039fc927a1f37313f32bf347" + } + ] + }, + { + "releaseRevision": 11, + "packageDigest": "7486314542c03f682e7640ad61825cc709004e8c076c2971f707bdf839018233", + "gitTreeSha": "c556268a21e785b01be2dfb7cd0988100c7d7cde", + "files": [ + { + "path": "SKILL.md", + "size": 27197, + "executable": false, + "classification": "text", + "exactSha256": "80f8b98301c0d4c0cce6d522d56ae248312d6a386a17b275078c618cc7ddaa5e", + "textNormalizedSha256": "80f8b98301c0d4c0cce6d522d56ae248312d6a386a17b275078c618cc7ddaa5e", + "identitySha256": "80f8b98301c0d4c0cce6d522d56ae248312d6a386a17b275078c618cc7ddaa5e" + } + ] + }, + { + "releaseRevision": 12, + "packageDigest": "962904e301eef6917af25eccbfe74ab8a119503749030e44c67fd34415d85002", + "gitTreeSha": "4fde9a02a5a77e7ff1a47c04819b764378557a28", + "files": [ + { + "path": "SKILL.md", + "size": 28908, + "executable": false, + "classification": "text", + "exactSha256": "f3e3112e666d3735acac3e41b768fcb7be3e2daf692e8c03b481a50798783137", + "textNormalizedSha256": "f3e3112e666d3735acac3e41b768fcb7be3e2daf692e8c03b481a50798783137", + "identitySha256": "f3e3112e666d3735acac3e41b768fcb7be3e2daf692e8c03b481a50798783137" + } + ] + }, + { + "releaseRevision": 13, + "packageDigest": "d73f45feb6ccf88947aba3095e9ae36c2c02e9fb4dfeaba67f3f8e3af1cb6f74", + "gitTreeSha": "b5c98b55e0b10e4525d63f5a8df3bc95b62158e6", + "files": [ + { + "path": "SKILL.md", + "size": 30358, + "executable": false, + "classification": "text", + "exactSha256": "5d5a5c630b581fe2e3ae047baddd795434dbb3ecb4ff6e1384fcd60ead665d07", + "textNormalizedSha256": "5d5a5c630b581fe2e3ae047baddd795434dbb3ecb4ff6e1384fcd60ead665d07", + "identitySha256": "5d5a5c630b581fe2e3ae047baddd795434dbb3ecb4ff6e1384fcd60ead665d07" + } + ] + }, + { + "releaseRevision": 14, + "packageDigest": "d1adae602b254aa2de6d65d09066ca1dcd9e89685bc1ba7c72e9964beb3f6539", + "gitTreeSha": "b545ca22a27b8cfff80fd2d33c77db8b03efd7ed", + "files": [ + { + "path": "SKILL.md", + "size": 30787, + "executable": false, + "classification": "text", + "exactSha256": "41451300db3ebaea51931b325aea20195923d631125588614fd1be5ebcfdcdc1", + "textNormalizedSha256": "41451300db3ebaea51931b325aea20195923d631125588614fd1be5ebcfdcdc1", + "identitySha256": "41451300db3ebaea51931b325aea20195923d631125588614fd1be5ebcfdcdc1" + } + ] + }, + { + "releaseRevision": 15, + "packageDigest": "23a6bb7a6069a8c4d6506aaa688a44c8421699e3575bb8c73185561579c1f623", + "gitTreeSha": "8b7caa9655971745a8ea782f6d05d76ab0f3812c", + "files": [ + { + "path": "SKILL.md", + "size": 31985, + "executable": false, + "classification": "text", + "exactSha256": "2ada72dac2e503723f3046433995756549fd1e44d445ec4d13c8f01764e6304d", + "textNormalizedSha256": "2ada72dac2e503723f3046433995756549fd1e44d445ec4d13c8f01764e6304d", + "identitySha256": "2ada72dac2e503723f3046433995756549fd1e44d445ec4d13c8f01764e6304d" + } + ] + }, + { + "releaseRevision": 16, + "packageDigest": "c882bcd1293ef6f601e7f48199f6e755692f668419f8c2098e00757d9349d557", + "gitTreeSha": "22de521546df142d7fd540dd3a329462c548e86b", + "files": [ + { + "path": "SKILL.md", + "size": 32276, + "executable": false, + "classification": "text", + "exactSha256": "7ef384a2687f655acd0cc40be40ea5a7df46a0e6d6bdb1ea3d43d9d7ae25778c", + "textNormalizedSha256": "7ef384a2687f655acd0cc40be40ea5a7df46a0e6d6bdb1ea3d43d9d7ae25778c", + "identitySha256": "7ef384a2687f655acd0cc40be40ea5a7df46a0e6d6bdb1ea3d43d9d7ae25778c" + } + ] + }, + { + "releaseRevision": 17, + "packageDigest": "a1bfdab91a68b3441b548de158e2c6a56452a499600625c6a9a3523d325837ec", + "gitTreeSha": "c694b183230bface19ef8ec9218726f82e391f69", + "files": [ + { + "path": "SKILL.md", + "size": 32853, + "executable": false, + "classification": "text", + "exactSha256": "ad2bbe6f75cc98ce09781ed1345f09d1fc3f2042c4f9a5c5f75032ca149a2672", + "textNormalizedSha256": "ad2bbe6f75cc98ce09781ed1345f09d1fc3f2042c4f9a5c5f75032ca149a2672", + "identitySha256": "ad2bbe6f75cc98ce09781ed1345f09d1fc3f2042c4f9a5c5f75032ca149a2672" + } + ] + }, + { + "releaseRevision": 18, + "packageDigest": "afb5026bf156e8d411898ecc84c48348010129558499a18240194b0d5533028c", + "gitTreeSha": "3b9164e0fba50923c9ce3a4a298a9c8b81677291", + "files": [ + { + "path": "SKILL.md", + "size": 33310, + "executable": false, + "classification": "text", + "exactSha256": "320539431c03434355a2186095f782444a4dbec5adbcb8560cebcea3ed49fe1f", + "textNormalizedSha256": "320539431c03434355a2186095f782444a4dbec5adbcb8560cebcea3ed49fe1f", + "identitySha256": "320539431c03434355a2186095f782444a4dbec5adbcb8560cebcea3ed49fe1f" + } + ] + }, + { + "releaseRevision": 19, + "packageDigest": "2c07a8155f0a0f3fdc6a038c25e0758486dc2ce19d8e0f5bb19d4722f6acbae7", + "gitTreeSha": "dc8ef30fef07f81167e948a9bfc90d14670ea10c", + "files": [ + { + "path": "SKILL.md", + "size": 11055, + "executable": false, + "classification": "text", + "exactSha256": "9d10496ccc998f58472e902b4131910d7bde48f305f70a400c211deecd670c0b", + "textNormalizedSha256": "9d10496ccc998f58472e902b4131910d7bde48f305f70a400c211deecd670c0b", + "identitySha256": "9d10496ccc998f58472e902b4131910d7bde48f305f70a400c211deecd670c0b" + } + ] + }, + { + "releaseRevision": 20, + "packageDigest": "6f57bb7833fd4e6acf743921ca25fd57bb51df76caa8ea2aea98c149e20f22e1", + "gitTreeSha": "535bf7b62e1e374adb380e16f837a3c575e302d4", + "files": [ + { + "path": "SKILL.md", + "size": 13423, + "executable": false, + "classification": "text", + "exactSha256": "aa9307c3db1e8d96870955fa2f6260b220e840401ce7ea9c034021658601a3ea", + "textNormalizedSha256": "aa9307c3db1e8d96870955fa2f6260b220e840401ce7ea9c034021658601a3ea", + "identitySha256": "aa9307c3db1e8d96870955fa2f6260b220e840401ce7ea9c034021658601a3ea" + } + ] + }, + { + "releaseRevision": 21, + "packageDigest": "ce5459d22254faf23a3af527a22fa8981f1c3f88afe48487a9fd79c44d0729b9", + "gitTreeSha": "de4999bb21e7009bf1e453d593e7726d917d3fc4", + "files": [ + { + "path": "SKILL.md", + "size": 13764, + "executable": false, + "classification": "text", + "exactSha256": "879f328595d4148a5b0dba9dd3cc31c4d30740cb574d754f38ee82f9d1cc782c", + "textNormalizedSha256": "879f328595d4148a5b0dba9dd3cc31c4d30740cb574d754f38ee82f9d1cc782c", + "identitySha256": "879f328595d4148a5b0dba9dd3cc31c4d30740cb574d754f38ee82f9d1cc782c" + } + ] + }, + { + "releaseRevision": 22, + "packageDigest": "6f57bb7833fd4e6acf743921ca25fd57bb51df76caa8ea2aea98c149e20f22e1", + "gitTreeSha": "535bf7b62e1e374adb380e16f837a3c575e302d4", + "files": [ + { + "path": "SKILL.md", + "size": 13423, + "executable": false, + "classification": "text", + "exactSha256": "aa9307c3db1e8d96870955fa2f6260b220e840401ce7ea9c034021658601a3ea", + "textNormalizedSha256": "aa9307c3db1e8d96870955fa2f6260b220e840401ce7ea9c034021658601a3ea", + "identitySha256": "aa9307c3db1e8d96870955fa2f6260b220e840401ce7ea9c034021658601a3ea" + } + ] + }, + { + "releaseRevision": 23, + "packageDigest": "ce5459d22254faf23a3af527a22fa8981f1c3f88afe48487a9fd79c44d0729b9", + "gitTreeSha": "de4999bb21e7009bf1e453d593e7726d917d3fc4", + "files": [ + { + "path": "SKILL.md", + "size": 13764, + "executable": false, + "classification": "text", + "exactSha256": "879f328595d4148a5b0dba9dd3cc31c4d30740cb574d754f38ee82f9d1cc782c", + "textNormalizedSha256": "879f328595d4148a5b0dba9dd3cc31c4d30740cb574d754f38ee82f9d1cc782c", + "identitySha256": "879f328595d4148a5b0dba9dd3cc31c4d30740cb574d754f38ee82f9d1cc782c" + } + ] + }, + { + "releaseRevision": 24, + "packageDigest": "30b067d09f9f9ee9c048f0b3d809c25e269fd46cc948486cdb66690205ae8c4b", + "gitTreeSha": "833e5ce46df5bf55d29fbbd3bd710aa2dfe6b5c4", + "files": [ + { + "path": "SKILL.md", + "size": 14298, + "executable": false, + "classification": "text", + "exactSha256": "a1a98ff994d134217b32ccd13130a1d0af44db9df0c90a8ea7c1e46b5092603f", + "textNormalizedSha256": "a1a98ff994d134217b32ccd13130a1d0af44db9df0c90a8ea7c1e46b5092603f", + "identitySha256": "a1a98ff994d134217b32ccd13130a1d0af44db9df0c90a8ea7c1e46b5092603f" + } + ] + }, + { + "releaseRevision": 25, + "packageDigest": "2ae59c0bea7f2a12ef0c78e3cba65895ff67b311b40b97b46223460b4d1a7acd", + "gitTreeSha": "ba4e183d53b70fd01b763a9cc19f757550a18e2e", + "files": [ + { + "path": "SKILL.md", + "size": 14320, + "executable": false, + "classification": "text", + "exactSha256": "ed3994def086f5a8aeecfee545116d1f8fa4ef46b48656dfa797a507a7a078a4", + "textNormalizedSha256": "ed3994def086f5a8aeecfee545116d1f8fa4ef46b48656dfa797a507a7a078a4", + "identitySha256": "ed3994def086f5a8aeecfee545116d1f8fa4ef46b48656dfa797a507a7a078a4" + } + ] + }, + { + "releaseRevision": 26, + "packageDigest": "5d7bdc664990cd84771eceea9393b0a566aee4ad9cdbf0883910cd8d4d46d5ac", + "gitTreeSha": "6b459e105bb77cbfa67ba0086f3afcbe32e12093", + "files": [ + { + "path": "SKILL.md", + "size": 14636, + "executable": false, + "classification": "text", + "exactSha256": "86f75826ec0ed735a3363fe6b17c7b29431f1ddcf16736398bd38ee6a7e9c7ce", + "textNormalizedSha256": "86f75826ec0ed735a3363fe6b17c7b29431f1ddcf16736398bd38ee6a7e9c7ce", + "identitySha256": "86f75826ec0ed735a3363fe6b17c7b29431f1ddcf16736398bd38ee6a7e9c7ce" + } + ] + }, + { + "releaseRevision": 27, + "packageDigest": "94f535ede189381ae1cf1a7cf6effee03335b2e98e9bdf57fed72dcba39036e3", + "gitTreeSha": "1b02d6a7c06465e3ba6661bef201c6db6552c9e6", + "files": [ + { + "path": "SKILL.md", + "size": 14829, + "executable": false, + "classification": "text", + "exactSha256": "7b1b746d4f080e0ac9a4b30f1fcb32f24433efccf70e03b66342a07cac54c835", + "textNormalizedSha256": "7b1b746d4f080e0ac9a4b30f1fcb32f24433efccf70e03b66342a07cac54c835", + "identitySha256": "7b1b746d4f080e0ac9a4b30f1fcb32f24433efccf70e03b66342a07cac54c835" + } + ] + }, + { + "releaseRevision": 28, + "packageDigest": "d70d2e70a9ac4ae8cf8ec5cb958524521842d558e40ea24dd882b90a846a2cdc", + "gitTreeSha": "3d734f7f385d66ab408c56e8814b9c115d53556c", + "files": [ + { + "path": "SKILL.md", + "size": 15055, + "executable": false, + "classification": "text", + "exactSha256": "399d3e63eb8db02632870428e9465866787540599a39e168516a30621fa84995", + "textNormalizedSha256": "399d3e63eb8db02632870428e9465866787540599a39e168516a30621fa84995", + "identitySha256": "399d3e63eb8db02632870428e9465866787540599a39e168516a30621fa84995" + } + ] + }, + { + "releaseRevision": 29, + "packageDigest": "ca9d1d5e2ad2dc6814be80af642c5fb485644713e9bd7ef19acd36d4228b94c5", + "gitTreeSha": "d1175a5a5807a6783a3d896f720645b39f736dbb", + "files": [ + { + "path": "SKILL.md", + "size": 17257, + "executable": false, + "classification": "text", + "exactSha256": "65dddf473219f4324d6b81ece2af29d512948dd9ebd67f15b424a1b205ea814e", + "textNormalizedSha256": "65dddf473219f4324d6b81ece2af29d512948dd9ebd67f15b424a1b205ea814e", + "identitySha256": "65dddf473219f4324d6b81ece2af29d512948dd9ebd67f15b424a1b205ea814e" + } + ] + }, + { + "releaseRevision": 30, + "packageDigest": "4994ac99e6563e53f85c404d52470ab05945679ecb3e0f6a1ac4dbba83d70b35", + "gitTreeSha": "91c0234c3e64dfcf45ac1515ecc77c973a7af185", + "files": [ + { + "path": "SKILL.md", + "size": 19660, + "executable": false, + "classification": "text", + "exactSha256": "236008f441d16f1d4c92681776798b787dc67357f9110dac11d8b0191b00a368", + "textNormalizedSha256": "236008f441d16f1d4c92681776798b787dc67357f9110dac11d8b0191b00a368", + "identitySha256": "236008f441d16f1d4c92681776798b787dc67357f9110dac11d8b0191b00a368" + } + ] + }, + { + "releaseRevision": 31, + "packageDigest": "c52a999a35f8e96ec72c97a537ce5906ef098879495382d85f50c70a888ba901", + "gitTreeSha": "77be03ea57ddbc4c4067bca22ab58c3af5ae3d2c", + "files": [ + { + "path": "SKILL.md", + "size": 20862, + "executable": false, + "classification": "text", + "exactSha256": "4304a86de2844899f730650c48a4ac7db812bc9f3bfb0c447d5e6415139abe14", + "textNormalizedSha256": "4304a86de2844899f730650c48a4ac7db812bc9f3bfb0c447d5e6415139abe14", + "identitySha256": "4304a86de2844899f730650c48a4ac7db812bc9f3bfb0c447d5e6415139abe14" + } + ] + }, + { + "releaseRevision": 32, + "packageDigest": "51740ff13f379ac5743d3fd28a14b17168dcef40f7048c20182dce166098c45f", + "gitTreeSha": "ded93000a5f654e2b4f324501282459bd56afe19", + "files": [ + { + "path": "SKILL.md", + "size": 21557, + "executable": false, + "classification": "text", + "exactSha256": "b4c36e19fc158fc8c286bdfdf05a537985cb2159a596c93862968c5417bf15be", + "textNormalizedSha256": "b4c36e19fc158fc8c286bdfdf05a537985cb2159a596c93862968c5417bf15be", + "identitySha256": "b4c36e19fc158fc8c286bdfdf05a537985cb2159a596c93862968c5417bf15be" + } + ] + } + ], + "orchestration": [ + { + "releaseRevision": 1, + "packageDigest": "427a9ae737ae258ccf2c1f7659549da1b6b00a65c1fad8ac9c6baf4f42af8c4e", + "gitTreeSha": "13840d1ad238a462bdc34f7fca11cb4d224b514c", + "files": [ + { + "path": "SKILL.md", + "size": 12589, + "executable": false, + "classification": "text", + "exactSha256": "e4aa2bde868fdb45f905189361ab09e613ea9d83875ba7e951b6f3addfb58c75", + "textNormalizedSha256": "e4aa2bde868fdb45f905189361ab09e613ea9d83875ba7e951b6f3addfb58c75", + "identitySha256": "e4aa2bde868fdb45f905189361ab09e613ea9d83875ba7e951b6f3addfb58c75" + } + ] + }, + { + "releaseRevision": 2, + "packageDigest": "122aa952ff591cddbda47767a7de738b05dbeb0c5d26065010bcb6b788ef329d", + "gitTreeSha": "2c0e80be2d5ff6760f461711179d9fd94a7454b2", + "files": [ + { + "path": "SKILL.md", + "size": 12972, + "executable": false, + "classification": "text", + "exactSha256": "68761e4b247b7a5bc22087eb4d801287e120b9a3746054b50c96d9f5e7e0bcca", + "textNormalizedSha256": "68761e4b247b7a5bc22087eb4d801287e120b9a3746054b50c96d9f5e7e0bcca", + "identitySha256": "68761e4b247b7a5bc22087eb4d801287e120b9a3746054b50c96d9f5e7e0bcca" + } + ] + }, + { + "releaseRevision": 3, + "packageDigest": "8e8ce7fc8dc644841bb9a62426eb4bf3e0ca15dd49c33f23d442ec0b2b72aaab", + "gitTreeSha": "2775e9a44fec9135321fdf92051c34a4bc9214ff", + "files": [ + { + "path": "SKILL.md", + "size": 13003, + "executable": false, + "classification": "text", + "exactSha256": "f3bae76495657f27458969fe2d6510098f39b429580870f5e79fac3493f891ee", + "textNormalizedSha256": "f3bae76495657f27458969fe2d6510098f39b429580870f5e79fac3493f891ee", + "identitySha256": "f3bae76495657f27458969fe2d6510098f39b429580870f5e79fac3493f891ee" + } + ] + }, + { + "releaseRevision": 4, + "packageDigest": "10d51bc7f30b924ac32f05a16352c9b2172e21082db81562316c7d7d897de049", + "gitTreeSha": "a75a8a7124054ebfa2898c13a399e8fb6578bbb0", + "files": [ + { + "path": "SKILL.md", + "size": 13167, + "executable": false, + "classification": "text", + "exactSha256": "abb639ec034ab3f50fddf3dc02c4a041876c34278f529ec2c044c0cda7b74059", + "textNormalizedSha256": "abb639ec034ab3f50fddf3dc02c4a041876c34278f529ec2c044c0cda7b74059", + "identitySha256": "abb639ec034ab3f50fddf3dc02c4a041876c34278f529ec2c044c0cda7b74059" + } + ] + }, + { + "releaseRevision": 5, + "packageDigest": "8e8ce7fc8dc644841bb9a62426eb4bf3e0ca15dd49c33f23d442ec0b2b72aaab", + "gitTreeSha": "2775e9a44fec9135321fdf92051c34a4bc9214ff", + "files": [ + { + "path": "SKILL.md", + "size": 13003, + "executable": false, + "classification": "text", + "exactSha256": "f3bae76495657f27458969fe2d6510098f39b429580870f5e79fac3493f891ee", + "textNormalizedSha256": "f3bae76495657f27458969fe2d6510098f39b429580870f5e79fac3493f891ee", + "identitySha256": "f3bae76495657f27458969fe2d6510098f39b429580870f5e79fac3493f891ee" + } + ] + }, + { + "releaseRevision": 6, + "packageDigest": "10d51bc7f30b924ac32f05a16352c9b2172e21082db81562316c7d7d897de049", + "gitTreeSha": "a75a8a7124054ebfa2898c13a399e8fb6578bbb0", + "files": [ + { + "path": "SKILL.md", + "size": 13167, + "executable": false, + "classification": "text", + "exactSha256": "abb639ec034ab3f50fddf3dc02c4a041876c34278f529ec2c044c0cda7b74059", + "textNormalizedSha256": "abb639ec034ab3f50fddf3dc02c4a041876c34278f529ec2c044c0cda7b74059", + "identitySha256": "abb639ec034ab3f50fddf3dc02c4a041876c34278f529ec2c044c0cda7b74059" + } + ] + }, + { + "releaseRevision": 7, + "packageDigest": "0c9848cc3d1e135f6d0105c49ec6f16a126e675125408744d9e6c940645fd427", + "gitTreeSha": "d12449bc4ba94699a9c9ec636a15ad3b6f095bfd", + "files": [ + { + "path": "SKILL.md", + "size": 12749, + "executable": false, + "classification": "text", + "exactSha256": "6c38221cc0c280320264dbcba35af1371b3bf525d430a9d6b7a21c33bcda2223", + "textNormalizedSha256": "6c38221cc0c280320264dbcba35af1371b3bf525d430a9d6b7a21c33bcda2223", + "identitySha256": "6c38221cc0c280320264dbcba35af1371b3bf525d430a9d6b7a21c33bcda2223" + } + ] + }, + { + "releaseRevision": 8, + "packageDigest": "fbb016bd4ec67677fea3ffa16aacc2e95d13968b33520409385bef5d1a025c16", + "gitTreeSha": "413ae24e6011f86372aa44c8611d8fe76d936f8a", + "files": [ + { + "path": "SKILL.md", + "size": 12795, + "executable": false, + "classification": "text", + "exactSha256": "9248461f4224081452ef8aed56748a456b99b79fb3f78a525857f8d12e44f7f1", + "textNormalizedSha256": "9248461f4224081452ef8aed56748a456b99b79fb3f78a525857f8d12e44f7f1", + "identitySha256": "9248461f4224081452ef8aed56748a456b99b79fb3f78a525857f8d12e44f7f1" + } + ] + }, + { + "releaseRevision": 9, + "packageDigest": "cb991258e829f2a1256bcaf4f90018046d2bc91b5fba301bd12b046e9e2e6e02", + "gitTreeSha": "820faebf9aa70e7c1792909ceb9fbf35db93795a", + "files": [ + { + "path": "SKILL.md", + "size": 15044, + "executable": false, + "classification": "text", + "exactSha256": "ee559224b6047d01db02d9aabdfbcc94475394e6f11f4076ce785c42faee63c6", + "textNormalizedSha256": "ee559224b6047d01db02d9aabdfbcc94475394e6f11f4076ce785c42faee63c6", + "identitySha256": "ee559224b6047d01db02d9aabdfbcc94475394e6f11f4076ce785c42faee63c6" + } + ] + }, + { + "releaseRevision": 10, + "packageDigest": "0d8470040c3c15570fe2745fc159b5ff57b5a229ec4e27ea600a8ced594c0455", + "gitTreeSha": "5113d720ff1a96ed158befcb8752f3a26aadd161", + "files": [ + { + "path": "SKILL.md", + "size": 10338, + "executable": false, + "classification": "text", + "exactSha256": "d37fa68601e6b383146976baf4ac28b1ecc8acef808394e9cede434a08be82ce", + "textNormalizedSha256": "d37fa68601e6b383146976baf4ac28b1ecc8acef808394e9cede434a08be82ce", + "identitySha256": "d37fa68601e6b383146976baf4ac28b1ecc8acef808394e9cede434a08be82ce" + } + ] + }, + { + "releaseRevision": 11, + "packageDigest": "9008c30bdf8a20857642968753013bba4961a6297b02d31afe65105606b0bcfd", + "gitTreeSha": "993c9e1861ad97047e34ab82e5dbd045e888a4a5", + "files": [ + { + "path": "SKILL.md", + "size": 10348, + "executable": false, + "classification": "text", + "exactSha256": "3899a8a6da1d7a962e1029d002f7187058543e47a230b6459756a41bc08af4a7", + "textNormalizedSha256": "3899a8a6da1d7a962e1029d002f7187058543e47a230b6459756a41bc08af4a7", + "identitySha256": "3899a8a6da1d7a962e1029d002f7187058543e47a230b6459756a41bc08af4a7" + } + ] + }, + { + "releaseRevision": 12, + "packageDigest": "c2a45891a381db4376a61f2e0f170cd512883e0f817a5c28266b744e04d00006", + "gitTreeSha": "2f7404841129a80c8b9add53c761f0ff11174dae", + "files": [ + { + "path": "SKILL.md", + "size": 10973, + "executable": false, + "classification": "text", + "exactSha256": "4bb49dd8fb3062af4424f912909d52914f377e6dd3fcd12ed5296e7781bdabe4", + "textNormalizedSha256": "4bb49dd8fb3062af4424f912909d52914f377e6dd3fcd12ed5296e7781bdabe4", + "identitySha256": "4bb49dd8fb3062af4424f912909d52914f377e6dd3fcd12ed5296e7781bdabe4" + } + ] + }, + { + "releaseRevision": 13, + "packageDigest": "b1a4930b9a368892f54a7364c76fab3a63cea1638ebfefd0a556d96fd0af2ef8", + "gitTreeSha": "bf470e96a3411ef20ce67ee9a900bc773010775c", + "files": [ + { + "path": "SKILL.md", + "size": 11536, + "executable": false, + "classification": "text", + "exactSha256": "1150a30d854ad1d6c4f107af4c115bac6afd00122e4b011ee5e8f9c49a9d9bab", + "textNormalizedSha256": "1150a30d854ad1d6c4f107af4c115bac6afd00122e4b011ee5e8f9c49a9d9bab", + "identitySha256": "1150a30d854ad1d6c4f107af4c115bac6afd00122e4b011ee5e8f9c49a9d9bab" + } + ] + }, + { + "releaseRevision": 14, + "packageDigest": "a8c4f3fbc51a6a4d310ab5cddb015224884ac834b40530b6dbb7fcc2f91b52d1", + "gitTreeSha": "141e1de10ebe68fe5e72da23eb900b72746d217f", + "files": [ + { + "path": "SKILL.md", + "size": 12177, + "executable": false, + "classification": "text", + "exactSha256": "d6490454f5a2814e98bb3848f05574beb81b7c5111295efd8c1d26c8b16bee4a", + "textNormalizedSha256": "d6490454f5a2814e98bb3848f05574beb81b7c5111295efd8c1d26c8b16bee4a", + "identitySha256": "d6490454f5a2814e98bb3848f05574beb81b7c5111295efd8c1d26c8b16bee4a" + } + ] + }, + { + "releaseRevision": 15, + "packageDigest": "b1a4930b9a368892f54a7364c76fab3a63cea1638ebfefd0a556d96fd0af2ef8", + "gitTreeSha": "bf470e96a3411ef20ce67ee9a900bc773010775c", + "files": [ + { + "path": "SKILL.md", + "size": 11536, + "executable": false, + "classification": "text", + "exactSha256": "1150a30d854ad1d6c4f107af4c115bac6afd00122e4b011ee5e8f9c49a9d9bab", + "textNormalizedSha256": "1150a30d854ad1d6c4f107af4c115bac6afd00122e4b011ee5e8f9c49a9d9bab", + "identitySha256": "1150a30d854ad1d6c4f107af4c115bac6afd00122e4b011ee5e8f9c49a9d9bab" + } + ] + }, + { + "releaseRevision": 16, + "packageDigest": "a8c4f3fbc51a6a4d310ab5cddb015224884ac834b40530b6dbb7fcc2f91b52d1", + "gitTreeSha": "141e1de10ebe68fe5e72da23eb900b72746d217f", + "files": [ + { + "path": "SKILL.md", + "size": 12177, + "executable": false, + "classification": "text", + "exactSha256": "d6490454f5a2814e98bb3848f05574beb81b7c5111295efd8c1d26c8b16bee4a", + "textNormalizedSha256": "d6490454f5a2814e98bb3848f05574beb81b7c5111295efd8c1d26c8b16bee4a", + "identitySha256": "d6490454f5a2814e98bb3848f05574beb81b7c5111295efd8c1d26c8b16bee4a" + } + ] + }, + { + "releaseRevision": 17, + "packageDigest": "f51066a029f17a1d87f7ac587df9850440cde08f5bdda80cb810a100ea45a169", + "gitTreeSha": "df7b970dda7e03e87bd9fd1dfe62c9aaf8769adc", + "files": [ + { + "path": "SKILL.md", + "size": 13415, + "executable": false, + "classification": "text", + "exactSha256": "277dd8372f4b3b1773f8debe6e02eae575f01d568de328ae06d78ba71fc045cb", + "textNormalizedSha256": "277dd8372f4b3b1773f8debe6e02eae575f01d568de328ae06d78ba71fc045cb", + "identitySha256": "277dd8372f4b3b1773f8debe6e02eae575f01d568de328ae06d78ba71fc045cb" + } + ] + }, + { + "releaseRevision": 18, + "packageDigest": "77663355eef8ed82ff6918d9745bfecc4a50550ea86edc0f2a151f989c205a23", + "gitTreeSha": "7ff543c1701d55ffb5dffb2cd8dc5dd3fc47c700", + "files": [ + { + "path": "SKILL.md", + "size": 17357, + "executable": false, + "classification": "text", + "exactSha256": "e72ecaf625b4a839189e8f13717b0c5b7a309fc82c9ed0dbfb8e861e31a2124a", + "textNormalizedSha256": "e72ecaf625b4a839189e8f13717b0c5b7a309fc82c9ed0dbfb8e861e31a2124a", + "identitySha256": "e72ecaf625b4a839189e8f13717b0c5b7a309fc82c9ed0dbfb8e861e31a2124a" + } + ] + }, + { + "releaseRevision": 19, + "packageDigest": "3318ddeba58457774a0436b6d2d1e722b8d81e2ab3c67cd8409bfbd8efe62a5f", + "gitTreeSha": "90a57c3d0135992369caa36493ca04278e02e55a", + "files": [ + { + "path": "SKILL.md", + "size": 17600, + "executable": false, + "classification": "text", + "exactSha256": "2c1ac3a94ef4ee085147ee48d813bdcce532f5f6c7040d7836ea7b3d2a882628", + "textNormalizedSha256": "2c1ac3a94ef4ee085147ee48d813bdcce532f5f6c7040d7836ea7b3d2a882628", + "identitySha256": "2c1ac3a94ef4ee085147ee48d813bdcce532f5f6c7040d7836ea7b3d2a882628" + } + ] + }, + { + "releaseRevision": 20, + "packageDigest": "15ec10fdb480836bedc166c54ea15c10fb1e66e7c480f19300ad1227abe2b34d", + "gitTreeSha": "189912ad39f48b0a71f1cf0ee87abfcd55f90716", + "files": [ + { + "path": "SKILL.md", + "size": 19701, + "executable": false, + "classification": "text", + "exactSha256": "e3b81f4d713f97a65a35ca5db0606939cebfdaade3daf1d709a76df1c869fd8a", + "textNormalizedSha256": "e3b81f4d713f97a65a35ca5db0606939cebfdaade3daf1d709a76df1c869fd8a", + "identitySha256": "e3b81f4d713f97a65a35ca5db0606939cebfdaade3daf1d709a76df1c869fd8a" + } + ] + }, + { + "releaseRevision": 21, + "packageDigest": "211f80c301de8afa2623dac3fde79f9ada8ec99a817f54a56dbb3cb43e4325d7", + "gitTreeSha": "a96dcd29ab8618eccc54c5c433f0b2378e50f698", + "files": [ + { + "path": "SKILL.md", + "size": 21546, + "executable": false, + "classification": "text", + "exactSha256": "9550fb923937729fbaa7d6edec8a22f5a88da556fa7834c6a78ba3c65e8cda67", + "textNormalizedSha256": "9550fb923937729fbaa7d6edec8a22f5a88da556fa7834c6a78ba3c65e8cda67", + "identitySha256": "9550fb923937729fbaa7d6edec8a22f5a88da556fa7834c6a78ba3c65e8cda67" + } + ] + }, + { + "releaseRevision": 22, + "packageDigest": "c8eddb2814dda4c4b000048e628f147ef730088f8c9628afb04529a4863a410b", + "gitTreeSha": "ad1024ebab1f464a743d1c91a7005211beed0917", + "files": [ + { + "path": "SKILL.md", + "size": 21701, + "executable": false, + "classification": "text", + "exactSha256": "6053ed840323522c3bac235007b2241df897857a6974dfd784f7e539b2b98a10", + "textNormalizedSha256": "6053ed840323522c3bac235007b2241df897857a6974dfd784f7e539b2b98a10", + "identitySha256": "6053ed840323522c3bac235007b2241df897857a6974dfd784f7e539b2b98a10" + } + ] + }, + { + "releaseRevision": 23, + "packageDigest": "dab5cc1bf87b12838a53dda90f1f4f35d1f77da66bcc63840574531eff600d2b", + "gitTreeSha": "f7a017f3e1f14dafed0c59717bec37c022075be2", + "files": [ + { + "path": "SKILL.md", + "size": 22850, + "executable": false, + "classification": "text", + "exactSha256": "b73c7c665aa233a782ba81982ebc954e220fdf1c1c6ca2b6bf38133c1cd76fc9", + "textNormalizedSha256": "b73c7c665aa233a782ba81982ebc954e220fdf1c1c6ca2b6bf38133c1cd76fc9", + "identitySha256": "b73c7c665aa233a782ba81982ebc954e220fdf1c1c6ca2b6bf38133c1cd76fc9" + } + ] + }, + { + "releaseRevision": 24, + "packageDigest": "9fbfa2ae3f3f99441563a4b8b1c6302107944480db8718ddb326a862a51f7ab9", + "gitTreeSha": "086c41e0b353b4908d2963694b4a7c791d4b3982", + "files": [ + { + "path": "SKILL.md", + "size": 22861, + "executable": false, + "classification": "text", + "exactSha256": "0cb898d37560ab7ac4cb477a2d7e8ba3e593d45affd6d7e9f99151879aaeb1a5", + "textNormalizedSha256": "0cb898d37560ab7ac4cb477a2d7e8ba3e593d45affd6d7e9f99151879aaeb1a5", + "identitySha256": "0cb898d37560ab7ac4cb477a2d7e8ba3e593d45affd6d7e9f99151879aaeb1a5" + } + ] + } + ], + "mobile-fit-debug": [ + { + "releaseRevision": 1, + "packageDigest": "97ff3f85018bdea5e4518325423b159708eabe16cde3fbf7297525fa084d6a46", + "gitTreeSha": "9ff10aaeea448fa956cd4708b7f3b8242d178c0a", + "files": [ + { + "path": "SKILL.md", + "size": 9414, + "executable": false, + "classification": "text", + "exactSha256": "84aa7cc18ed03df90f40c3c70650a0ad3df941a5e1f80870bd00cf6b9b7f7eab", + "textNormalizedSha256": "84aa7cc18ed03df90f40c3c70650a0ad3df941a5e1f80870bd00cf6b9b7f7eab", + "identitySha256": "84aa7cc18ed03df90f40c3c70650a0ad3df941a5e1f80870bd00cf6b9b7f7eab" + } + ] + } + ], + "computer-use": [ + { + "releaseRevision": 1, + "packageDigest": "7c844c42b8e8c1a413cb8755b11a3b89fb3dc6c5a349d7aa63f269df975c1ccb", + "gitTreeSha": "ac1f6e3de0cbd9b488c5cfb1d4c5c076e17afea0", + "files": [ + { + "path": "SKILL.md", + "size": 8514, + "executable": false, + "classification": "text", + "exactSha256": "bdd257ccd34bf4db179a549d2f133ac5e5fa9e4ea5c7e811d38a98e8a939b3cc", + "textNormalizedSha256": "bdd257ccd34bf4db179a549d2f133ac5e5fa9e4ea5c7e811d38a98e8a939b3cc", + "identitySha256": "bdd257ccd34bf4db179a549d2f133ac5e5fa9e4ea5c7e811d38a98e8a939b3cc" + } + ] + }, + { + "releaseRevision": 2, + "packageDigest": "6c882a35e50a00c49d2f87647c54d401fe2af60a95dc3d17a14bf3cf879e5547", + "gitTreeSha": "d83929742fd30aa3d4ab82ed1be4000acbb9cce0", + "files": [ + { + "path": "SKILL.md", + "size": 6368, + "executable": false, + "classification": "text", + "exactSha256": "1b6e39080305dea665ce2c05c4c53ad5b49697db874920dab758d7a5cdc8292c", + "textNormalizedSha256": "1b6e39080305dea665ce2c05c4c53ad5b49697db874920dab758d7a5cdc8292c", + "identitySha256": "1b6e39080305dea665ce2c05c4c53ad5b49697db874920dab758d7a5cdc8292c" + } + ] + }, + { + "releaseRevision": 3, + "packageDigest": "747b9bbe7d08d0c6fc69a47c02064de73fa09ab755244b40a6253aa810d015f7", + "gitTreeSha": "b3fb19187d9775774df2a93d59e9e0003093e4a2", + "files": [ + { + "path": "SKILL.md", + "size": 10454, + "executable": false, + "classification": "text", + "exactSha256": "7f5a7bbe8b2fb51a9637e8df71cd1dca1a84b213ffe5bf56dcdd3417d7fb1601", + "textNormalizedSha256": "7f5a7bbe8b2fb51a9637e8df71cd1dca1a84b213ffe5bf56dcdd3417d7fb1601", + "identitySha256": "7f5a7bbe8b2fb51a9637e8df71cd1dca1a84b213ffe5bf56dcdd3417d7fb1601" + } + ] + }, + { + "releaseRevision": 4, + "packageDigest": "35b6d91d1d3d38f3828c60e5b856a91324bdd09273db33664f508392c5c48dfc", + "gitTreeSha": "b4e2d7e7af8f8f29c6d0df6e92f23c1b2c439e3b", + "files": [ + { + "path": "SKILL.md", + "size": 10625, + "executable": false, + "classification": "text", + "exactSha256": "4ee2af92cf1bcb1a31b0ea87e61c5f99dbce325a6f211e320c05fba3b599a07b", + "textNormalizedSha256": "4ee2af92cf1bcb1a31b0ea87e61c5f99dbce325a6f211e320c05fba3b599a07b", + "identitySha256": "4ee2af92cf1bcb1a31b0ea87e61c5f99dbce325a6f211e320c05fba3b599a07b" + } + ] + }, + { + "releaseRevision": 5, + "packageDigest": "cd2809474d57fd7277adb277448e6fa446810d3cbad71ac0b473b9e8ff1bad68", + "gitTreeSha": "306c0f8cb63bcac265a5b7975dc2f855be4f1344", + "files": [ + { + "path": "SKILL.md", + "size": 11241, + "executable": false, + "classification": "text", + "exactSha256": "f49b29fb6b209956907688692387adcdc509fad344555f09badaf383106f5f39", + "textNormalizedSha256": "f49b29fb6b209956907688692387adcdc509fad344555f09badaf383106f5f39", + "identitySha256": "f49b29fb6b209956907688692387adcdc509fad344555f09badaf383106f5f39" + } + ] + } + ], + "orca-emulator": [ + { + "releaseRevision": 1, + "packageDigest": "fdecfa3957c5502ec5f4933504f7112ab98c2c3b7b8902aad34db6cb1b7dc28a", + "gitTreeSha": "6b6b92ea67eec1661e45a8b37bf8014542f05417", + "files": [ + { + "path": "SKILL.md", + "size": 10715, + "executable": false, + "classification": "text", + "exactSha256": "273123d797b6b6072a13a4e2732feca2b6d391154987836b31d5b105a7096045", + "textNormalizedSha256": "273123d797b6b6072a13a4e2732feca2b6d391154987836b31d5b105a7096045", + "identitySha256": "273123d797b6b6072a13a4e2732feca2b6d391154987836b31d5b105a7096045" + } + ] + }, + { + "releaseRevision": 2, + "packageDigest": "5a25152a06a88be0361f47cc37325e06ab840e81df0092cfea82f5e20e50978b", + "gitTreeSha": "5575f765c3dc31f8bc57032ca3716df38826eabd", + "files": [ + { + "path": "SKILL.md", + "size": 10717, + "executable": false, + "classification": "text", + "exactSha256": "56b880444b2fe1f33061017ae41eb0eac1b11155ac74087f6e511a879891a712", + "textNormalizedSha256": "56b880444b2fe1f33061017ae41eb0eac1b11155ac74087f6e511a879891a712", + "identitySha256": "56b880444b2fe1f33061017ae41eb0eac1b11155ac74087f6e511a879891a712" + } + ] + }, + { + "releaseRevision": 3, + "packageDigest": "1807d287d2c08b1c535ba4e14ca2f29afe39274c4bde5737995a1792e44d5412", + "gitTreeSha": "dbb8e0daba768527885715ee5efebeaa1ee38f0f", + "files": [ + { + "path": "SKILL.md", + "size": 10853, + "executable": false, + "classification": "text", + "exactSha256": "837909c40e2472c24bcfd88684c00b0c19208e35e4446f81772ee6d7854fd834", + "textNormalizedSha256": "837909c40e2472c24bcfd88684c00b0c19208e35e4446f81772ee6d7854fd834", + "identitySha256": "837909c40e2472c24bcfd88684c00b0c19208e35e4446f81772ee6d7854fd834" + } + ] + }, + { + "releaseRevision": 4, + "packageDigest": "453b1d9aa20b51b8a4d32c7b6def6a93f7ef9c730de32abbcbc1788ad1b1820b", + "gitTreeSha": "66be6abe99f1807da85934aee0e22daefc8f7656", + "files": [ + { + "path": "SKILL.md", + "size": 11527, + "executable": false, + "classification": "text", + "exactSha256": "84dbfacf6854874e369840c011e78603e533273fb848d21dac3cfb08e0346429", + "textNormalizedSha256": "84dbfacf6854874e369840c011e78603e533273fb848d21dac3cfb08e0346429", + "identitySha256": "84dbfacf6854874e369840c011e78603e533273fb848d21dac3cfb08e0346429" + } + ] + } + ], + "linear-tickets": [ + { + "releaseRevision": 1, + "packageDigest": "d70fb0e7640182b1b193969c96d2ff0d70821f59f71945915709ae95ddc4e350", + "gitTreeSha": "bb8c28593807f05b5007f89ccc86f9c054728882", + "files": [ + { + "path": "SKILL.md", + "size": 6271, + "executable": false, + "classification": "text", + "exactSha256": "050220f735ce9b0f65520d3ed7fba6f5c1f856a6e8e84093ef564901cfc97ee1", + "textNormalizedSha256": "050220f735ce9b0f65520d3ed7fba6f5c1f856a6e8e84093ef564901cfc97ee1", + "identitySha256": "050220f735ce9b0f65520d3ed7fba6f5c1f856a6e8e84093ef564901cfc97ee1" + } + ] + }, + { + "releaseRevision": 2, + "packageDigest": "cc5901e9ce67829d909ab97910eb22825ca58547d215c42b665b8ff7401d5a05", + "gitTreeSha": "954d226df97ba0034002887bc9d23221667d45f0", + "files": [ + { + "path": "SKILL.md", + "size": 8985, + "executable": false, + "classification": "text", + "exactSha256": "f57dfda0dddd20992b83d8b7a3e85f9651244ae4cb1b80bed80a87bb2c9b8bf2", + "textNormalizedSha256": "f57dfda0dddd20992b83d8b7a3e85f9651244ae4cb1b80bed80a87bb2c9b8bf2", + "identitySha256": "f57dfda0dddd20992b83d8b7a3e85f9651244ae4cb1b80bed80a87bb2c9b8bf2" + } + ] + }, + { + "releaseRevision": 3, + "packageDigest": "948919e8f2b37157c40009f865fa43fc925e56d0c30b544d0508cec823c26b15", + "gitTreeSha": "e00b39a2c9b37916e40b539e08341274bd8db2f7", + "files": [ + { + "path": "SKILL.md", + "size": 9670, + "executable": false, + "classification": "text", + "exactSha256": "0d4fa74e40af8b530ff7071eb6f96b136e1c4cb0c02239305bb6d83b67a8214b", + "textNormalizedSha256": "0d4fa74e40af8b530ff7071eb6f96b136e1c4cb0c02239305bb6d83b67a8214b", + "identitySha256": "0d4fa74e40af8b530ff7071eb6f96b136e1c4cb0c02239305bb6d83b67a8214b" + } + ] + }, + { + "releaseRevision": 4, + "packageDigest": "f198d7b22e5ee1673dac403f9cca0553b124e0a90e4fdd05d2c23b7344e32d2b", + "gitTreeSha": "de9fc106bbb4e313a90ff9a9513a720909bbd176", + "files": [ + { + "path": "SKILL.md", + "size": 10596, + "executable": false, + "classification": "text", + "exactSha256": "0c3077c93328b9965430cd6951f1a35889b8c0775037870ea3a8bcf303d9d2c5", + "textNormalizedSha256": "0c3077c93328b9965430cd6951f1a35889b8c0775037870ea3a8bcf303d9d2c5", + "identitySha256": "0c3077c93328b9965430cd6951f1a35889b8c0775037870ea3a8bcf303d9d2c5" + } + ] + } + ], + "orca-linear": [ + { + "releaseRevision": 1, + "packageDigest": "9015e708218fe86620eee39041d3e4f9ddd4b67ff2c6d049beca9ffe3783e03d", + "gitTreeSha": "bc547faebffa3f5645b7d2aad32560d25d836b7f", + "files": [ + { + "path": "SKILL.md", + "size": 9394, + "executable": false, + "classification": "text", + "exactSha256": "bd158d47ba6f7090d73e8d86b9c4cd02610c14a96bf6a03402fe1f725ca84c3f", + "textNormalizedSha256": "bd158d47ba6f7090d73e8d86b9c4cd02610c14a96bf6a03402fe1f725ca84c3f", + "identitySha256": "bd158d47ba6f7090d73e8d86b9c4cd02610c14a96bf6a03402fe1f725ca84c3f" + } + ] + }, + { + "releaseRevision": 2, + "packageDigest": "d44d09e6ecb6a64da177083aad26a95f031cd1cf26ba059fdc888c2628aef64f", + "gitTreeSha": "c34f42030f43e5a85737996fa375bbd79cb5bea8", + "files": [ + { + "path": "SKILL.md", + "size": 10320, + "executable": false, + "classification": "text", + "exactSha256": "48ded55ec3842ce65105e6db7adf9bc9ed263ece08555cec056e68e90321c3d5", + "textNormalizedSha256": "48ded55ec3842ce65105e6db7adf9bc9ed263ece08555cec056e68e90321c3d5", + "identitySha256": "48ded55ec3842ce65105e6db7adf9bc9ed263ece08555cec056e68e90321c3d5" + } + ] + } + ], + "orca-emulator-android": [ + { + "releaseRevision": 1, + "packageDigest": "cf86dd085b981febd8e4e11b7796c90d69a60bd9c1dfaab5a16b99f77841aaa4", + "gitTreeSha": "6299122f69f0218ec01214e2a14c479b1811cdf6", + "files": [ + { + "path": "SKILL.md", + "size": 8189, + "executable": false, + "classification": "text", + "exactSha256": "ebc1ce862f3e5489e9bd94388386a28116241748f5957116509de7f759250844", + "textNormalizedSha256": "ebc1ce862f3e5489e9bd94388386a28116241748f5957116509de7f759250844", + "identitySha256": "ebc1ce862f3e5489e9bd94388386a28116241748f5957116509de7f759250844" + } + ] + }, + { + "releaseRevision": 2, + "packageDigest": "12272cf82e0731f11e424822b961882457034e730358cc65ea28e4eb9c8ff7f5", + "gitTreeSha": "f7b0fc8cbf5cd78ca5156f6bbe3a20f1462d8f83", + "files": [ + { + "path": "SKILL.md", + "size": 8886, + "executable": false, + "classification": "text", + "exactSha256": "1035d4db357923e98d5075c0c21bc9995b00a36a3739543516fe45ae5ded0332", + "textNormalizedSha256": "1035d4db357923e98d5075c0c21bc9995b00a36a3739543516fe45ae5ded0332", + "identitySha256": "1035d4db357923e98d5075c0c21bc9995b00a36a3739543516fe45ae5ded0332" + } + ] + } + ], + "orca-per-workspace-env": [ + { + "releaseRevision": 1, + "packageDigest": "dc7f471327f55b27a40cd9575ca476fea7a79c2aba74881af00ce4e468cbf714", + "gitTreeSha": "8efb9872787bf874c558393b9a651df91604650c", + "files": [ + { + "path": "SKILL.md", + "size": 37692, + "executable": false, + "classification": "text", + "exactSha256": "a18c3e67b3558d95c07ac80ab7e94c9ddfa0c61946c6b977923ffd65d3df63cc", + "textNormalizedSha256": "a18c3e67b3558d95c07ac80ab7e94c9ddfa0c61946c6b977923ffd65d3df63cc", + "identitySha256": "a18c3e67b3558d95c07ac80ab7e94c9ddfa0c61946c6b977923ffd65d3df63cc" + } + ] + }, + { + "releaseRevision": 2, + "packageDigest": "fa3b65a1a107fca3f0375c696852477b62f58c154b9eb5c0663c41edc4bcd30d", + "gitTreeSha": "354e775b79ea6952ec63acac4d3ee8a9ae07a650", + "files": [ + { + "path": "SKILL.md", + "size": 43769, + "executable": false, + "classification": "text", + "exactSha256": "58e479bd18c4c553df0dfcb408eece2fbe550a0f9688bc289414420f72ed7ea7", + "textNormalizedSha256": "58e479bd18c4c553df0dfcb408eece2fbe550a0f9688bc289414420f72ed7ea7", + "identitySha256": "58e479bd18c4c553df0dfcb408eece2fbe550a0f9688bc289414420f72ed7ea7" + } + ] + } + ] + } +} diff --git a/resources/tray/orca-menu-barTemplate.png b/resources/tray/orca-menu-barTemplate.png new file mode 100644 index 00000000000..b98b90f2fda Binary files /dev/null and b/resources/tray/orca-menu-barTemplate.png differ diff --git a/resources/tray/orca-menu-barTemplate@2x.png b/resources/tray/orca-menu-barTemplate@2x.png new file mode 100644 index 00000000000..fffb571fe76 Binary files /dev/null and b/resources/tray/orca-menu-barTemplate@2x.png differ diff --git a/src/cli/args.ts b/src/cli/args.ts index a3dc0df5349..4392d64314c 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -58,6 +58,7 @@ export const BOOLEAN_FLAGS = new Set([ 'run-hooks', 'show-profile', 'staged', + 'tab', 'tasks', 'text-stdin', 'unread', diff --git a/src/cli/handlers/terminal.test.ts b/src/cli/handlers/terminal.test.ts new file mode 100644 index 00000000000..7dadf41c2d3 --- /dev/null +++ b/src/cli/handlers/terminal.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeClient } from '../runtime-client' +import { parseArgs } from '../args' +import { printHelp } from '../help' +import { COMMAND_SPECS } from '../specs' +import { TERMINAL_HANDLERS } from './terminal' + +describe('terminal close CLI', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('keeps the default close RPC unchanged', async () => { + const call = vi.fn().mockResolvedValue({ + result: { close: { handle: 'term-1', tabId: 'tab-1', ptyKilled: true } } + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await TERMINAL_HANDLERS['terminal close']({ + flags: new Map([['terminal', 'term-1']]), + client: { call } as unknown as RuntimeClient, + cwd: '/tmp/worktree', + json: true + }) + + expect(call).toHaveBeenCalledWith('terminal.close', { terminal: 'term-1' }) + }) + + it('routes --tab to the durable whole-tab RPC', async () => { + const parsed = parseArgs(['terminal', 'close', '--terminal', 'term-1', '--tab']) + const call = vi.fn().mockResolvedValue({ + result: { + close: { + handle: 'term-1', + tabId: 'tab-1', + closeMode: 'tab', + ptyKilled: false + } + } + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await TERMINAL_HANDLERS['terminal close']({ + flags: parsed.flags, + client: { call } as unknown as RuntimeClient, + cwd: '/tmp/worktree', + json: true + }) + + expect(parsed.flags.get('tab')).toBe(true) + expect(call).toHaveBeenCalledWith('terminal.closeTab', { terminal: 'term-1' }) + }) + + it('documents that --tab waits for durable persistence', () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + + printHelp(COMMAND_SPECS, ['terminal', 'close']) + + const help = String(log.mock.calls[0]?.[0]) + expect(help).toContain('orca terminal close [--terminal ] [--tab] [--json]') + expect(help).toContain('durable persistence') + }) +}) diff --git a/src/cli/handlers/terminal.ts b/src/cli/handlers/terminal.ts index fb2eefc3748..1359f5cce13 100644 --- a/src/cli/handlers/terminal.ts +++ b/src/cli/handlers/terminal.ts @@ -150,7 +150,8 @@ export const TERMINAL_HANDLERS: Record = { // `focus` resolves to this canonical path via CommandSpec.aliases before dispatch. 'terminal switch': terminalFocusHandler, 'terminal close': async ({ flags, client, cwd, json }) => { - const result = await client.call<{ close: RuntimeTerminalClose }>('terminal.close', { + const method = flags.get('tab') === true ? 'terminal.closeTab' : 'terminal.close' + const result = await client.call<{ close: RuntimeTerminalClose }>(method, { terminal: await getTerminalHandle(flags, cwd, client) }) printResult(result, json, formatTerminalClose) diff --git a/src/cli/help.ts b/src/cli/help.ts index 379bfbce1e0..11d9fd2dc20 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -82,7 +82,7 @@ Terminals: terminal split Split an existing terminal pane terminal switch Bring a terminal tab to the foreground terminal focus Alias for terminal switch - terminal close Close a terminal pane (or tab if last pane) + terminal close Close a terminal pane/session, or its whole tab with --tab Orchestration: orchestration send Send an inter-agent message @@ -224,7 +224,7 @@ Common Commands: orca terminal create [--worktree ] [--title ] [--command ] [--focus] [--json] orca terminal split [--terminal ] [--direction horizontal|vertical] [--json] orca terminal switch [--terminal ] [--json] - orca terminal close [--terminal ] [--json] + orca terminal close [--terminal ] [--tab] [--json] orca project list [--json] orca project setups [--project ] [--host ] [--json] orca project setup-existing-folder --project --host --path [--kind git|folder] [--display-name ] [--json] @@ -401,6 +401,9 @@ export function formatGroupHelp(specs: CommandSpec[], group: string): string { function formatCommandFlagHelp(flag: string, commandPath: string[]): string { const command = commandPath.join(' ') + if (command === 'terminal close' && flag === 'tab') { + return '--tab Close the whole tab and wait for durable persistence' + } if (command === 'linear issue' && flag === 'id') { return '--id Linear issue key, id, or URL' } diff --git a/src/cli/specs/core.ts b/src/cli/specs/core.ts index 3c89c5ff3a5..41d001daf58 100644 --- a/src/cli/specs/core.ts +++ b/src/cli/specs/core.ts @@ -254,10 +254,16 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [ }, { path: ['terminal', 'close'], - summary: 'Close a terminal tab (kills PTY if running)', - usage: 'orca terminal close [--terminal ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'terminal'], - examples: ['orca terminal close --terminal term_abc123'] + summary: 'Close a terminal pane/session, or its whole tab with --tab', + usage: 'orca terminal close [--terminal ] [--tab] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'terminal', 'tab'], + notes: [ + 'Without --tab, preserves the existing pane/session close behavior. With --tab, waits until the whole tab is durably removed.' + ], + examples: [ + 'orca terminal close --terminal term_abc123', + 'orca terminal close --terminal term_abc123 --tab --json' + ] }, { path: ['terminal', 'rename'], diff --git a/src/cli/terminal-format.ts b/src/cli/terminal-format.ts index a0791d6e038..a6fbd6a9514 100644 --- a/src/cli/terminal-format.ts +++ b/src/cli/terminal-format.ts @@ -168,6 +168,9 @@ export function formatTerminalFocus(result: { focus: RuntimeTerminalFocus }): st } export function formatTerminalClose(result: { close: RuntimeTerminalClose }): string { + if (result.close.closeMode === 'tab') { + return `Closed terminal tab ${result.close.tabId} (${result.close.handle}).` + } const ptyNote = result.close.ptyKilled ? ' PTY killed.' : '' return `Closed terminal ${result.close.handle}.${ptyNote}` } diff --git a/src/main/ai-vault/remote-session-scanner-discovery.ts b/src/main/ai-vault/remote-session-scanner-discovery.ts new file mode 100644 index 00000000000..1791baa82cd --- /dev/null +++ b/src/main/ai-vault/remote-session-scanner-discovery.ts @@ -0,0 +1,168 @@ +import { extname } from 'node:path' +import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types' +import type { ExecutionHostId } from '../../shared/execution-host' +import type { FileStat, IFilesystemProvider } from '../providers/types' +import { joinRemotePath } from '../ssh/ssh-remote-platform' +import { partitionSubagentTranscriptPaths } from './session-scanner-subagent-transcripts' +import type { FileWithMtime } from './session-scanner-types' +import { errorMessage } from './session-scanner-values' +import type { + RemoteScannerContext, + RemoteSessionCandidate, + RemoteSessionSource +} from './remote-session-scanner-types' + +const REMOTE_DISCOVERY_CONCURRENCY = 8 + +export async function discoverRemoteSourceCandidates(args: { + source: RemoteSessionSource + context: RemoteScannerContext + issues: AiVaultScanIssue[] +}): Promise { + const walked = args.source.fixedChildFileSegments + ? await listRemoteFixedChildFiles(args.source, args.context, args.issues) + : await walkRemoteSessionFiles(args.source, args.context, args.issues) + const partition = args.source.collectSubagentSiblingCounts + ? partitionSubagentTranscriptPaths(walked) + : null + const paths = partition ? partition.sessionFilePaths : walked + const files = await mapDiscoveryConcurrently(paths, (path) => + statRemoteFile( + args.context.provider, + path, + args.source.agent, + args.context.executionHostId, + args.issues, + Boolean(args.source.fixedChildFileSegments) + ) + ) + return files + .filter((file): file is FileWithMtime => Boolean(file)) + .map((file) => ({ + source: args.source, + file, + subagentTranscriptCount: partition?.subagentTranscriptCounts.get(file.path) ?? 0 + })) +} + +async function listRemoteFixedChildFiles( + source: RemoteSessionSource, + context: RemoteScannerContext, + issues: AiVaultScanIssue[] +): Promise { + let entries + try { + entries = await context.provider.readDir(source.rootDir) + } catch (err) { + recordRemoteDirectoryIssue(source, context.executionHostId, issues, source.rootDir, err) + return [] + } + const segments = source.fixedChildFileSegments ?? [] + // Why: Antigravity's transcript path is fixed. Constructing it avoids three + // serialized SSH readDir round trips for every conversation directory. + return entries + .filter((entry) => entry.isDirectory && !entry.isSymlink) + .map((entry) => joinRemotePath(context.hostPlatform, source.rootDir, entry.name, ...segments)) + .filter((path) => source.filePredicate?.(path) ?? true) +} + +async function walkRemoteSessionFiles( + source: RemoteSessionSource, + context: RemoteScannerContext, + issues: AiVaultScanIssue[], + dirPath = source.rootDir, + depth = 0 +): Promise { + let entries + try { + entries = await context.provider.readDir(dirPath) + } catch (err) { + recordRemoteDirectoryIssue(source, context.executionHostId, issues, dirPath, err) + return [] + } + + const extensions = new Set(source.extensions) + const files: string[] = [] + for (const entry of entries) { + const fullPath = joinRemotePath(context.hostPlatform, dirPath, entry.name) + if ( + entry.isDirectory && + !entry.isSymlink && + (source.directoryPredicate?.(entry.name, depth) ?? true) + ) { + files.push(...(await walkRemoteSessionFiles(source, context, issues, fullPath, depth + 1))) + continue + } + if ( + !entry.isSymlink && + extensions.has(extname(entry.name).toLowerCase()) && + (source.filePredicate?.(fullPath) ?? true) + ) { + files.push(fullPath) + } + } + return files +} + +async function statRemoteFile( + provider: IFilesystemProvider, + path: string, + agent: AiVaultAgent, + executionHostId: ExecutionHostId, + issues: AiVaultScanIssue[], + missingIsExpected: boolean +): Promise { + try { + const stat = await provider.stat(path) + const mtimeMs = remoteStatMtimeMs(stat) + return { path, mtimeMs, modifiedAt: new Date(mtimeMs).toISOString() } + } catch (err) { + if (!missingIsExpected || !isMissingRemotePathError(err)) { + issues.push({ executionHostId, agent, path, message: errorMessage(err) }) + } + return null + } +} + +function recordRemoteDirectoryIssue( + source: RemoteSessionSource, + executionHostId: ExecutionHostId, + issues: AiVaultScanIssue[], + path: string, + err: unknown +): void { + if (!isMissingRemotePathError(err)) { + issues.push({ executionHostId, agent: source.agent, path, message: errorMessage(err) }) + } +} + +function isMissingRemotePathError(err: unknown): boolean { + const code = + err && typeof err === 'object' && 'code' in err && typeof err.code === 'string' + ? err.code + : null + if (code === 'ENOENT' || code === 'ENOTDIR') { + return true + } + // Relay/provider boundaries can preserve only the underlying Node error text. + return /(?:^|[\s:])(ENOENT|ENOTDIR)(?=[\s:]|$)/.test(errorMessage(err)) +} + +function remoteStatMtimeMs(stat: FileStat): number { + if (typeof stat.mtimeMs === 'number' && Number.isFinite(stat.mtimeMs)) { + return stat.mtimeMs + } + return stat.mtime > 10_000_000_000 ? stat.mtime : stat.mtime * 1000 +} + +async function mapDiscoveryConcurrently( + items: readonly T[], + mapper: (item: T) => Promise +): Promise { + const results: U[] = [] + for (let index = 0; index < items.length; index += REMOTE_DISCOVERY_CONCURRENCY) { + const batch = items.slice(index, index + REMOTE_DISCOVERY_CONCURRENCY) + results.push(...(await Promise.all(batch.map(mapper)))) + } + return results +} diff --git a/src/main/ai-vault/remote-session-scanner-sources.ts b/src/main/ai-vault/remote-session-scanner-sources.ts index c05261de31d..c33a0ae7701 100644 --- a/src/main/ai-vault/remote-session-scanner-sources.ts +++ b/src/main/ai-vault/remote-session-scanner-sources.ts @@ -1,6 +1,8 @@ import type { AiVaultAgent, AiVaultSession } from '../../shared/ai-vault-types' import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' import { joinRemotePath } from '../ssh/ssh-remote-platform' +import { parseAntigravitySessionContent } from './session-scanner-antigravity-parser' +import { isAntigravityTranscriptPath } from './session-scanner-antigravity-paths' import { parseCodexSessionContent } from './session-scanner-codex-parser' import { parseDevinSessionContent } from './session-scanner-devin-parser' import { parseDroidSessionContent } from './session-scanner-droid-parser' @@ -49,6 +51,7 @@ export function remoteSessionSources( // top-level sessions carrying the parent's sessionId. collectSubagentSiblingCounts: true }, + remoteAntigravitySource(remoteHome, hostPlatform), source( 'gemini', remoteHome, @@ -108,6 +111,30 @@ export function remoteSessionSources( ] } +function remoteAntigravitySource( + remoteHome: string, + hostPlatform: RemoteHostPlatform +): RemoteSessionSource { + const cliRoot = joinRemotePath(hostPlatform, remoteHome, '.gemini', 'antigravity-cli') + const historyPath = joinRemotePath(hostPlatform, cliRoot, 'history.jsonl') + return { + agent: 'antigravity', + rootDir: joinRemotePath(hostPlatform, cliRoot, 'brain'), + extensions: ['.jsonl'], + filePredicate: isAntigravityTranscriptPath, + fixedChildFileSegments: ['.system_generated', 'logs', 'transcript.jsonl'], + parse: async (file, content, context) => { + const session = await parseAntigravitySessionContent( + file, + content, + context.hostPlatform.os, + parserOptions(context) + ) + return session ? context.antigravityWorkspaceResolver.enrich(session, historyPath) : null + } + } +} + function source( agent: AiVaultAgent, remoteHome: string, @@ -115,13 +142,15 @@ function source( segments: readonly string[], extensions: readonly string[], parseContent: RemoteContentParser, - filePredicate?: (path: string) => boolean + filePredicate?: (path: string) => boolean, + directoryPredicate?: (name: string, depth: number) => boolean ): RemoteSessionSource { return { agent, rootDir: joinRemotePath(hostPlatform, remoteHome, ...segments), extensions, filePredicate, + directoryPredicate, parse: (file, content, context) => Promise.resolve(parseContent(file, content, context.hostPlatform.os, parserOptions(context))) } diff --git a/src/main/ai-vault/remote-session-scanner-types.ts b/src/main/ai-vault/remote-session-scanner-types.ts index c65ef99aded..4da4bd3f4a6 100644 --- a/src/main/ai-vault/remote-session-scanner-types.ts +++ b/src/main/ai-vault/remote-session-scanner-types.ts @@ -3,12 +3,14 @@ import type { ExecutionHostId } from '../../shared/execution-host' import type { IFilesystemProvider } from '../providers/types' import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' import type { FileWithMtime } from './session-scanner-types' +import type { AntigravityWorkspaceResolver } from './session-scanner-antigravity-history' export type RemoteScannerContext = { provider: IFilesystemProvider executionHostId: ExecutionHostId hostPlatform: RemoteHostPlatform titleCaches: Map>> + antigravityWorkspaceResolver: AntigravityWorkspaceResolver } export type RemoteParserOptions = { @@ -21,6 +23,10 @@ export type RemoteSessionSource = { rootDir: string extensions: readonly string[] filePredicate?: (path: string) => boolean + // Depth 0 denotes a direct child of rootDir. + directoryPredicate?: (name: string, depth: number) => boolean + // A canonical file directly beneath every top-level session directory. + fixedChildFileSegments?: readonly string[] // Claude layout: count `/subagents/*.jsonl` siblings from the walked // listing and drop them from candidates instead of indexing them as sessions. collectSubagentSiblingCounts?: boolean diff --git a/src/main/ai-vault/remote-session-scanner.test.ts b/src/main/ai-vault/remote-session-scanner.test.ts index a2280b29165..4f9cbd73bb0 100644 --- a/src/main/ai-vault/remote-session-scanner.test.ts +++ b/src/main/ai-vault/remote-session-scanner.test.ts @@ -6,13 +6,29 @@ import { scanRemoteAiVaultSessions } from './remote-session-scanner' class MemoryRemoteProvider implements IFilesystemProvider { private readonly files = new Map() + private readonly readDirErrors = new Map() + private readonly statErrors = new Map() + readonly readDirPaths: string[] = [] addFile(path: string, content: string, mtimeMs: number): void { this.files.set(normalize(path), { content, mtimeMs }) } + failStat(path: string, error: Error): void { + this.statErrors.set(normalize(path), error) + } + + failReadDir(path: string, error: Error): void { + this.readDirErrors.set(normalize(path), error) + } + async readDir(dirPath: string): Promise { const dir = normalize(dirPath) + this.readDirPaths.push(dir) + const readDirError = this.readDirErrors.get(dir) + if (readDirError) { + throw readDirError + } const prefix = dir.endsWith('/') ? dir : `${dir}/` const entries = new Map() for (const path of this.files.keys()) { @@ -45,6 +61,10 @@ class MemoryRemoteProvider implements IFilesystemProvider { } async stat(filePath: string): Promise { + const statError = this.statErrors.get(normalize(filePath)) + if (statError) { + throw statError + } const file = this.files.get(normalize(filePath)) if (!file) { throw new Error(`ENOENT: ${filePath}`) @@ -201,6 +221,184 @@ describe('scanRemoteAiVaultSessions', () => { }) }) + it('parses only canonical Antigravity transcripts on SSH hosts', async () => { + const provider = new MemoryRemoteProvider() + const sessionId = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' + const logsDir = `/home/ada/.gemini/antigravity-cli/brain/${sessionId}/.system_generated/logs` + provider.addFile( + `${logsDir}/transcript.jsonl`, + jsonLines([ + { + source: 'USER_EXPLICIT', + type: 'USER_INPUT', + created_at: '2026-07-15T11:39:10Z', + content: 'Fix remote Antigravity history' + }, + { + source: 'MODEL', + type: 'PLANNER_RESPONSE', + created_at: '2026-07-15T11:39:12Z', + content: 'Done' + } + ]), + 50 + ) + provider.addFile(`${logsDir}/transcript_full.jsonl`, 'duplicate', 51) + provider.addFile( + `/home/ada/.gemini/antigravity-cli/brain/${sessionId}/artifacts/task.jsonl`, + 'not a transcript', + 52 + ) + provider.addFile( + '/home/ada/.gemini/antigravity-cli/history.jsonl', + jsonLines([ + { + display: 'Fix remote Antigravity history', + timestamp: Date.parse('2026-07-15T11:39:10.100Z'), + workspace: '/home/ada/project' + } + ]), + 53 + ) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64'), + scopePaths: ['/home/ada/project'] + }) + + expect(result.issues).toEqual([]) + expect(result.sessions).toHaveLength(1) + expect(result.sessions[0]).toMatchObject({ + executionHostId: 'ssh:dev-box', + executionHostPlatform: 'linux', + agent: 'antigravity', + sessionId, + title: 'Fix remote Antigravity history', + cwd: '/home/ada/project', + messageCount: 2, + resumeCommand: `agy --conversation '${sessionId}'`, + filePath: `${logsDir}/transcript.jsonl` + }) + expect( + provider.readDirPaths.filter((path) => + path.startsWith('/home/ada/.gemini/antigravity-cli/brain') + ) + ).toEqual(['/home/ada/.gemini/antigravity-cli/brain']) + }) + + it('keeps Antigravity SSH discovery to one listing as the session store grows', async () => { + const provider = new MemoryRemoteProvider() + const brainDir = '/home/ada/.gemini/antigravity-cli/brain' + for (let index = 0; index < 40; index++) { + provider.addFile( + `${brainDir}/session-${index}/.system_generated/logs/transcript.jsonl`, + jsonLines([ + { + source: 'USER_EXPLICIT', + type: 'USER_INPUT', + created_at: `2026-07-15T11:39:${String(index).padStart(2, '0')}Z`, + content: `Remote session ${index}` + } + ]), + 100 + index + ) + } + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64') + }) + + expect(result.issues).toEqual([]) + expect(result.sessions).toHaveLength(40) + expect(provider.readDirPaths.filter((path) => path.startsWith(brainDir))).toEqual([brainDir]) + }) + + it('ignores missing canonical Antigravity transcripts but reports other stat failures', async () => { + const provider = new MemoryRemoteProvider() + const brainDir = '/home/ada/.gemini/antigravity-cli/brain' + provider.addFile(`${brainDir}/missing-session/artifacts/task.jsonl`, 'artifact', 1) + const deniedTranscript = `${brainDir}/denied-session/.system_generated/logs/transcript.jsonl` + provider.addFile(deniedTranscript, 'unreadable', 2) + provider.failStat( + deniedTranscript, + new Error(`EACCES: permission denied, stat '${deniedTranscript}'`) + ) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64') + }) + + expect(result.sessions).toEqual([]) + expect(result.issues).toEqual([ + expect.objectContaining({ + agent: 'antigravity', + path: deniedTranscript, + message: expect.stringContaining('EACCES') + }) + ]) + }) + + it('reports non-missing fixed and recursive remote directory failures', async () => { + const provider = new MemoryRemoteProvider() + const brainDir = '/home/ada/.gemini/antigravity-cli/brain' + const claudeProjectDir = '/home/ada/.claude/projects/repo' + provider.addFile(`${claudeProjectDir}/session.jsonl`, 'unreadable', 1) + provider.failReadDir(brainDir, new Error(`EACCES: permission denied, scandir '${brainDir}'`)) + provider.failReadDir( + claudeProjectDir, + new Error(`ECONNRESET: connection lost while reading '${claudeProjectDir}'`) + ) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64') + }) + + expect(result.sessions).toEqual([]) + expect(result.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + agent: 'antigravity', + path: brainDir, + message: expect.stringContaining('EACCES') + }), + expect.objectContaining({ + agent: 'claude', + path: claudeProjectDir, + message: expect.stringContaining('ECONNRESET') + }) + ]) + ) + expect(result.issues).toHaveLength(2) + }) + + it('keeps missing optional remote directories silent', async () => { + const provider = new MemoryRemoteProvider() + const brainDir = '/home/ada/.gemini/antigravity-cli/brain' + provider.failReadDir(brainDir, new Error(`ENOENT: no such directory, scandir '${brainDir}'`)) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64') + }) + + expect(result.sessions).toEqual([]) + expect(result.issues).toEqual([]) + }) + it('excludes Claude subagent transcripts from remote scans', async () => { const provider = new MemoryRemoteProvider() provider.addFile( @@ -357,6 +555,49 @@ describe('scanRemoteAiVaultSessions', () => { ) }) + it('loads Antigravity workspace history with Windows remote paths', async () => { + const provider = new MemoryRemoteProvider() + const sessionId = 'dddddddd-eeee-4fff-8aaa-bbbbbbbbbbbb' + provider.addFile( + `C:/Users/Ada/.gemini/antigravity-cli/brain/${sessionId}/.system_generated/logs/transcript.jsonl`, + jsonLines([ + { + source: 'USER_EXPLICIT', + type: 'USER_INPUT', + created_at: '2026-07-15T11:39:10.000Z', + content: 'Windows Antigravity title' + } + ]), + 30 + ) + provider.addFile( + 'C:/Users/Ada/.gemini/antigravity-cli/history.jsonl', + jsonLines([ + { + display: 'Windows Antigravity title', + timestamp: Date.parse('2026-07-15T11:39:10.100Z'), + workspace: 'C:/repo/app' + } + ]), + 31 + ) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:win-box', + remoteHome: 'C:/Users/Ada', + hostPlatform: getRemoteHostPlatform('win32-x64') + }) + + expect(result.issues).toEqual([]) + expect(result.sessions[0]).toMatchObject({ + agent: 'antigravity', + sessionId, + cwd: 'C:/repo/app', + executionHostPlatform: 'win32' + }) + }) + it('continues past skipped candidates to fill the remote scan limit', async () => { const provider = new MemoryRemoteProvider() provider.addFile( diff --git a/src/main/ai-vault/remote-session-scanner.ts b/src/main/ai-vault/remote-session-scanner.ts index cc5ae37dad3..c8e07929b61 100644 --- a/src/main/ai-vault/remote-session-scanner.ts +++ b/src/main/ai-vault/remote-session-scanner.ts @@ -1,25 +1,18 @@ -import { extname } from 'node:path' import type { - AiVaultAgent, AiVaultListResult, AiVaultScanIssue, AiVaultSession } from '../../shared/ai-vault-types' import { isPathInsideOrEqual } from '../../shared/cross-platform-path' import type { ExecutionHostId } from '../../shared/execution-host' -import type { FileStat, IFilesystemProvider } from '../providers/types' +import type { IFilesystemProvider } from '../providers/types' import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' -import { joinRemotePath } from '../ssh/ssh-remote-platform' import { sessionSortTime } from './session-scanner-accumulator' -import { partitionSubagentTranscriptPaths } from './session-scanner-subagent-transcripts' -import type { FileWithMtime } from './session-scanner-types' +import { createAntigravityWorkspaceResolver } from './session-scanner-antigravity-history' import { errorMessage } from './session-scanner-values' +import { discoverRemoteSourceCandidates } from './remote-session-scanner-discovery' import { remoteSessionSources } from './remote-session-scanner-sources' -import type { - RemoteScannerContext, - RemoteSessionCandidate, - RemoteSessionSource -} from './remote-session-scanner-types' +import type { RemoteScannerContext, RemoteSessionCandidate } from './remote-session-scanner-types' const DEFAULT_REMOTE_SCAN_LIMIT = 1000 const REMOTE_SCAN_CONCURRENCY = 8 @@ -39,7 +32,15 @@ export async function scanRemoteAiVaultSessions(args: { provider: args.provider, executionHostId: args.executionHostId, hostPlatform: args.hostPlatform, - titleCaches: new Map() + titleCaches: new Map(), + antigravityWorkspaceResolver: createAntigravityWorkspaceResolver(async (historyPath) => { + try { + const read = await args.provider.readFile(historyPath) + return read.isBinary ? null : read.content + } catch { + return null + } + }) } const candidates = ( await mapRemoteScanConcurrently( @@ -73,70 +74,6 @@ export async function scanRemoteAiVaultSessions(args: { } } -async function discoverRemoteSourceCandidates(args: { - source: RemoteSessionSource - context: RemoteScannerContext - issues: AiVaultScanIssue[] -}): Promise { - const walked = await walkRemoteSessionFiles( - args.source, - args.context.provider, - args.context.hostPlatform - ) - const partition = args.source.collectSubagentSiblingCounts - ? partitionSubagentTranscriptPaths(walked) - : null - const paths = partition ? partition.sessionFilePaths : walked - const files = await mapRemoteScanConcurrently(paths, (path) => - statRemoteFile( - args.context.provider, - path, - args.source.agent, - args.context.executionHostId, - args.issues - ) - ) - return files - .filter((file): file is FileWithMtime => Boolean(file)) - .map((file) => ({ - source: args.source, - file, - subagentTranscriptCount: partition?.subagentTranscriptCounts.get(file.path) ?? 0 - })) -} - -async function walkRemoteSessionFiles( - source: RemoteSessionSource, - provider: IFilesystemProvider, - hostPlatform: RemoteHostPlatform, - dirPath = source.rootDir -): Promise { - let entries - try { - entries = await provider.readDir(dirPath) - } catch { - return [] - } - - const extensions = new Set(source.extensions) - const files: string[] = [] - for (const entry of entries) { - const fullPath = joinRemotePath(hostPlatform, dirPath, entry.name) - if (entry.isDirectory && !entry.isSymlink) { - files.push(...(await walkRemoteSessionFiles(source, provider, hostPlatform, fullPath))) - continue - } - if ( - !entry.isSymlink && - extensions.has(extname(entry.name).toLowerCase()) && - (source.filePredicate?.(fullPath) ?? true) - ) { - files.push(fullPath) - } - } - return files -} - async function parseRemoteSessionCandidates(args: { candidates: readonly RemoteSessionCandidate[] context: RemoteScannerContext @@ -254,30 +191,6 @@ function normalizeRemoteScopePaths(scopePaths: readonly string[]): string[] { return scopePaths.map((scopePath) => scopePath.trim()).filter(Boolean) } -async function statRemoteFile( - provider: IFilesystemProvider, - path: string, - agent: AiVaultAgent, - executionHostId: ExecutionHostId, - issues: AiVaultScanIssue[] -): Promise { - try { - const stat = await provider.stat(path) - const mtimeMs = remoteStatMtimeMs(stat) - return { path, mtimeMs, modifiedAt: new Date(mtimeMs).toISOString() } - } catch (err) { - issues.push({ executionHostId, agent, path, message: errorMessage(err) }) - return null - } -} - -function remoteStatMtimeMs(stat: FileStat): number { - if (typeof stat.mtimeMs === 'number' && Number.isFinite(stat.mtimeMs)) { - return stat.mtimeMs - } - return stat.mtime > 10_000_000_000 ? stat.mtime : stat.mtime * 1000 -} - function canStopParsingRemoteSessions( sessions: AiVaultSession[], limit: number, diff --git a/src/main/ai-vault/session-scanner-agent-parser.ts b/src/main/ai-vault/session-scanner-agent-parser.ts index 22c6cd46abc..029227d8745 100644 --- a/src/main/ai-vault/session-scanner-agent-parser.ts +++ b/src/main/ai-vault/session-scanner-agent-parser.ts @@ -1,5 +1,6 @@ import type { AiVaultSession } from '../../shared/ai-vault-types' import { parseDevinSessionFile } from './session-scanner-devin-parser' +import { parseAntigravitySessionFile } from './session-scanner-antigravity-parser' import { parseDroidSessionFile } from './session-scanner-droid-parser' import { parseGrokSessionFile } from './session-scanner-grok-parser' import { parseMessageGraphSessionFile, parseRovoSessionFile } from './session-scanner-graph-parsers' @@ -37,6 +38,8 @@ export async function parseAgentSessionFile( return parseCodexSessionFile(candidate.file, platform, candidate.codexHome) case 'gemini': return parseGeminiSessionFile(candidate.file, platform) + case 'antigravity': + return parseAntigravitySessionFile(candidate.file, platform) case 'copilot': return parseCopilotSessionFile(candidate.file, platform) case 'cursor': diff --git a/src/main/ai-vault/session-scanner-antigravity-fixtures.ts b/src/main/ai-vault/session-scanner-antigravity-fixtures.ts new file mode 100644 index 00000000000..cbcab494933 --- /dev/null +++ b/src/main/ai-vault/session-scanner-antigravity-fixtures.ts @@ -0,0 +1,35 @@ +import type { IncrementalAgentFixture } from './session-scanner-incremental-fixtures' + +export function antigravityFixture(): IncrementalAgentFixture { + const record = (source: string, type: string, content: string, createdAt: string) => + JSON.stringify({ source, type, content, created_at: createdAt }) + return { + agent: 'antigravity', + fileName: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee/.system_generated/logs/transcript.jsonl', + seedLines: [ + record( + 'USER_EXPLICIT', + 'USER_INPUT', + 'antigravity seed question', + '2026-05-01T10:00:00.000Z' + ), + record('MODEL', 'PLANNER_RESPONSE', 'antigravity seed answer', '2026-05-01T10:00:30.000Z') + ], + appendLines: [ + record( + 'USER', + 'REQUEST', + 'antigravity follow-up', + '2026-05-01T10:01:00.000Z' + ) + ], + truncatedLines: [ + record( + 'USER_EXPLICIT', + 'USER_INPUT', + 'antigravity rewritten', + '2026-05-01T10:00:00.000Z' + ) + ] + } +} diff --git a/src/main/ai-vault/session-scanner-antigravity-history.ts b/src/main/ai-vault/session-scanner-antigravity-history.ts new file mode 100644 index 00000000000..989ac7b4179 --- /dev/null +++ b/src/main/ai-vault/session-scanner-antigravity-history.ts @@ -0,0 +1,77 @@ +import type { AiVaultSession } from '../../shared/ai-vault-types' +import { normalizeTitleText, parseJsonObject, timestampMs } from './session-scanner-values' + +const HISTORY_MATCH_WINDOW_MS = 2_000 + +type AntigravityHistoryEntry = { + timestampMs: number + workspace: string +} + +type AntigravityHistoryIndex = Map + +export type AntigravityWorkspaceResolver = { + enrich(session: AiVaultSession, historyPath: string): Promise +} + +export function createAntigravityWorkspaceResolver( + readHistory: (historyPath: string) => Promise +): AntigravityWorkspaceResolver { + const indexes = new Map>() + + return { + async enrich(session, historyPath) { + if (session.agent !== 'antigravity' || session.cwd) { + return session + } + let index = indexes.get(historyPath) + if (!index) { + index = readHistory(historyPath).then(indexAntigravityHistory) + indexes.set(historyPath, index) + } + const workspace = findAntigravityWorkspace(session, await index) + return workspace ? { ...session, cwd: workspace } : session + } + } +} + +function indexAntigravityHistory(content: string | null): AntigravityHistoryIndex { + const index: AntigravityHistoryIndex = new Map() + for (const line of content?.split(/\r?\n/) ?? []) { + const record = parseJsonObject(line) + const display = typeof record?.display === 'string' ? normalizeTitleText(record.display) : null + const workspace = typeof record?.workspace === 'string' ? record.workspace.trim() : '' + const entryTimestampMs = timestampMs(record?.timestamp) + if (!display || !workspace || !Number.isFinite(entryTimestampMs)) { + continue + } + const entries = index.get(display) ?? [] + entries.push({ timestampMs: entryTimestampMs, workspace }) + index.set(display, entries) + } + return index +} + +function findAntigravityWorkspace( + session: AiVaultSession, + index: AntigravityHistoryIndex +): string | null { + // Why: truncated titles are not prompt identities; long worker prompts often + // share the same 96-character prefix across unrelated workspaces. + if (session.title.endsWith('...')) { + return null + } + const firstTitledUserTimestamp = session.previewMessages.find( + (message) => message.role === 'user' && normalizeTitleText(message.text) === session.title + )?.timestamp + const promptTimestampMs = timestampMs(firstTitledUserTimestamp ?? session.createdAt) + if (!Number.isFinite(promptTimestampMs)) { + return null + } + const matches = (index.get(session.title) ?? []).filter( + (entry) => Math.abs(entry.timestampMs - promptTimestampMs) <= HISTORY_MATCH_WINDOW_MS + ) + // Why: history rows have no conversation id. A unique prompt/time match is + // evidence for cwd; ambiguity must stay unknown instead of crossing projects. + return matches.length === 1 ? (matches[0]?.workspace ?? null) : null +} diff --git a/src/main/ai-vault/session-scanner-antigravity-parser.test.ts b/src/main/ai-vault/session-scanner-antigravity-parser.test.ts new file mode 100644 index 00000000000..c8d037098ca --- /dev/null +++ b/src/main/ai-vault/session-scanner-antigravity-parser.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest' +import { parseAntigravitySessionContent } from './session-scanner-antigravity-parser' +import type { FileWithMtime } from './session-scanner-types' +import { jsonLines } from './session-scanner-test-fixtures' + +const SESSION_ID = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' + +describe('Antigravity AI Vault parser', () => { + it('parses the established transcript contract without leaking metadata wrappers', async () => { + const session = await parseAntigravitySessionContent( + file( + `/home/ada/.gemini/antigravity-cli/brain/${SESSION_ID}/.system_generated/logs/transcript.jsonl` + ), + jsonLines([ + { + step_index: 0, + source: 'USER_EXPLICIT', + type: 'USER_INPUT', + status: 'DONE', + created_at: '2026-07-15T11:39:10Z', + content: + '\nFix the vault adapter\n\nignored' + }, + { + step_index: 1, + source: 'SYSTEM', + type: 'CONVERSATION_HISTORY', + created_at: '2026-07-15T11:39:11Z' + }, + { + step_index: 2, + source: 'MODEL', + type: 'PLANNER_RESPONSE', + status: 'DONE', + created_at: '2026-07-15T11:39:12Z', + content: 'The adapter is ready.' + }, + { + step_index: 3, + source: 'SYSTEM', + type: 'CHECKPOINT', + created_at: '2026-07-15T11:39:14Z', + content: 'internal checkpoint' + } + ]), + 'linux' + ) + + expect(session).toMatchObject({ + agent: 'antigravity', + sessionId: SESSION_ID, + title: 'Fix the vault adapter', + cwd: null, + model: null, + createdAt: '2026-07-15T11:39:10.000Z', + updatedAt: '2026-07-15T11:39:14.000Z', + messageCount: 2, + resumeCommand: `agy --conversation '${SESSION_ID}'` + }) + expect(session?.previewMessages).toEqual([ + { + role: 'user', + text: 'Fix the vault adapter', + timestamp: '2026-07-15T11:39:10.000Z' + }, + { + role: 'assistant', + text: 'The adapter is ready.', + timestamp: '2026-07-15T11:39:12.000Z' + } + ]) + }) + + it('derives the conversation id from Windows paths and quotes the resume command', async () => { + const session = await parseAntigravitySessionContent( + file( + `C:\\Users\\Ada\\.gemini\\antigravity-cli\\brain\\${SESSION_ID}\\.system_generated\\logs\\transcript.jsonl` + ), + jsonLines([ + { + source: 'USER', + type: 'REQUEST', + created_at: '2026-07-15T11:39:10Z', + content: 'Windows prompt' + } + ]), + 'win32' + ) + + expect(session).toMatchObject({ + sessionId: SESSION_ID, + title: 'Windows prompt', + resumeCommand: `agy --conversation "${SESSION_ID}"` + }) + }) + + it('rejects files outside the targeted Antigravity transcript layout', async () => { + await expect( + parseAntigravitySessionContent( + file('/home/ada/.gemini/antigravity-cli/brain/transcript.jsonl'), + jsonLines([{ source: 'USER', type: 'REQUEST', content: 'Not a conversation transcript' }]) + ) + ).resolves.toBeNull() + }) +}) + +function file(path: string): FileWithMtime { + return { + path, + mtimeMs: Date.parse('2026-07-15T11:39:14Z'), + modifiedAt: '2026-07-15T11:39:14.000Z', + sizeBytes: 1 + } +} diff --git a/src/main/ai-vault/session-scanner-antigravity-parser.ts b/src/main/ai-vault/session-scanner-antigravity-parser.ts new file mode 100644 index 00000000000..2e3a3a08b4a --- /dev/null +++ b/src/main/ai-vault/session-scanner-antigravity-parser.ts @@ -0,0 +1,120 @@ +import { createReadStream } from 'node:fs' +import { createInterface } from 'node:readline' +import type { AiVaultSession } from '../../shared/ai-vault-types' +import type { ExecutionHostId } from '../../shared/execution-host' +import { + accumulatorFoldResumeState, + addPreviewMessage, + createAccumulator, + updateTimeline +} from './session-scanner-accumulator' +import { antigravityConversationIdFromTranscriptPath } from './session-scanner-antigravity-paths' +import type { + FileWithMtime, + ResumableSessionParseState, + SessionAccumulator +} from './session-scanner-types' +import { extractString, normalizeTitleText, parseJsonObject } from './session-scanner-values' + +type ParserSessionOptions = { + executionHostId?: ExecutionHostId + executionHostPlatform?: NodeJS.Platform | null +} + +export async function parseAntigravitySessionFile( + file: FileWithMtime, + platform: NodeJS.Platform = process.platform +): Promise { + const lines = createInterface({ + input: createReadStream(file.path, { encoding: 'utf-8' }), + crlfDelay: Infinity + }) + return parseAntigravitySessionLines({ file, lines, platform }) +} + +export async function parseAntigravitySessionContent( + file: FileWithMtime, + content: string, + platform: NodeJS.Platform = process.platform, + options: ParserSessionOptions = {} +): Promise { + return parseAntigravitySessionLines({ + file, + lines: content.split(/\r?\n/), + platform, + options + }) +} + +export function createAntigravitySessionResumeState( + file: FileWithMtime +): ResumableSessionParseState { + const sessionId = antigravityConversationIdFromTranscriptPath(file.path) ?? '' + // Why: the transcript has no cwd/model fields. Workspace enrichment is a + // separate, conservative history join; protobuf/SQLite blobs are unstable. + return accumulatorFoldResumeState( + createAccumulator({ agent: 'antigravity', file, sessionId }), + consumeAntigravityRecordLine + ) +} + +async function parseAntigravitySessionLines(args: { + file: FileWithMtime + lines: AsyncIterable | Iterable + platform: NodeJS.Platform + options?: ParserSessionOptions +}): Promise { + const state = createAntigravitySessionResumeState(args.file) + for await (const line of args.lines) { + state.consumeLine(line) + } + return state.finalize(args.platform, args.options) +} + +function consumeAntigravityRecordLine(accumulator: SessionAccumulator, line: string): void { + const record = parseJsonObject(line) + if (!record) { + return + } + + updateTimeline(accumulator, record.created_at) + const source = extractString(record.source) + const type = extractString(record.type) + const content = extractString(record.content) + + if ( + (source === 'USER_EXPLICIT' || source === 'USER') && + (type === 'USER_INPUT' || type === 'REQUEST') + ) { + const request = extractAntigravityUserRequest(content ?? '') + if (!request) { + return + } + accumulator.messageCount++ + accumulator.title ??= normalizeTitleText(request) + addPreviewMessage(accumulator, { role: 'user', text: request, timestamp: record.created_at }) + return + } + + if (source === 'MODEL' && type === 'PLANNER_RESPONSE' && content) { + accumulator.messageCount++ + addPreviewMessage(accumulator, { + role: 'assistant', + text: content, + timestamp: record.created_at + }) + } +} + +function extractAntigravityUserRequest(content: string): string | null { + const opener = '' + const startIndex = content.indexOf(opener) + if (startIndex === -1) { + return extractString(content) + } + const bodyStart = startIndex + opener.length + const endIndex = content.indexOf('', bodyStart) + return extractString( + endIndex === -1 ? content.slice(bodyStart) : content.slice(bodyStart, endIndex) + ) +} diff --git a/src/main/ai-vault/session-scanner-antigravity-paths.ts b/src/main/ai-vault/session-scanner-antigravity-paths.ts new file mode 100644 index 00000000000..c505cb7e5ee --- /dev/null +++ b/src/main/ai-vault/session-scanner-antigravity-paths.ts @@ -0,0 +1,42 @@ +import { dirname, join } from 'node:path' + +const ANTIGRAVITY_TRANSCRIPT_FILE = 'transcript.jsonl' +const ANTIGRAVITY_SYSTEM_DIR = '.system_generated' +const ANTIGRAVITY_LOGS_DIR = 'logs' + +export function antigravityConversationIdFromTranscriptPath(filePath: string): string | null { + const segments = pathSegments(filePath) + const transcriptIndex = segments.length - 1 + if ( + segments[transcriptIndex] !== ANTIGRAVITY_TRANSCRIPT_FILE || + segments[transcriptIndex - 1] !== ANTIGRAVITY_LOGS_DIR || + segments[transcriptIndex - 2] !== ANTIGRAVITY_SYSTEM_DIR + ) { + return null + } + return segments[transcriptIndex - 3] ?? null +} + +export function isAntigravityTranscriptPath(filePath: string): boolean { + return antigravityConversationIdFromTranscriptPath(filePath) !== null +} + +export function shouldDescendAntigravityBrainDirectory(name: string, depth: number): boolean { + // Why: brain entries contain large artifact trees; only the fixed transcript + // chain is part of the resumable conversation contract. + if (depth === 0) { + return true + } + if (depth === 1) { + return name === ANTIGRAVITY_SYSTEM_DIR + } + return depth === 2 && name === ANTIGRAVITY_LOGS_DIR +} + +export function antigravityHistoryPathForBrainDir(brainDir: string): string { + return join(dirname(brainDir), 'history.jsonl') +} + +function pathSegments(filePath: string): string[] { + return filePath.split(/[\\/]+/).filter(Boolean) +} diff --git a/src/main/ai-vault/session-scanner-antigravity-source.test.ts b/src/main/ai-vault/session-scanner-antigravity-source.test.ts new file mode 100644 index 00000000000..454b3cf92f6 --- /dev/null +++ b/src/main/ai-vault/session-scanner-antigravity-source.test.ts @@ -0,0 +1,167 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { filterAiVaultSessions } from '../../shared/ai-vault-session-filters' +import { AI_VAULT_AGENTS } from '../../shared/ai-vault-types' +import { scanAiVaultSessions } from './session-scanner' +import { + isolatedScanRoots, + writeAntigravityHistory, + writeAntigravityTranscript +} from './session-scanner-test-fixtures' + +const tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('Antigravity AI Vault discovery', () => { + it('discovers canonical transcripts from WSL homes without indexing sibling artifacts', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-antigravity-wsl-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const wslHome = join(root, 'wsl-home') + const sessionId = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' + const transcriptPath = await writeAntigravityTranscript( + join(wslHome, '.gemini', 'antigravity-cli', 'brain'), + sessionId, + [ + { + source: 'USER_EXPLICIT', + type: 'USER_INPUT', + created_at: '2026-07-15T11:39:10Z', + content: 'Index the WSL conversation' + } + ] + ) + await writeFile(join(dirname(transcriptPath), 'transcript_full.jsonl'), 'duplicate') + await writeAntigravityHistory(join(wslHome, '.gemini', 'antigravity-cli', 'brain'), [ + { + display: 'Index the WSL conversation', + timestamp: Date.parse('2026-07-15T11:39:10.100Z') / 1000, + workspace: '/home/ada/project' + } + ]) + + const result = await scanAiVaultSessions({ + ...roots, + wslHomeDirs: [wslHome], + platform: 'linux' + }) + + expect(result.issues).toEqual([]) + expect(result.sessions).toHaveLength(1) + expect(result.sessions[0]).toMatchObject({ + agent: 'antigravity', + sessionId, + title: 'Index the WSL conversation', + cwd: '/home/ada/project', + resumeCommand: `agy --conversation '${sessionId}'`, + filePath: transcriptPath + }) + }) + + it('uses a unique history match in the default workspace view', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-antigravity-workspace-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const sessionId = 'bbbbbbbb-cccc-4ddd-8eee-ffffffffffff' + const workspace = join(root, 'active-workspace') + await writeAntigravityTranscript(roots.antigravityBrainDir, sessionId, [ + { + source: 'USER_EXPLICIT', + type: 'USER_INPUT', + created_at: '2026-07-15T11:39:10.000Z', + content: 'ORCA_8742_REPRO' + } + ]) + + const beforeHistory = await scanAiVaultSessions({ ...roots, platform: 'darwin' }) + expect(beforeHistory.sessions[0]?.cwd).toBeNull() + + await writeAntigravityHistory(roots.antigravityBrainDir, [ + { + display: 'ORCA_8742_REPRO', + timestamp: Date.parse('2026-07-15T11:39:10.100Z'), + workspace + } + ]) + // The transcript is unchanged, so this also covers history refresh after a + // cached incremental parse has already been stored. + const result = await scanAiVaultSessions({ ...roots, platform: 'darwin' }) + + expect(result.sessions[0]?.cwd).toBe(workspace) + expect( + filterAiVaultSessions(result.sessions, { + query: '', + agents: AI_VAULT_AGENTS, + scope: 'workspace', + sort: 'updated', + activeWorktreePaths: [workspace], + hideEmptySessions: true + }).map((session) => session.sessionId) + ).toEqual([sessionId]) + }) + + it('keeps workspace unknown when matching history rows are ambiguous', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-antigravity-ambiguous-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const sessionId = 'cccccccc-dddd-4eee-8fff-aaaaaaaaaaaa' + await writeAntigravityTranscript(roots.antigravityBrainDir, sessionId, [ + { + source: 'USER_EXPLICIT', + type: 'USER_INPUT', + created_at: '2026-07-15T11:39:10.000Z', + content: 'Repeated prompt' + } + ]) + await writeAntigravityHistory(roots.antigravityBrainDir, [ + { + display: 'Repeated prompt', + timestamp: Date.parse('2026-07-15T11:39:09.900Z'), + workspace: '/repo/one' + }, + { + display: 'Repeated prompt', + timestamp: Date.parse('2026-07-15T11:39:10.100Z'), + workspace: '/repo/two' + } + ]) + + const result = await scanAiVaultSessions({ ...roots, platform: 'linux' }) + + expect(result.sessions[0]?.cwd).toBeNull() + }) + + it('does not join workspace from a colliding truncated prompt title', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-antigravity-long-title-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const sessionId = 'eeeeeeee-ffff-4aaa-8bbb-cccccccccccc' + const commonPrefix = 'shared worker preamble '.repeat(7) + await writeAntigravityTranscript(roots.antigravityBrainDir, sessionId, [ + { + source: 'USER_EXPLICIT', + type: 'USER_INPUT', + created_at: '2026-07-15T11:39:10.000Z', + content: `${commonPrefix}workspace A` + } + ]) + await writeAntigravityHistory(roots.antigravityBrainDir, [ + { + display: `${commonPrefix}workspace B`, + timestamp: Date.parse('2026-07-15T11:39:10.100Z'), + workspace: '/repo/wrong' + } + ]) + + const result = await scanAiVaultSessions({ ...roots, platform: 'linux' }) + + expect(result.sessions[0]?.title).toHaveLength(96) + expect(result.sessions[0]?.title).toMatch(/\.\.\.$/) + expect(result.sessions[0]?.cwd).toBeNull() + }) +}) diff --git a/src/main/ai-vault/session-scanner-antigravity-sources.ts b/src/main/ai-vault/session-scanner-antigravity-sources.ts new file mode 100644 index 00000000000..a79edc380e6 --- /dev/null +++ b/src/main/ai-vault/session-scanner-antigravity-sources.ts @@ -0,0 +1,34 @@ +import { homedir } from 'node:os' +import { join } from 'node:path' +import type { AiVaultScanIssue } from '../../shared/ai-vault-types' +import { + isAntigravityTranscriptPath, + shouldDescendAntigravityBrainDirectory +} from './session-scanner-antigravity-paths' +import { discoverFiles } from './session-scanner-discovery' +import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner-types' + +const ANTIGRAVITY_BRAIN_DIR = join(homedir(), '.gemini', 'antigravity-cli', 'brain') + +export function antigravityDiscoveries( + options: AiVaultScanOptions, + wslHomeDirs: readonly string[], + limit: number, + issues: AiVaultScanIssue[] +): Promise[] { + const rootDirs = [ + options.antigravityBrainDir ?? ANTIGRAVITY_BRAIN_DIR, + ...wslHomeDirs.map((homeDir) => join(homeDir, '.gemini', 'antigravity-cli', 'brain')) + ] + return rootDirs.map((rootDir) => + discoverFiles({ + rootDir, + limit, + agent: 'antigravity', + issues, + extensions: ['.jsonl'], + filePredicate: isAntigravityTranscriptPath, + directoryPredicate: shouldDescendAntigravityBrainDirectory + }) + ) +} diff --git a/src/main/ai-vault/session-scanner-codex-workers.test.ts b/src/main/ai-vault/session-scanner-codex-workers.test.ts index 8a6fb183f62..d8a8e2850ac 100644 --- a/src/main/ai-vault/session-scanner-codex-workers.test.ts +++ b/src/main/ai-vault/session-scanner-codex-workers.test.ts @@ -106,6 +106,7 @@ describe('scanAiVaultSessions Codex worker sessions', () => { claudeProjectsDir: join(root, 'claude-projects'), codexSessionsDir, geminiSessionsDir: join(root, 'gemini-sessions'), + antigravityBrainDir: join(root, 'antigravity-brain'), copilotSessionsDir: join(root, 'copilot-sessions'), cursorProjectsDir: join(root, 'cursor-projects'), opencodeStorageDir: join(root, 'opencode-storage'), diff --git a/src/main/ai-vault/session-scanner-discovery.ts b/src/main/ai-vault/session-scanner-discovery.ts index c176fb3c6e5..576438a8c49 100644 --- a/src/main/ai-vault/session-scanner-discovery.ts +++ b/src/main/ai-vault/session-scanner-discovery.ts @@ -11,7 +11,7 @@ export async function discoverFiles(args: { issues: AiVaultScanIssue[] extensions: string[] filePredicate?: (path: string) => boolean - directoryPredicate?: (name: string) => boolean + directoryPredicate?: (name: string, depth: number) => boolean }): Promise { const paths = await walkSessionFiles(args.rootDir, args.agent, args.issues, { extensions: new Set(args.extensions), @@ -70,10 +70,11 @@ export async function walkSessionFiles( options: { extensions: Set filePredicate?: (path: string) => boolean - // Return false to skip descending into a directory (matched by its name), - // so pruned subtrees are never stat'd or parsed. - directoryPredicate?: (name: string) => boolean - } + // Return false to skip descending into a directory; depth 0 is a child of + // rootDir, so pruned subtrees are never stat'd or parsed. + directoryPredicate?: (name: string, depth: number) => boolean + }, + depth = 0 ): Promise { let entries try { @@ -88,8 +89,8 @@ export async function walkSessionFiles( if (entry.isDirectory()) { // Skip whole subtrees an agent never wants (e.g. subagent transcripts), // avoiding the readdir cost of descending into them. - if (options.directoryPredicate?.(entry.name) ?? true) { - files.push(...(await walkSessionFiles(fullPath, agent, issues, options))) + if (options.directoryPredicate?.(entry.name, depth) ?? true) { + files.push(...(await walkSessionFiles(fullPath, agent, issues, options, depth + 1))) } continue } diff --git a/src/main/ai-vault/session-scanner-incremental-fixtures.ts b/src/main/ai-vault/session-scanner-incremental-fixtures.ts index 1945193e6c7..54f0171e6c6 100644 --- a/src/main/ai-vault/session-scanner-incremental-fixtures.ts +++ b/src/main/ai-vault/session-scanner-incremental-fixtures.ts @@ -1,5 +1,6 @@ import type { AiVaultAgent } from '../../shared/ai-vault-types' +import { antigravityFixture } from './session-scanner-antigravity-fixtures' import { codexFixture } from './session-scanner-codex-fixtures' // Line builders for the incremental-parse differential tests: each agent gets @@ -286,6 +287,7 @@ export function allIncrementalAgentFixtures(): IncrementalAgentFixture[] { openclawFixture(), piFixture(), ompFixture(), - geminiJsonlFixture() + geminiJsonlFixture(), + antigravityFixture() ] } diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts index 2fb3df21613..1819afe81f2 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts @@ -23,6 +23,7 @@ function isolatedScanRoots(root: string) { claudeProjectsDir: join(root, 'claude-projects'), codexSessionsDir: join(root, 'codex-sessions'), geminiSessionsDir: join(root, 'gemini-sessions'), + antigravityBrainDir: join(root, 'antigravity-brain'), copilotSessionsDir: join(root, 'copilot-sessions'), cursorProjectsDir: join(root, 'cursor-projects'), opencodeStorageDir: join(root, 'opencode-storage'), diff --git a/src/main/ai-vault/session-scanner-parse-cache-agents.test.ts b/src/main/ai-vault/session-scanner-parse-cache-agents.test.ts index 61348ad7cec..cd4977679d2 100644 --- a/src/main/ai-vault/session-scanner-parse-cache-agents.test.ts +++ b/src/main/ai-vault/session-scanner-parse-cache-agents.test.ts @@ -1,6 +1,6 @@ import { appendFile, mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { parseAgentSessionFile } from './session-scanner-agent-parser' import { @@ -55,6 +55,7 @@ describe.each(allIncrementalAgentFixtures())('incremental parse parity: $agent', it('reuses unchanged files, resumes appends, and matches cold parses exactly', async () => { const root = await makeTempDir() const path = join(root, fixture.fileName) + await mkdir(dirname(path), { recursive: true }) await writeFile(path, `${fixture.seedLines.join('\n')}\n`) const stats = createSessionParseStats() @@ -87,6 +88,7 @@ describe.each(allIncrementalAgentFixtures())('incremental parse parity: $agent', it('includes a trailing unterminated line without double-counting it later', async () => { const root = await makeTempDir() const path = join(root, fixture.fileName) + await mkdir(dirname(path), { recursive: true }) const lastSeedLine = fixture.seedLines.at(-1) const headLines = fixture.seedLines.slice(0, -1) await writeFile(path, `${[...headLines, ''].join('\n')}${lastSeedLine}`) @@ -106,6 +108,7 @@ describe.each(allIncrementalAgentFixtures())('incremental parse parity: $agent', it('tolerates a mid-write truncated trailing line and never double-counts it', async () => { const root = await makeTempDir() const path = join(root, fixture.fileName) + await mkdir(dirname(path), { recursive: true }) // A writer caught mid-record: the trailing line is invalid JSON. await writeFile(path, `${fixture.seedLines.join('\n')}\n{"type":"user","mess`) diff --git a/src/main/ai-vault/session-scanner-parse-cache.ts b/src/main/ai-vault/session-scanner-parse-cache.ts index 70d0d311d6b..d42da123437 100644 --- a/src/main/ai-vault/session-scanner-parse-cache.ts +++ b/src/main/ai-vault/session-scanner-parse-cache.ts @@ -1,6 +1,7 @@ import { createReadStream } from 'node:fs' import { open } from 'node:fs/promises' import type { AiVaultSession } from '../../shared/ai-vault-types' +import { createAntigravitySessionResumeState } from './session-scanner-antigravity-parser' import { parseAgentSessionFile } from './session-scanner-agent-parser' import { createCodexSessionResumeState } from './session-scanner-codex-parser' import { createDroidSessionResumeState } from './session-scanner-droid-parser' @@ -67,6 +68,8 @@ function resumableStateFactoryFor( return candidate.file.path.endsWith('.jsonl') ? () => createGeminiJsonlSessionResumeState(candidate.file) : null + case 'antigravity': + return () => createAntigravitySessionResumeState(candidate.file) case 'devin': case 'grok': case 'hermes': diff --git a/src/main/ai-vault/session-scanner-scope.test.ts b/src/main/ai-vault/session-scanner-scope.test.ts index 77931d68715..487748de97a 100644 --- a/src/main/ai-vault/session-scanner-scope.test.ts +++ b/src/main/ai-vault/session-scanner-scope.test.ts @@ -19,6 +19,7 @@ function scopedScanOptions(claudeProjectsDir: string, extra: Partial[] { return [ + ...antigravityDiscoveries(options, wslHomeDirs, limit, issues), ...sessionRootDirs(options.geminiSessionsDir ?? GEMINI_SESSIONS_DIR, wslHomeDirs, [ '.gemini', 'tmp' diff --git a/src/main/ai-vault/session-scanner-test-fixtures.ts b/src/main/ai-vault/session-scanner-test-fixtures.ts index 0a3b6c7c1a8..f69bf2939c7 100644 --- a/src/main/ai-vault/session-scanner-test-fixtures.ts +++ b/src/main/ai-vault/session-scanner-test-fixtures.ts @@ -6,6 +6,7 @@ export function isolatedScanRoots(root: string) { claudeProjectsDir: join(root, 'claude-projects'), codexSessionsDir: join(root, 'codex-sessions'), geminiSessionsDir: join(root, 'gemini-sessions'), + antigravityBrainDir: join(root, 'antigravity-brain'), copilotSessionsDir: join(root, 'copilot-sessions'), cursorProjectsDir: join(root, 'cursor-projects'), opencodeStorageDir: join(root, 'opencode-storage'), @@ -34,3 +35,37 @@ export async function writeJsonlFile(filePath: string, records: unknown[]): Prom await mkdir(dirname(filePath), { recursive: true }) await writeFile(filePath, jsonLines(records)) } + +export async function writeAntigravityTranscript( + brainDir: string, + sessionId: string, + records: unknown[] +): Promise { + const transcriptPath = join(brainDir, sessionId, '.system_generated', 'logs', 'transcript.jsonl') + await writeJsonlFile(transcriptPath, records) + return transcriptPath +} + +export function writeAntigravityHistory(brainDir: string, records: unknown[]): Promise { + return writeJsonlFile(join(dirname(brainDir), 'history.jsonl'), records) +} + +export function writeAntigravityScannerFixture( + brainDir: string, + sessionId: string +): Promise { + return writeAntigravityTranscript(brainDir, sessionId, [ + { + source: 'USER_EXPLICIT', + type: 'USER_INPUT', + created_at: '2026-05-01T10:02:30.000Z', + content: 'Antigravity title' + }, + { + source: 'MODEL', + type: 'PLANNER_RESPONSE', + created_at: '2026-05-01T10:02:31.000Z', + content: 'Done' + } + ]) +} diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts index 803554316c1..efc6660ab1a 100644 --- a/src/main/ai-vault/session-scanner-types.ts +++ b/src/main/ai-vault/session-scanner-types.ts @@ -12,6 +12,7 @@ export type AiVaultScanOptions = { additionalCodexSessionsDirs?: readonly string[] wslHomeDirs?: readonly string[] geminiSessionsDir?: string + antigravityBrainDir?: string copilotSessionsDir?: string cursorProjectsDir?: string opencodeStorageDir?: string @@ -52,6 +53,7 @@ export type SessionFileCandidate = { agent: AiVaultAgent file: FileWithMtime codexHome: string | null + antigravityHistoryPath?: string } export type SessionFileDiscovery = { diff --git a/src/main/ai-vault/session-scanner.test.ts b/src/main/ai-vault/session-scanner.test.ts index d4404341fc1..7ba80556051 100644 --- a/src/main/ai-vault/session-scanner.test.ts +++ b/src/main/ai-vault/session-scanner.test.ts @@ -4,7 +4,11 @@ import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { AI_VAULT_AGENTS } from '../../shared/ai-vault-types' import { scanAiVaultSessions } from './session-scanner' -import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures' +import { + isolatedScanRoots, + jsonLines, + writeAntigravityScannerFixture +} from './session-scanner-test-fixtures' let tempRoots: string[] = [] @@ -423,6 +427,9 @@ describe('scanAiVaultSessions', () => { }) ) + const antigravitySessionId = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' + await writeAntigravityScannerFixture(roots.antigravityBrainDir, antigravitySessionId) + await mkdir(roots.copilotSessionsDir, { recursive: true }) await writeFile( join(roots.copilotSessionsDir, 'copilot-session.jsonl'), @@ -718,11 +725,7 @@ describe('scanAiVaultSessions', () => { ]) ) - const result = await scanAiVaultSessions({ - ...roots, - platform: 'darwin', - limit: 20 - }) + const result = await scanAiVaultSessions({ ...roots, platform: 'darwin', limit: 20 }) expect(result.issues).toEqual([]) expect(new Set(result.sessions.map((session) => session.agent))).toEqual( @@ -739,6 +742,7 @@ describe('scanAiVaultSessions', () => { `cd '/tmp/codex' && CODEX_HOME='${root}' codex resume 'codex-session'` ) expect(commandByAgent.get('gemini')).toBe("gemini --resume 'gemini-session'") + expect(commandByAgent.get('antigravity')).toBe(`agy --conversation '${antigravitySessionId}'`) expect(commandByAgent.get('copilot')).toBe( "cd '/tmp/copilot' && copilot --resume='copilot-session'" ) diff --git a/src/main/ai-vault/session-scanner.ts b/src/main/ai-vault/session-scanner.ts index 5ba2a95d503..ddb9ba7a861 100644 --- a/src/main/ai-vault/session-scanner.ts +++ b/src/main/ai-vault/session-scanner.ts @@ -1,3 +1,4 @@ +import { readFile } from 'node:fs/promises' import type { AiVaultListResult, AiVaultScanIssue, @@ -6,6 +7,11 @@ import type { import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host' import { withSpan } from '../observability/tracer' import { sessionSortTime } from './session-scanner-accumulator' +import { + createAntigravityWorkspaceResolver, + type AntigravityWorkspaceResolver +} from './session-scanner-antigravity-history' +import { antigravityHistoryPathForBrainDir } from './session-scanner-antigravity-paths' import { codexHomeForSessionsDir } from './session-scanner-codex-paths' import { createSessionParseStats, @@ -54,6 +60,7 @@ export async function scanAiVaultSessions( const executionHostId = options.executionHostId ?? LOCAL_EXECUTION_HOST_ID const issues: AiVaultScanIssue[] = [] const parseStats = createSessionParseStats() + const antigravityWorkspaceResolver = createAntigravityWorkspaceResolver(readOptionalTextFile) const discoveries = await discoverAiVaultSessionSources({ options, limitPerAgent, issues }) const candidates = discoveries @@ -65,7 +72,11 @@ export async function scanAiVaultSessions( codexHome: discovery.agent === 'codex' ? codexHomeForSessionsDir(discovery.rootDir, DEFAULT_CODEX_HOME_DIR) - : null + : null, + antigravityHistoryPath: + discovery.agent === 'antigravity' + ? antigravityHistoryPathForBrainDir(discovery.rootDir) + : undefined }) ) ) @@ -77,7 +88,8 @@ export async function scanAiVaultSessions( platform, executionHostId, issues, - parseStats + parseStats, + antigravityWorkspaceResolver }) const cappedSessions = parsedSessions @@ -175,6 +187,7 @@ async function parseSessionCandidates(args: { executionHostId: ExecutionHostId issues: AiVaultScanIssue[] parseStats: SessionParseStats + antigravityWorkspaceResolver?: AntigravityWorkspaceResolver }): Promise { const sessions: AiVaultSession[] = [] let index = 0 @@ -190,7 +203,13 @@ async function parseSessionCandidates(args: { const batch = args.candidates.slice(index, index + batchSize) const results = await Promise.all( batch.map((candidate) => - parseSessionCandidate(candidate, args.platform, args.executionHostId, args.parseStats) + parseSessionCandidate( + candidate, + args.platform, + args.executionHostId, + args.parseStats, + args.antigravityWorkspaceResolver + ) ) ) @@ -213,10 +232,14 @@ async function parseSessionCandidate( candidate: SessionFileCandidate, platform: NodeJS.Platform, executionHostId: ExecutionHostId, - parseStats: SessionParseStats + parseStats: SessionParseStats, + antigravityWorkspaceResolver?: AntigravityWorkspaceResolver ): Promise { try { - const session = await parseAgentSessionFileCached(candidate, platform, parseStats) + let session = await parseAgentSessionFileCached(candidate, platform, parseStats) + if (session && candidate.antigravityHistoryPath && antigravityWorkspaceResolver) { + session = await antigravityWorkspaceResolver.enrich(session, candidate.antigravityHistoryPath) + } return { session: session ? withSessionExecutionHost(session, executionHostId) : null, issue: null @@ -234,6 +257,14 @@ async function parseSessionCandidate( } } +async function readOptionalTextFile(path: string): Promise { + try { + return await readFile(path, 'utf-8') + } catch { + return null + } +} + function withSessionExecutionHost( session: AiVaultSession, executionHostId: ExecutionHostId diff --git a/src/main/browser/browser-clicked-link-routing.test.ts b/src/main/browser/browser-clicked-link-routing.test.ts new file mode 100644 index 00000000000..eee5eea35f1 --- /dev/null +++ b/src/main/browser/browser-clicked-link-routing.test.ts @@ -0,0 +1,272 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { + buildBrowserClickedLinkRoutingScript, + buildBrowserIframeClickedLinkRoutingScript, + installBrowserIframeClickedLinkRouting, + installBrowserClickedLinkRouting +} from './browser-clicked-link-routing' + +const FOREGROUND_FRAME_NAME = '__orca_clicked_link_foreground_test' + +type RoutingGlobal = typeof globalThis & { + __orcaBrowserClickedLinkRouting?: { + listener: (event: MouseEvent) => void + } +} + +let cleanupIframeRouting: (() => void) | null = null + +function resetRouting(): void { + const routingGlobal = globalThis as RoutingGlobal + const state = routingGlobal.__orcaBrowserClickedLinkRouting + if (state) { + window.removeEventListener('click', state.listener) + window.removeEventListener('auxclick', state.listener) + } + delete routingGlobal.__orcaBrowserClickedLinkRouting + cleanupIframeRouting?.() + cleanupIframeRouting = null +} + +function installRouting(isMac = true): void { + installBrowserClickedLinkRouting(FOREGROUND_FRAME_NAME, isMac, true) +} + +function clickLink( + link: Element, + init: MouseEventInit & { type?: 'click' | 'auxclick' } = {} +): { event: MouseEvent; open: ReturnType } { + const open = vi.fn() + vi.spyOn(window, 'open').mockImplementation(open) + const { type = 'click', ...eventInit } = init + const event = new MouseEvent(type, { + bubbles: true, + cancelable: true, + button: type === 'auxclick' ? 1 : 0, + ...eventInit + }) + link.dispatchEvent(event) + return { event, open } +} + +describe('browser clicked-link routing', () => { + beforeEach(() => { + resetRouting() + document.head.innerHTML = '' + document.body.innerHTML = '' + }) + + afterEach(() => { + resetRouting() + vi.restoreAllMocks() + }) + + it('routes plain target=_blank links back into the current Orca tab', () => { + const link = document.createElement('a') + link.href = 'https://docs.example.com/guide' + link.target = '_blank' + document.body.append(link) + installRouting() + + const { event, open } = clickLink(link) + + expect(event.defaultPrevented).toBe(false) + expect(open).not.toHaveBeenCalled() + expect(link.getAttribute('target')).toBe('_self') + }) + + it('routes the host-platform modifier without trusting an emulated guest user agent', () => { + Object.defineProperty(navigator, 'userAgent', { + configurable: true, + value: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)' + }) + const link = document.createElement('a') + link.href = 'https://example.com/reference' + document.body.append(link) + installRouting(false) + + expect(clickLink(link, { metaKey: true }).open).not.toHaveBeenCalled() + expect(clickLink(link, { ctrlKey: true }).open).toHaveBeenCalledWith( + 'https://example.com/reference', + FOREGROUND_FRAME_NAME + ) + }) + + it('routes middle clicks and primary-modifier plus Shift clicks as new-tab intents', () => { + const link = document.createElement('a') + link.href = 'https://example.com/reference' + document.body.append(link) + installRouting() + + expect(clickLink(link, { type: 'auxclick' }).open).toHaveBeenCalledWith( + 'https://example.com/reference', + FOREGROUND_FRAME_NAME + ) + expect(clickLink(link, { metaKey: true, shiftKey: true }).open).toHaveBeenCalledWith( + 'https://example.com/reference', + FOREGROUND_FRAME_NAME + ) + }) + + it('honors page cancellation and observes link rewrites before routing', () => { + const cancelled = document.createElement('a') + cancelled.href = 'https://example.com/original' + cancelled.target = '_blank' + cancelled.addEventListener('click', (event) => event.preventDefault()) + const rewritten = document.createElement('a') + rewritten.href = 'https://example.com/original' + rewritten.target = '_blank' + rewritten.addEventListener('click', () => { + rewritten.href = 'https://example.com/rewritten' + }) + document.body.append(cancelled, rewritten) + installRouting() + + expect(clickLink(cancelled).open).not.toHaveBeenCalled() + expect(cancelled.getAttribute('target')).toBe('_blank') + expect(clickLink(rewritten).open).not.toHaveBeenCalled() + expect(rewritten.href).toBe('https://example.com/rewritten') + expect(rewritten.getAttribute('target')).toBe('_self') + }) + + it('routes SVG links but leaves download links and links without href alone', () => { + const svgLink = document.createElementNS('http://www.w3.org/2000/svg', 'a') + svgLink.setAttribute('href', 'https://example.com/svg') + svgLink.setAttribute('target', '_blank') + const areaDownload = document.createElement('area') + areaDownload.href = 'https://example.com/archive.zip' + areaDownload.target = '_blank' + areaDownload.download = 'archive.zip' + const noHref = document.createElement('a') + noHref.target = '_blank' + document.body.append(svgLink, areaDownload, noHref) + installRouting() + + expect(clickLink(svgLink).open).not.toHaveBeenCalled() + expect(svgLink.getAttribute('target')).toBe('_self') + expect(clickLink(areaDownload).open).not.toHaveBeenCalled() + expect(clickLink(noHref).open).not.toHaveBeenCalled() + }) + + it('leaves ordinary same-tab, Shift-window, Alt, and unsafe links to Chromium', () => { + const sameTab = document.createElement('a') + sameTab.href = 'https://example.com/current' + const shifted = document.createElement('a') + shifted.href = 'https://example.com/window' + shifted.target = '_blank' + const altClicked = document.createElement('a') + altClicked.href = 'https://example.com/download' + altClicked.target = '_blank' + const unsafe = document.createElement('a') + unsafe.href = 'javascript:void(0)' + unsafe.target = '_blank' + document.body.append(sameTab, shifted, altClicked, unsafe) + installRouting() + + expect(clickLink(sameTab).open).not.toHaveBeenCalled() + expect(clickLink(shifted, { shiftKey: true }).open).not.toHaveBeenCalled() + expect(clickLink(altClicked, { altKey: true }).open).not.toHaveBeenCalled() + expect(clickLink(unsafe).open).not.toHaveBeenCalled() + }) + + it('updates an existing isolated-world installation without adding another listener', () => { + const addEventListener = vi.spyOn(window, 'addEventListener') + const link = document.createElement('a') + link.href = 'https://example.com/reference' + document.body.append(link) + installBrowserClickedLinkRouting('__orca_clicked_link_old_fg', true, true) + installBrowserClickedLinkRouting('__orca_clicked_link_new_fg', false, true) + + expect(addEventListener.mock.calls.filter(([event]) => event === 'click')).toHaveLength(1) + expect(clickLink(link, { ctrlKey: true }).open).toHaveBeenCalledWith( + 'https://example.com/reference', + '__orca_clicked_link_new_fg' + ) + }) + + it('builds a self-contained isolated-world script', () => { + const script = buildBrowserClickedLinkRoutingScript(FOREGROUND_FRAME_NAME, false) + + expect(script).toContain('installBrowserClickedLinkRouting') + expect(script).toContain(`"${FOREGROUND_FRAME_NAME}",false`) + expect(script).not.toContain('BrowserClickedLinkRoutingState') + }) + + it('routes plain iframe target=_blank links into the top-level guest', () => { + const link = document.createElement('a') + link.href = 'https://example.com/from-frame' + link.target = '_blank' + document.body.append(link) + cleanupIframeRouting = installBrowserIframeClickedLinkRouting(FOREGROUND_FRAME_NAME, true, true) + + const { event, open } = clickLink(link) + + expect(event.defaultPrevented).toBe(false) + expect(open).not.toHaveBeenCalled() + expect(link.getAttribute('target')).toBe('_top') + }) + + it('routes explicit iframe new-tab gestures through one-use frame names', () => { + const blank = document.createElement('a') + blank.href = 'https://example.com/new-context' + blank.target = '_blank' + document.body.append(blank) + cleanupIframeRouting = installBrowserIframeClickedLinkRouting(FOREGROUND_FRAME_NAME, true, true) + + expect(clickLink(blank, { metaKey: true }).open).toHaveBeenCalledWith( + 'https://example.com/new-context', + FOREGROUND_FRAME_NAME + ) + + cleanupIframeRouting = installBrowserIframeClickedLinkRouting(FOREGROUND_FRAME_NAME, true, true) + expect(clickLink(blank, { type: 'auxclick' }).open).toHaveBeenCalledWith( + 'https://example.com/new-context', + FOREGROUND_FRAME_NAME + ) + + cleanupIframeRouting = installBrowserIframeClickedLinkRouting(FOREGROUND_FRAME_NAME, true, true) + expect(clickLink(blank, { metaKey: true, shiftKey: true }).open).toHaveBeenCalledWith( + 'https://example.com/new-context', + FOREGROUND_FRAME_NAME + ) + }) + + it('leaves other-platform, Shift-window, and ordinary iframe clicks to Chromium', () => { + const blank = document.createElement('a') + blank.href = 'https://example.com/new-context' + blank.target = '_blank' + const ordinary = document.createElement('a') + ordinary.href = 'https://example.com/current' + document.body.append(blank, ordinary) + cleanupIframeRouting = installBrowserIframeClickedLinkRouting(FOREGROUND_FRAME_NAME, true, true) + + expect(clickLink(blank, { ctrlKey: true }).open).not.toHaveBeenCalled() + expect(clickLink(blank, { shiftKey: true }).open).not.toHaveBeenCalled() + expect(clickLink(ordinary).open).not.toHaveBeenCalled() + }) + + it('ignores synthetic main-frame and iframe clicks in production mode', () => { + const mainLink = document.createElement('a') + mainLink.href = 'https://example.com/main-synthetic' + const frameLink = document.createElement('a') + frameLink.href = 'https://example.com/frame-synthetic' + frameLink.target = '_blank' + document.body.append(mainLink, frameLink) + installBrowserClickedLinkRouting(FOREGROUND_FRAME_NAME, true) + cleanupIframeRouting = installBrowserIframeClickedLinkRouting(FOREGROUND_FRAME_NAME, true) + + expect(clickLink(mainLink, { metaKey: true }).open).not.toHaveBeenCalled() + expect(clickLink(frameLink).open).not.toHaveBeenCalled() + expect(frameLink.getAttribute('target')).toBe('_blank') + }) + + it('builds a self-contained iframe script with per-frame routing tokens', () => { + const script = buildBrowserIframeClickedLinkRoutingScript(FOREGROUND_FRAME_NAME, false) + + expect(script).toContain('installBrowserIframeClickedLinkRouting') + expect(script).toContain(`"${FOREGROUND_FRAME_NAME}",false`) + }) +}) diff --git a/src/main/browser/browser-clicked-link-routing.ts b/src/main/browser/browser-clicked-link-routing.ts new file mode 100644 index 00000000000..42a8bbf8232 --- /dev/null +++ b/src/main/browser/browser-clicked-link-routing.ts @@ -0,0 +1,217 @@ +export const BROWSER_CLICKED_LINK_ROUTING_WORLD_ID = 1208 + +type BrowserClickedLinkRoutingState = { + frameName: string + isMac: boolean + allowUntrustedEvents: boolean + listener: (event: MouseEvent) => void +} + +type BrowserClickedLinkRoutingGlobal = typeof globalThis & { + __orcaBrowserClickedLinkRouting?: BrowserClickedLinkRoutingState +} + +/** + * Re-expresses explicit new-tab link gestures with private frame names so main + * can distinguish them from opener-dependent window.open calls. + */ +export function installBrowserClickedLinkRouting( + frameName: string, + isMac: boolean, + allowUntrustedEvents = false +): void { + const routingGlobal = globalThis as BrowserClickedLinkRoutingGlobal + const existing = routingGlobal.__orcaBrowserClickedLinkRouting + if (existing) { + existing.frameName = frameName + existing.isMac = isMac + existing.allowUntrustedEvents = allowUntrustedEvents + return + } + + const state: BrowserClickedLinkRoutingState = { + frameName, + isMac, + allowUntrustedEvents, + listener: () => {} + } + state.listener = (event) => { + const primaryClick = event.type === 'click' && event.button === 0 + const middleClick = event.type === 'auxclick' && event.button === 1 + if ( + !(event instanceof MouseEvent) || + (!event.isTrusted && !state.allowUntrustedEvents) || + (!primaryClick && !middleClick) || + event.defaultPrevented || + event.altKey + ) { + return + } + + const link = event + .composedPath() + .find( + (target): target is Element => + target instanceof Element && + ((target.namespaceURI === 'http://www.w3.org/1999/xhtml' && + (target.localName === 'a' || target.localName === 'area')) || + (target.namespaceURI === 'http://www.w3.org/2000/svg' && target.localName === 'a')) + ) + if (!link || link.hasAttribute('download')) { + return + } + + const modifierClick = state.isMac ? event.metaKey : event.ctrlKey + const otherPlatformModifier = state.isMac ? event.ctrlKey : event.metaKey + if (otherPlatformModifier) { + return + } + // Shift alone is browser-native new-window intent; keep OAuth and other + // opener-dependent window flows in Orca's guarded popup window. + if (event.shiftKey && !modifierClick) { + return + } + + const baseTarget = document.querySelector('base[target]')?.getAttribute('target') ?? '' + const ownTarget = link.getAttribute('target') + const effectiveTarget = (ownTarget === null ? baseTarget : ownTarget).trim().toLowerCase() + const opensNewContext = middleClick || modifierClick + if (!opensNewContext && effectiveTarget !== '_blank') { + return + } + + const rawHref = + link.getAttribute('href') ?? link.getAttributeNS('http://www.w3.org/1999/xlink', 'href') + if (rawHref === null) { + return + } + + let targetUrl: URL + try { + targetUrl = new URL(rawHref, document.baseURI) + } catch { + return + } + if (targetUrl.protocol !== 'http:' && targetUrl.protocol !== 'https:') { + return + } + + if (!opensNewContext) { + // Why: changing only the browsing context keeps Chromium's native anchor + // navigation, including referrer policy, attribution, and history. + link.setAttribute('target', '_self') + return + } + + // Why: Electron reports direct link clicks and featureless window.open() + // with the same disposition. The private frame name preserves that one + // distinction without weakening OAuth popups that need window.opener. + event.preventDefault() + window.open(targetUrl.toString(), state.frameName) + } + routingGlobal.__orcaBrowserClickedLinkRouting = state + + // Why: page click handlers must get the first chance to cancel or rewrite a + // link; capture-phase interception breaks SPA routing and analytics handlers. + window.addEventListener('click', state.listener, false) + window.addEventListener('auxclick', state.listener, false) +} + +/** + * Keeps plain target=_blank clicks inside the top-level guest when Electron's + * isolated-world API cannot target a child frame. + */ +export function installBrowserIframeClickedLinkRouting( + frameName: string, + isMac: boolean, + allowUntrustedEvents = false +): () => void { + const listener = (event: MouseEvent): void => { + const primaryClick = event.type === 'click' && event.button === 0 + const middleClick = event.type === 'auxclick' && event.button === 1 + if ( + !(event instanceof MouseEvent) || + (!event.isTrusted && !allowUntrustedEvents) || + (!primaryClick && !middleClick) || + event.defaultPrevented || + event.altKey + ) { + return + } + + const link = event + .composedPath() + .find( + (target): target is Element => + target instanceof Element && + ((target.namespaceURI === 'http://www.w3.org/1999/xhtml' && + (target.localName === 'a' || target.localName === 'area')) || + (target.namespaceURI === 'http://www.w3.org/2000/svg' && target.localName === 'a')) + ) + if (!link || link.hasAttribute('download')) { + return + } + + const modifierClick = isMac ? event.metaKey : event.ctrlKey + const otherPlatformModifier = isMac ? event.ctrlKey : event.metaKey + if (otherPlatformModifier || (event.shiftKey && !modifierClick)) { + return + } + + const baseTarget = document.querySelector('base[target]')?.getAttribute('target') ?? '' + const ownTarget = link.getAttribute('target') + const effectiveTarget = (ownTarget === null ? baseTarget : ownTarget).trim().toLowerCase() + const opensNewContext = middleClick || modifierClick + if (!opensNewContext && effectiveTarget !== '_blank') { + return + } + + const rawHref = + link.getAttribute('href') ?? link.getAttributeNS('http://www.w3.org/1999/xlink', 'href') + if (rawHref === null) { + return + } + + let targetUrl: URL + try { + targetUrl = new URL(rawHref, document.baseURI) + } catch { + return + } + if (targetUrl.protocol !== 'http:' && targetUrl.protocol !== 'https:') { + return + } + + if (!opensNewContext) { + // Why: WebContents isolated worlds only cover the main frame. Rewriting + // to `_top` preserves native anchor semantics without opening a popup. + link.setAttribute('target', '_top') + return + } + + // Why: child-frame code runs in the page world, so each token is one-use. + // A page that observes a real click cannot replay it to create more tabs. + event.preventDefault() + cleanup() + window.open(targetUrl.toString(), frameName) + } + + const cleanup = (): void => { + window.removeEventListener('click', listener, false) + window.removeEventListener('auxclick', listener, false) + } + window.addEventListener('click', listener, false) + window.addEventListener('auxclick', listener, false) + return cleanup +} + +export function buildBrowserClickedLinkRoutingScript(frameName: string, isMac: boolean): string { + return `(${installBrowserClickedLinkRouting.toString()})(${JSON.stringify(frameName)},${JSON.stringify(isMac)});` +} + +export function buildBrowserIframeClickedLinkRoutingScript( + frameName: string, + isMac: boolean +): string { + return `void (${installBrowserIframeClickedLinkRouting.toString()})(${JSON.stringify(frameName)},${JSON.stringify(isMac)});` +} diff --git a/src/main/browser/browser-manager.test.ts b/src/main/browser/browser-manager.test.ts index 29d2c8f8edd..912923fc8f5 100644 --- a/src/main/browser/browser-manager.test.ts +++ b/src/main/browser/browser-manager.test.ts @@ -250,6 +250,165 @@ describe('browserManager', () => { expect(shellOpenExternalMock).not.toHaveBeenCalled() }) + it('keeps plain links current and routes explicit new-tab gestures to Orca tabs', async () => { + const rendererSendMock = vi.fn() + const executeJavaScriptInIsolatedWorldMock = vi.fn().mockResolvedValue(undefined) + const guest = { + id: 141, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock, + executeJavaScriptInIsolatedWorld: executeJavaScriptInIsolatedWorldMock + } + webContentsFromIdMock.mockImplementation((id: number) => { + if (id === guest.id) { + return guest + } + if (id === rendererWebContentsId) { + return { isDestroyed: vi.fn(() => false), send: rendererSendMock } + } + return null + }) + + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'browser-1', + webContentsId: guest.id, + rendererWebContentsId + }) + + const domReadyHandler = guestOnMock.mock.calls.find(([event]) => event === 'dom-ready')?.[1] as + | (() => void) + | undefined + domReadyHandler?.() + await vi.waitFor(() => expect(executeJavaScriptInIsolatedWorldMock).toHaveBeenCalledTimes(1)) + + const managerState = browserManager as unknown as { + clickedLinkFrameNameByGuestId: Map + } + const clickedLinkFrameName = managerState.clickedLinkFrameNameByGuestId.get(guest.id) + if (!clickedLinkFrameName) { + throw new Error('Expected a private clicked-link frame name') + } + expect(clickedLinkFrameName).toMatch(/^__orca_clicked_link_foreground_/) + expect(executeJavaScriptInIsolatedWorldMock).toHaveBeenCalledWith( + expect.any(Number), + [ + expect.objectContaining({ + code: expect.stringContaining( + `${JSON.stringify(clickedLinkFrameName)},${process.platform === 'darwin'})` + ) + }) + ], + false + ) + + const handler = guestSetWindowOpenHandlerMock.mock.calls[0][0] as (details: { + url: string + frameName: string + }) => { action: 'allow' | 'deny' } + expect( + handler({ + url: 'https://docs.example.com/guide', + frameName: clickedLinkFrameName + }) + ).toEqual({ action: 'deny' }) + + expect(rendererSendMock).toHaveBeenCalledWith('browser:open-link-in-orca-tab', { + browserPageId: 'browser-1', + url: 'https://docs.example.com/guide' + }) + expect(rendererSendMock).toHaveBeenCalledWith('browser:popup', { + browserPageId: 'browser-1', + origin: 'https://docs.example.com', + action: 'opened-in-orca' + }) + expect(openPopupWithOriginBarMock).not.toHaveBeenCalled() + expect(shellOpenExternalMock).not.toHaveBeenCalled() + }) + + it('routes child-frame gestures with one-use tokens', async () => { + const rendererSendMock = vi.fn() + const executeJavaScriptMock = vi.fn().mockResolvedValue(undefined) + const frameOnceMock = vi.fn() + const frame = { + parent: {}, + isDestroyed: vi.fn(() => false), + executeJavaScript: executeJavaScriptMock, + once: frameOnceMock, + off: vi.fn() + } + const guest = { + id: 142, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock, + executeJavaScriptInIsolatedWorld: vi.fn().mockResolvedValue(undefined) + } + webContentsFromIdMock.mockImplementation((id: number) => { + if (id === guest.id) { + return guest + } + if (id === rendererWebContentsId) { + return { isDestroyed: vi.fn(() => false), send: rendererSendMock } + } + return null + }) + + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'browser-frame', + webContentsId: guest.id, + rendererWebContentsId + }) + const frameCreatedHandler = guestOnMock.mock.calls.find( + ([event]) => event === 'frame-created' + )?.[1] as ((event: Electron.Event, details: Electron.FrameCreatedDetails) => void) | undefined + frameCreatedHandler?.({} as Electron.Event, { frame } as never) + const frameDomReadyHandler = frameOnceMock.mock.calls.find( + ([event]) => event === 'dom-ready' + )?.[1] as (() => void) | undefined + frameDomReadyHandler?.() + + await vi.waitFor(() => expect(executeJavaScriptMock).toHaveBeenCalledTimes(1)) + const firstScript = executeJavaScriptMock.mock.calls[0][0] as string + const foregroundFrameName = firstScript.match( + /__orca_clicked_link_iframe_foreground_[0-9a-f-]+/ + )?.[0] + if (!foregroundFrameName) { + throw new Error('Expected a private child-frame routing token') + } + expect(firstScript).toContain('installBrowserIframeClickedLinkRouting') + expect(executeJavaScriptMock).toHaveBeenCalledWith(firstScript, false) + + const popupHandler = guestSetWindowOpenHandlerMock.mock.calls[0][0] as (details: { + url: string + frameName: string + }) => { action: string } + expect( + popupHandler({ + url: 'https://docs.example.com/from-frame', + frameName: foregroundFrameName + }) + ).toEqual({ action: 'deny' }) + expect(rendererSendMock).toHaveBeenCalledWith('browser:open-link-in-orca-tab', { + browserPageId: 'browser-frame', + url: 'https://docs.example.com/from-frame' + }) + + await vi.waitFor(() => expect(executeJavaScriptMock).toHaveBeenCalledTimes(2)) + const secondScript = executeJavaScriptMock.mock.calls[1][0] as string + expect(secondScript).not.toContain(foregroundFrameName) + }) + it('hosts allowed popups in an origin-bar window with inherited guest policies', () => { const rendererSendMock = vi.fn() const guestOnceMock = vi.fn() @@ -320,6 +479,7 @@ describe('browserManager', () => { // attached directly here. expect(popupContents.setWindowOpenHandler).toHaveBeenCalledTimes(1) expect(popupContents.setBackgroundThrottling).toHaveBeenCalledWith(false) + expect(popupContents.on.mock.calls.some(([event]) => event === 'dom-ready')).toBe(false) // The renderer notice carries only the sanitized origin, never the URL. expect(rendererSendMock).toHaveBeenCalledWith('browser:popup', { browserPageId: 'browser-1', @@ -1000,12 +1160,19 @@ describe('browserManager', () => { webContentsId: 102, rendererWebContentsId }) + // Why: attach-before-registration teardown skips unregisterGuest, so global + // cleanup must independently release the private click-routing token. + browserManager.attachGuestPolicies({ ...guest, id: 103 } as never) browserManager.unregisterAll() expect(browserManager.getGuestWebContentsId('browser-1')).toBeNull() expect(browserManager.getGuestWebContentsId('browser-2')).toBeNull() expect(guestOffMock).toHaveBeenCalled() + const managerState = browserManager as unknown as { + clickedLinkFrameNameByGuestId: Map + } + expect(managerState.clickedLinkFrameNameByGuestId.size).toBe(0) }) it('rejects non-webview guest types to prevent privilege escalation', () => { diff --git a/src/main/browser/browser-manager.ts b/src/main/browser/browser-manager.ts index acd404f91ee..b9232bcd8d6 100644 --- a/src/main/browser/browser-manager.ts +++ b/src/main/browser/browser-manager.ts @@ -39,6 +39,11 @@ import { } from './browser-guest-ui' import { ANTI_DETECTION_SCRIPT } from './anti-detection' import { openPopupWithOriginBar, type PopupChildWindowOptions } from './popup-origin-bar-window' +import { + BROWSER_CLICKED_LINK_ROUTING_WORLD_ID, + buildBrowserClickedLinkRoutingScript, + buildBrowserIframeClickedLinkRoutingScript +} from './browser-clicked-link-routing' import { cleanElectronUserAgent } from './browser-session-ua' import type { BrowserViewportOverride } from '../../shared/types' import { @@ -146,7 +151,6 @@ type PopupOwnerContext = { browserTabId: string rootGuestWebContentsId: number } - const SAFE_POPUP_WINDOW_OPTIONS = { alwaysOnTop: false, closable: true, @@ -235,6 +239,7 @@ export class BrowserManager { private readonly worktreeIdByTabId = new Map() private readonly policyAttachedGuestIds = new Set() private readonly policyCleanupByGuestId = new Map void>() + private readonly clickedLinkFrameNameByGuestId = new Map() private shouldForwardDictationShortcut: (() => boolean) | null = null private readonly pendingLoadFailuresByGuestId = new Map< number, @@ -620,6 +625,15 @@ export class BrowserManager { if (inheritedOwnerContext) { this.popupOwnerContextByGuestId.set(guest.id, inheritedOwnerContext) } + // Why: OAuth child windows must retain normal link/window relationships; + // only the primary embedded browser converts new-tab clicks to Orca tabs. + const clickedLinkFrameName = inheritedOwnerContext + ? null + : `__orca_clicked_link_foreground_${randomUUID()}` + if (clickedLinkFrameName) { + this.clickedLinkFrameNameByGuestId.set(guest.id, clickedLinkFrameName) + } + let clickedLinkRoutingActive = Boolean(clickedLinkFrameName) // Why: Cloudflare Turnstile and similar bot detectors probe browser APIs // (navigator.webdriver, plugins, window.chrome) that differ in Electron @@ -632,16 +646,118 @@ export class BrowserManager { // Orca window is not the focused foreground app. With throttling enabled, // the compositor stops producing frames and capturePage() returns empty. guest.setBackgroundThrottling(false) + const installClickedLinkRouting = (): void => { + if (!clickedLinkRoutingActive || !clickedLinkFrameName || guest.isDestroyed()) { + return + } + // Why: an isolated-world click listener can label real anchor clicks + // without exposing the private frame name to untrusted page scripts. + void guest + .executeJavaScriptInIsolatedWorld( + BROWSER_CLICKED_LINK_ROUTING_WORLD_ID, + [ + { + // Why: mobile emulation spoofs the guest UA as iOS, so modifier + // routing must use the actual desktop host platform from main. + code: buildBrowserClickedLinkRoutingScript( + clickedLinkFrameName, + process.platform === 'darwin' + ) + } + ], + false + ) + .catch(() => {}) + } + if (clickedLinkFrameName) { + guest.on('dom-ready', installClickedLinkRouting) + } + const pendingIframeRoutingInstalls = new Map void>() + const iframeFrameNameByFrame = new Map() + const iframeFrameByFrameName = new Map() + const clearIframeFrameName = (frame: Electron.WebFrameMain): void => { + const name = iframeFrameNameByFrame.get(frame) + if (!name) { + return + } + iframeFrameNameByFrame.delete(frame) + iframeFrameByFrameName.delete(name) + } + const installIframeClickedLinkRouting = (frame: Electron.WebFrameMain): void => { + clearIframeFrameName(frame) + if (!clickedLinkRoutingActive || frame.isDestroyed()) { + return + } + const name = `__orca_clicked_link_iframe_foreground_${randomUUID()}` + iframeFrameNameByFrame.set(frame, name) + iframeFrameByFrameName.set(name, frame) + // Why: child-frame tokens live in the page world, so they are consumed + // after one trusted click and replaced before another can be routed. + void frame + .executeJavaScript( + buildBrowserIframeClickedLinkRoutingScript(name, process.platform === 'darwin'), + false + ) + .catch(() => { + if (iframeFrameNameByFrame.get(frame) === name) { + clearIframeFrameName(frame) + } + }) + } + const handleFrameCreated = ( + _event: Electron.Event, + { frame }: Electron.FrameCreatedDetails + ): void => { + if (!clickedLinkFrameName || !frame || frame.parent === null) { + return + } + for (const knownFrame of iframeFrameNameByFrame.keys()) { + if (knownFrame.isDestroyed()) { + clearIframeFrameName(knownFrame) + } + } + const installAfterDomReady = (): void => { + pendingIframeRoutingInstalls.delete(frame) + installIframeClickedLinkRouting(frame) + } + pendingIframeRoutingInstalls.set(frame, installAfterDomReady) + frame.once('dom-ready', installAfterDomReady) + } + if (clickedLinkFrameName) { + guest.on('frame-created', handleFrameCreated) + } const handleDidCreateWindow = (window: Electron.BrowserWindow): void => { // Why: popup descendants inherit the opener's owner context for routing, // but must not replace its primary guest registration. this.attachGuestPolicies(window.webContents, this.resolvePopupOwnerContext(guest.id)) } guest.on('did-create-window', handleDidCreateWindow) - guest.setWindowOpenHandler(({ url }) => { + guest.setWindowOpenHandler(({ url, frameName }) => { const browserTabId = this.resolveBrowserTabIdForGuestWebContentsId(guest.id) const browserUrl = normalizeBrowserNavigationUrl(url) const externalUrl = normalizeExternalBrowserUrl(url) + const expectedClickedLinkFrameName = this.clickedLinkFrameNameByGuestId.get(guest.id) + const iframeFrame = frameName ? iframeFrameByFrameName.get(frameName) : undefined + let isClickedLink = Boolean( + expectedClickedLinkFrameName && frameName === expectedClickedLinkFrameName + ) + if (!isClickedLink && iframeFrame) { + isClickedLink = true + clearIframeFrameName(iframeFrame) + queueMicrotask(() => installIframeClickedLinkRouting(iframeFrame)) + } + + if (isClickedLink) { + if (browserTabId && browserUrl && this.openLinkInOrcaTab(browserTabId, browserUrl)) { + this.forwardOrQueuePopupEvent(guest.id, { + origin: safeOrigin(browserUrl), + action: 'opened-in-orca' + }) + } + // Why: a recognized user gesture must never fall through to a native + // popup merely because its renderer disappeared during the click. + return { action: 'deny' } + } // Why: file URLs are valid for user-opened in-pane previews, but remote // content must not create native child windows targeting local paths. @@ -739,6 +855,19 @@ export class BrowserManager { try { guest.off('destroyed', handleDestroyed) guest.off('did-create-window', handleDidCreateWindow) + if (clickedLinkFrameName) { + clickedLinkRoutingActive = false + guest.off('dom-ready', installClickedLinkRouting) + guest.off('frame-created', handleFrameCreated) + for (const [frame, install] of pendingIframeRoutingInstalls) { + if (!frame.isDestroyed()) { + frame.off('dom-ready', install) + } + } + pendingIframeRoutingInstalls.clear() + iframeFrameNameByFrame.clear() + iframeFrameByFrameName.clear() + } } catch { // guest may already be destroyed } @@ -795,6 +924,7 @@ export class BrowserManager { this.policyCleanupByGuestId.delete(guestWebContentsId) } this.policyAttachedGuestIds.delete(guestWebContentsId) + this.clickedLinkFrameNameByGuestId.delete(guestWebContentsId) this.popupOwnerContextByGuestId.delete(guestWebContentsId) // Why: a popup must stop inheriting authorization as soon as its primary // owner is retired, even if Chromium has not destroyed the child yet. @@ -989,6 +1119,7 @@ export class BrowserManager { cleanup() } this.policyCleanupByGuestId.clear() + this.clickedLinkFrameNameByGuestId.clear() this.tabIdByWebContentsId.clear() this.popupOwnerContextByGuestId.clear() this.worktreeIdByTabId.clear() @@ -1881,6 +2012,24 @@ export class BrowserManager { } }) } + + private openLinkInOrcaTab(browserTabId: string, rawUrl: string): boolean { + const renderer = this.resolveRendererForBrowserTab(browserTabId) + if (!renderer) { + return false + } + const normalizedUrl = normalizeBrowserNavigationUrl(rawUrl) + if (!normalizedUrl || normalizedUrl === ORCA_BROWSER_BLANK_URL) { + return false + } + // Why: only the renderer owns Orca's worktree/tab model. Main forwards a + // validated URL instead of letting arbitrary guest content mutate it. + renderer.send('browser:open-link-in-orca-tab', { + browserPageId: browserTabId, + url: normalizedUrl + }) + return true + } } export const browserManager = new BrowserManager() diff --git a/src/main/emulator/serve-sim-state-watcher.test.ts b/src/main/emulator/serve-sim-state-watcher.test.ts deleted file mode 100644 index e528ea324bb..00000000000 --- a/src/main/emulator/serve-sim-state-watcher.test.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { - SERVE_SIM_STATE_DIR_EXISTENCE_POLL_MS, - ServeSimStateWatcher, - type ServeSimStateDetectedEvent -} from './serve-sim-state-watcher' - -const TEST_UDID = '11111111-2222-3333-4444-555555555555' - -let isolatedStateDirSeq = 0 -// Why: the default state dir is the real $TMPDIR/serve-sim — a leftover state -// file from an actual simulator session would leak extra events into tests -// that only exercise PTY ingestion. Point at a dir that never exists. -function createIsolatedWatcher(): ServeSimStateWatcher { - isolatedStateDirSeq += 1 - return new ServeSimStateWatcher({ - stateDir: join(tmpdir(), `orca-serve-sim-test-none-${process.pid}-${isolatedStateDirSeq}`) - }) -} - -async function waitForEvent( - events: ServeSimStateDetectedEvent[], - predicate: (event: ServeSimStateDetectedEvent) => boolean -): Promise { - const deadline = Date.now() + 1500 - while (Date.now() < deadline) { - const match = events.find(predicate) - if (match) { - return match - } - await new Promise((resolve) => setTimeout(resolve, 25)) - } - throw new Error(`Timed out waiting for serve-sim watcher event. Received ${events.length}`) -} - -describe('ServeSimStateWatcher', () => { - const cleanupPaths: string[] = [] - - afterEach(async () => { - await Promise.all( - cleanupPaths.splice(0).map((path) => rm(path, { recursive: true, force: true })) - ) - }) - - it('attaches to the serve-sim state directory when it appears after startup', async () => { - const parentDir = await mkdtemp(join(tmpdir(), 'orca-serve-sim-watch-')) - cleanupPaths.push(parentDir) - const stateDir = join(parentDir, 'serve-sim') - // Force darwin (the poll is macOS-only) and a fast interval so this - // cross-platform test exercises the existence-poll -> attach path quickly. - const watcher = new ServeSimStateWatcher({ stateDir, platform: 'darwin', existencePollMs: 50 }) - const events: ServeSimStateDetectedEvent[] = [] - - watcher.bindPty('pty-1', 'worktree-1') - watcher.onDetected((event) => events.push(event)) - watcher.start() - - await mkdir(stateDir) - await writeFile( - join(stateDir, `server-${TEST_UDID}.json`), - JSON.stringify({ - device: TEST_UDID, - streamUrl: 'http://127.0.0.1:3100/stream.mjpeg', - wsUrl: 'ws://127.0.0.1:3100/ws', - pid: 12345 - }) - ) - - const event = await waitForEvent(events, (candidate) => candidate.info.deviceUdid === TEST_UDID) - expect(event).toMatchObject({ - worktreeId: 'worktree-1', - source: 'state-file', - info: { - deviceUdid: TEST_UDID, - streamUrl: 'http://127.0.0.1:3100/stream.mjpeg', - wsUrl: 'ws://127.0.0.1:3100/ws', - helperPid: 12345 - } - }) - - watcher.stop() - }) - - it('does not arm the existence poll on non-macOS platforms', () => { - // serve-sim state never appears off macOS, so start() must not leave a - // recurring timer waking the daemon for a directory that can never exist. - for (const platform of ['win32', 'linux'] as const) { - const watcher = new ServeSimStateWatcher({ - stateDir: join(tmpdir(), `orca-serve-sim-nonmac-${process.pid}-${platform}`), - platform - }) - watcher.start() - const poll = (watcher as unknown as { stateDirPoll: unknown }).stateDirPoll - expect(poll).toBeNull() - watcher.stop() - } - }) - - it('defaults the existence poll to a coarse (battery-friendly) interval', () => { - expect(SERVE_SIM_STATE_DIR_EXISTENCE_POLL_MS).toBe(2_000) - const watcher = new ServeSimStateWatcher() - expect((watcher as unknown as { existencePollMs: number }).existencePollMs).toBe( - SERVE_SIM_STATE_DIR_EXISTENCE_POLL_MS - ) - }) - - it('does not buffer repeated brace-free PTY output while waiting for metadata', () => { - const watcher = createIsolatedWatcher() - const buffers = (watcher as unknown as { ptyBuffers: Map }).ptyBuffers - const originalSet = buffers.set - let setCalls = 0 - buffers.set = function (key, value): Map { - setCalls += 1 - return originalSet.call(this, key, value) - } - watcher.bindPty('pty-1', 'worktree-1') - - try { - for (let index = 0; index < 4096; index += 1) { - watcher.ingestPtyOutput('pty-1', 'ordinary compiler progress without JSON metadata\n') - } - } finally { - buffers.set = originalSet - } - - expect(setCalls).toBe(0) - expect(buffers.has('pty-1')).toBe(false) - watcher.stop() - }) - - it('detects serve-sim metadata split across PTY chunks and releases the partial object', () => { - const watcher = createIsolatedWatcher() - const events: ServeSimStateDetectedEvent[] = [] - const buffers = (watcher as unknown as { ptyBuffers: Map }).ptyBuffers - const payload = JSON.stringify({ - device: TEST_UDID, - streamUrl: 'http://127.0.0.1:3100/stream.mjpeg', - wsUrl: 'ws://127.0.0.1:3100/ws' - }) - const splitIndex = payload.indexOf('streamUrl') + 4 - watcher.bindPty('pty-1', 'worktree-1') - watcher.onDetected((event) => events.push(event)) - - watcher.ingestPtyOutput('pty-1', payload.slice(0, splitIndex)) - expect(events).toHaveLength(0) - expect(buffers.has('pty-1')).toBe(true) - - watcher.ingestPtyOutput('pty-1', payload.slice(splitIndex)) - expect(events).toHaveLength(1) - expect(events[0]?.info.deviceUdid).toBe(TEST_UDID) - expect(buffers.has('pty-1')).toBe(false) - watcher.stop() - }) - - it('suppresses Orca-managed sessions only while they are marked managed', async () => { - const watcher = createIsolatedWatcher() - const events: ServeSimStateDetectedEvent[] = [] - const payload = JSON.stringify({ - device: TEST_UDID, - streamUrl: 'http://127.0.0.1:3100/stream.mjpeg', - wsUrl: 'ws://127.0.0.1:3100/ws' - }) - - watcher.bindPty('pty-1', 'worktree-1') - watcher.onDetected((event) => events.push(event)) - watcher.markOrcaManaged({ - deviceUdid: TEST_UDID, - streamUrl: 'http://127.0.0.1:3100/stream.mjpeg', - wsUrl: 'ws://127.0.0.1:3100/ws' - }) - watcher.ingestPtyOutput('pty-1', payload) - expect(events).toHaveLength(0) - - watcher.unmarkOrcaManaged(TEST_UDID) - watcher.ingestPtyOutput('pty-1', payload) - expect(events).toHaveLength(1) - expect(events[0]?.info.deviceUdid).toBe(TEST_UDID) - - watcher.stop() - }) - - it('prunes worktree-scoped dedupe keys on forget so a re-bound worktree re-emits', () => { - const watcher = createIsolatedWatcher() - const events: ServeSimStateDetectedEvent[] = [] - const payload = JSON.stringify({ - device: TEST_UDID, - streamUrl: 'http://127.0.0.1:3100/stream.mjpeg', - wsUrl: 'ws://127.0.0.1:3100/ws', - pid: 12345 - }) - - watcher.onDetected((event) => events.push(event)) - - watcher.bindPty('pty-1', 'worktree-1') - watcher.ingestPtyOutput('pty-1', payload) - watcher.ingestPtyOutput('pty-1', payload) // deduped within the same worktree - expect(events).toHaveLength(1) - - // Forgetting the worktree must drop its dedupe keys; a re-bind is a fresh - // context and should re-emit (otherwise the Set leaks for the session). - watcher.forgetWorktree('worktree-1') - watcher.bindPty('pty-2', 'worktree-1') - watcher.ingestPtyOutput('pty-2', payload) - expect(events).toHaveLength(2) - - watcher.stop() - }) - - it('dedupes one helper without hiding a later helper for the same simulator', () => { - const watcher = createIsolatedWatcher() - const events: ServeSimStateDetectedEvent[] = [] - const firstPayload = JSON.stringify({ - device: TEST_UDID, - streamUrl: 'http://127.0.0.1:3100/stream.mjpeg', - wsUrl: 'ws://127.0.0.1:3100/ws', - pid: 12345 - }) - const secondPayload = JSON.stringify({ - device: TEST_UDID, - streamUrl: 'http://127.0.0.1:3101/stream.mjpeg', - wsUrl: 'ws://127.0.0.1:3101/ws', - pid: 23456 - }) - - watcher.bindPty('pty-1', 'worktree-1') - watcher.onDetected((event) => events.push(event)) - - watcher.ingestPtyOutput('pty-1', firstPayload) - watcher.ingestPtyOutput('pty-1', firstPayload) - watcher.ingestPtyOutput('pty-1', secondPayload) - - expect(events.map((event) => event.info.helperPid)).toEqual([12345, 23456]) - - watcher.stop() - }) -}) diff --git a/src/main/emulator/serve-sim-state-watcher.ts b/src/main/emulator/serve-sim-state-watcher.ts deleted file mode 100644 index dae8e57bc39..00000000000 --- a/src/main/emulator/serve-sim-state-watcher.ts +++ /dev/null @@ -1,309 +0,0 @@ -import { existsSync, readFileSync, readdirSync, watch } from 'node:fs' -import { basename, join } from 'node:path' -import { tmpdir } from 'node:os' -import type { FSWatcher } from 'node:fs' - -// Why: terminal-started `serve-sim --detach` writes state under $TMPDIR/serve-sim/ and may -// print JSON to the PTY. Orca-managed attach (CLI/pane) already registers via EmulatorBridge; -// this watcher only reacts to *external* helper starts so the UI can open a simulator tab -// without focus-steal (mirror advertised-url-watcher PTY binding model). - -export type ServeSimHelperInfo = { - deviceUdid: string - wsUrl: string - streamUrl: string - axUrl?: string - helperPid?: number -} - -export type ServeSimStateDetectedEvent = { - worktreeId: string - info: ServeSimHelperInfo - source: 'pty' | 'state-file' -} - -const DEFAULT_STATE_DIR = join(tmpdir(), 'serve-sim') -// Why: this only waits for a rarely-created dir to appear; sub-second detection -// of a detached emulator is not user-perceptible, so a coarse interval avoids -// waking the daemon 4x/sec for the whole session on machines that never use it. -export const SERVE_SIM_STATE_DIR_EXISTENCE_POLL_MS = 2_000 -const STATE_FILE_RE = /^server-([0-9A-F-]{36})\.json$/i -const PTY_JSON_RE = /\{[^{}]*"streamUrl"\s*:\s*"[^"]+"[^{}]*"wsUrl"\s*:\s*"[^"]+"[^{}]*\}/g - -function parseHelperInfo(raw: unknown, fallbackUdid?: string): ServeSimHelperInfo | null { - if (!raw || typeof raw !== 'object') { - return null - } - const obj = raw as Record - const deviceUdid = - (typeof obj.device === 'string' && obj.device) || - (typeof obj.deviceUdid === 'string' && obj.deviceUdid) || - fallbackUdid - const streamUrl = - (typeof obj.streamUrl === 'string' && obj.streamUrl) || (typeof obj.url === 'string' && obj.url) - const wsUrl = typeof obj.wsUrl === 'string' ? obj.wsUrl : undefined - if (!deviceUdid || !streamUrl || !wsUrl) { - return null - } - return { - deviceUdid, - wsUrl, - streamUrl, - axUrl: typeof obj.axUrl === 'string' ? obj.axUrl : undefined, - helperPid: typeof obj.pid === 'number' ? obj.pid : undefined - } -} - -function readStateFilePath(filePath: string): ServeSimHelperInfo | null { - try { - const name = basename(filePath) - const match = STATE_FILE_RE.exec(name) - const fallbackUdid = match?.[1] - const raw = JSON.parse(readFileSync(filePath, 'utf8')) - return parseHelperInfo(raw, fallbackUdid) - } catch { - return null - } -} - -function helperInstanceKey(info: ServeSimHelperInfo): string { - return `${info.deviceUdid}::${info.helperPid ?? 'pidless'}::${info.wsUrl}::${info.streamUrl}` -} - -function trailingIncompletePtyJsonObject(data: string): string { - const lastObjectStart = data.lastIndexOf('{') - return lastObjectStart > data.lastIndexOf('}') ? data.slice(lastObjectStart) : '' -} - -export class ServeSimStateWatcher { - private readonly stateDir: string - private readonly platform: NodeJS.Platform - private readonly existencePollMs: number - private readonly ptyToWorktree = new Map() - private readonly ptyBuffers = new Map() - private readonly seenExternalKeys = new Set() - private readonly orcaManagedHelperKeys = new Set() - private readonly listeners = new Set<(event: ServeSimStateDetectedEvent) => void>() - private stateWatcher: FSWatcher | null = null - private stateDirPoll: ReturnType | null = null - - constructor( - options: { stateDir?: string; platform?: NodeJS.Platform; existencePollMs?: number } = {} - ) { - this.stateDir = options.stateDir ?? DEFAULT_STATE_DIR - this.platform = options.platform ?? process.platform - this.existencePollMs = options.existencePollMs ?? SERVE_SIM_STATE_DIR_EXISTENCE_POLL_MS - } - - onDetected(listener: (event: ServeSimStateDetectedEvent) => void): () => void { - this.listeners.add(listener) - return () => this.listeners.delete(listener) - } - - // Why: bridge calls this when Orca CLI/pane attaches so we do not duplicate-tab on our own session. - markOrcaManaged(info: ServeSimHelperInfo): void { - this.orcaManagedHelperKeys.add(helperInstanceKey(info)) - } - - unmarkOrcaManaged(deviceUdid: string): void { - for (const key of this.orcaManagedHelperKeys) { - if (key.startsWith(`${deviceUdid}::`)) { - this.orcaManagedHelperKeys.delete(key) - } - } - } - - bindPty(ptyId: string, worktreeId: string): void { - this.ptyToWorktree.set(ptyId, worktreeId) - this.scanExistingStateFiles() - } - - unbindPty(ptyId: string): void { - this.ptyToWorktree.delete(ptyId) - this.ptyBuffers.delete(ptyId) - } - - forgetWorktree(worktreeId: string): void { - for (const [ptyId, wt] of this.ptyToWorktree.entries()) { - if (wt === worktreeId) { - this.ptyToWorktree.delete(ptyId) - this.ptyBuffers.delete(ptyId) - } - } - // Why: dedupe keys are worktree-scoped (`${worktreeId}::...`); prune them on - // forget so the Set does not grow for the session across worktree switches. - // A later re-bind of the same worktree is a fresh context and should re-emit. - const prefix = `${worktreeId}::` - for (const key of this.seenExternalKeys) { - if (key.startsWith(prefix)) { - this.seenExternalKeys.delete(key) - } - } - } - - ingestPtyOutput(ptyId: string, data: string): void { - const worktreeId = this.ptyToWorktree.get(ptyId) - if (!worktreeId) { - return - } - - const previous = this.ptyBuffers.get(ptyId) - if (!previous && !data.includes('{')) { - // Why: serve-sim metadata is a JSON object, while ordinary PTY output is - // the hot path. Stay idle instead of rebuilding and regex-scanning 16 KiB. - return - } - const combined = `${previous ?? ''}${data}`.slice(-16_384) - const trailingObject = trailingIncompletePtyJsonObject(combined) - if (trailingObject) { - this.ptyBuffers.set(ptyId, trailingObject) - } else { - this.ptyBuffers.delete(ptyId) - } - - const matches = combined.match(PTY_JSON_RE) - if (!matches) { - return - } - for (const fragment of matches) { - try { - const info = parseHelperInfo(JSON.parse(fragment)) - if (info) { - this.emitIfExternal(worktreeId, info, 'pty') - } - } catch { - /* ignore partial JSON */ - } - } - } - - start(): void { - if (this.stateDirPoll || this.stateWatcher) { - return - } - // Why: serve-sim (the iOS Simulator bridge) only ever writes state on macOS, - // so $TMPDIR/serve-sim never appears on Windows/Linux. Skip arming the - // existence poll there instead of waking the daemon every interval for a - // directory that can never exist. - if (this.platform !== 'darwin') { - return - } - try { - // Why: $TMPDIR/serve-sim/ may not exist until the first terminal `serve-sim --detach`. - // Poll for it instead of fs.watch on the parent tmpdir: watching $TMPDIR - // registers a permanent FSEvents client on the system's highest-churn - // directory, while an existence poll costs the daemon nothing. - this.attachStateDirWatch() - if (this.stateWatcher) { - this.scanExistingStateFiles() - return - } - - // attachStateDirWatch() clears this poll once the dir appears and the - // native watcher takes over, so it only ticks while waiting for a - // rarely-created dir — a coarse interval keeps that wait off the idle floor. - this.stateDirPoll = setInterval(() => { - this.attachStateDirWatch() - this.scanExistingStateFiles() - }, this.existencePollMs) - this.stateDirPoll.unref?.() - } catch { - // Permission issues: watcher is best-effort. - } - } - - stop(): void { - this.stateWatcher?.close() - if (this.stateDirPoll) { - clearInterval(this.stateDirPoll) - } - this.stateWatcher = null - this.stateDirPoll = null - this.ptyToWorktree.clear() - this.ptyBuffers.clear() - this.seenExternalKeys.clear() - this.orcaManagedHelperKeys.clear() - this.listeners.clear() - } - - private attachStateDirWatch(): void { - if (this.stateWatcher || !existsSync(this.stateDir)) { - return - } - this.stateWatcher = watch(this.stateDir, (_event, filename) => { - if (!filename) { - return - } - const name = String(filename) - if (!STATE_FILE_RE.test(name)) { - return - } - const info = readStateFilePath(join(this.stateDir, name)) - if (!info) { - return - } - const worktreeId = this.latestBoundWorktree() - if (worktreeId) { - this.emitIfExternal(worktreeId, info, 'state-file') - } - }) - if (this.stateDirPoll) { - clearInterval(this.stateDirPoll) - this.stateDirPoll = null - } - } - - private latestBoundWorktree(): string | undefined { - let latest: string | undefined - for (const wt of this.ptyToWorktree.values()) { - latest = wt - } - return latest - } - - private scanExistingStateFiles(): void { - const worktreeId = this.latestBoundWorktree() - if (!worktreeId || !existsSync(this.stateDir)) { - return - } - try { - for (const name of readdirSync(this.stateDir)) { - if (!STATE_FILE_RE.test(name)) { - continue - } - const info = readStateFilePath(join(this.stateDir, name)) - if (info) { - this.emitIfExternal(worktreeId, info, 'state-file') - } - } - } catch { - /* ignore */ - } - } - - private emitIfExternal( - worktreeId: string, - info: ServeSimHelperInfo, - source: ServeSimStateDetectedEvent['source'] - ): void { - const instanceKey = helperInstanceKey(info) - if (this.orcaManagedHelperKeys.has(instanceKey)) { - return - } - const dedupeKey = `${worktreeId}::${instanceKey}` - if (this.seenExternalKeys.has(dedupeKey)) { - return - } - this.seenExternalKeys.add(dedupeKey) - const event: ServeSimStateDetectedEvent = { worktreeId, info, source } - for (const listener of this.listeners) { - try { - listener(event) - } catch { - /* listener errors must not break watcher */ - } - } - } -} - -export const serveSimStateWatcher = new ServeSimStateWatcher() diff --git a/src/main/git/status.test.ts b/src/main/git/status.test.ts index 3f7cbfd9f3a..8af536a31a6 100644 --- a/src/main/git/status.test.ts +++ b/src/main/git/status.test.ts @@ -1414,6 +1414,116 @@ describe('getStatus', () => { ]) }) + it('reuses unchanged line stats only when the safety hint is present', async () => { + readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') + existsSyncMock.mockReturnValue(false) + gitExecFileAsyncMock.mockImplementation((args: string[]) => { + if (args.includes('status')) { + return Promise.resolve({ + stdout: + '# branch.oid head-1\n' + '1 .M N... 100644 100644 100644 aaaa aaaa src/unstaged.ts\n' + }) + } + if (args.includes('--numstat')) { + return Promise.resolve({ stdout: '3\t4\tsrc/unstaged.ts\n' }) + } + return Promise.resolve({ stdout: '' }) + }) + + await getStatus('/repo') + const reused = await getStatus('/repo', { reuseLineStats: true }) + await getStatus('/repo') + + expect(reused.entries).toEqual([ + { path: 'src/unstaged.ts', status: 'modified', area: 'unstaged', added: 3, removed: 4 } + ]) + expect( + gitExecFileAsyncMock.mock.calls.filter(([args]) => args.includes('--numstat')) + ).toHaveLength(2) + }) + + it('recomputes after a scan whose numstat failed instead of pinning missing counts', async () => { + readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') + existsSyncMock.mockReturnValue(false) + let failNumstat = true + gitExecFileAsyncMock.mockImplementation((args: string[]) => { + if (args.includes('status')) { + return Promise.resolve({ + stdout: + '# branch.oid head-1\n' + '1 .M N... 100644 100644 100644 aaaa aaaa src/unstaged.ts\n' + }) + } + if (args.includes('--numstat')) { + if (failNumstat) { + failNumstat = false + return Promise.reject(new Error('transient index.lock')) + } + return Promise.resolve({ stdout: '3\t4\tsrc/unstaged.ts\n' }) + } + return Promise.resolve({ stdout: '' }) + }) + + const failed = await getStatus('/repo') + const reused = await getStatus('/repo', { reuseLineStats: true }) + + expect(failed.entries[0]?.added).toBeUndefined() + expect(reused.entries).toEqual([ + { path: 'src/unstaged.ts', status: 'modified', area: 'unstaged', added: 3, removed: 4 } + ]) + }) + + it('invalidates safety reuse for a new head and for known mutations', async () => { + readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') + existsSyncMock.mockReturnValue(false) + let head = 'head-1' + gitExecFileAsyncMock.mockImplementation((args: string[]) => { + if (args.includes('status')) { + return Promise.resolve({ + stdout: + `# branch.oid ${head}\n` + '1 .M N... 100644 100644 100644 aaaa aaaa src/unstaged.ts\n' + }) + } + if (args.includes('--numstat')) { + return Promise.resolve({ stdout: '3\t4\tsrc/unstaged.ts\n' }) + } + return Promise.resolve({ stdout: '' }) + }) + + await getStatus('/repo') + head = 'head-2' + await getStatus('/repo', { reuseLineStats: true }) + await stageFile('/repo', 'src/unstaged.ts') + await getStatus('/repo', { reuseLineStats: true }) + + expect( + gitExecFileAsyncMock.mock.calls.filter(([args]) => args.includes('--numstat')) + ).toHaveLength(3) + }) + + it('isolates line-stat reuse between WSL distributions', async () => { + readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') + existsSyncMock.mockReturnValue(false) + gitExecFileAsyncMock.mockImplementation((args: string[]) => { + if (args.includes('status')) { + return Promise.resolve({ + stdout: '1 .M N... 100644 100644 100644 aaaa aaaa src/unstaged.ts\n' + }) + } + if (args.includes('--numstat')) { + return Promise.resolve({ stdout: '3\t4\tsrc/unstaged.ts\n' }) + } + return Promise.resolve({ stdout: '' }) + }) + + await getStatus('/repo', { wslDistro: 'ubuntu' }) + await getStatus('/repo', { wslDistro: 'debian', reuseLineStats: true }) + await getStatus('/repo', { wslDistro: 'ubuntu', reuseLineStats: true }) + + expect( + gitExecFileAsyncMock.mock.calls.filter(([args]) => args.includes('--numstat')) + ).toHaveLength(2) + }) + it('attaches numstat counts for literal paths containing rename markers', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') existsSyncMock.mockReturnValue(false) diff --git a/src/main/git/status.ts b/src/main/git/status.ts index ddbdd1b2ca4..a0c103b744c 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -51,6 +51,12 @@ import { InFlightPromiseDedupe, stableInFlightKey } from '../../shared/in-flight import type { GitRuntimeOptions } from './git-runtime-options' import { gitOptionsForWorktree } from './git-runtime-options' import { parseGitRevListFirstParentOid } from '../../shared/git-rev-list-output' +import { + beginGitStatusLineStatsCacheWrite, + clearGitStatusLineStatsCache, + clearGitStatusLineStatsCacheKey, + reuseOrRecomputeGitStatusLineStats +} from '../../shared/git-status-line-stats-cache' const MAX_GIT_SHOW_BYTES = 10 * 1024 * 1024 const MAX_STAGED_COMMIT_CONTEXT_BYTES = MAX_GIT_SHOW_BYTES @@ -97,6 +103,7 @@ const statusReadsInFlight = new Map>() export function invalidateGitReadCaches(): void { gitDiffReadDedupe.clear() statusReadsInFlight.clear() + clearGitStatusLineStatsCache() clearSubmodulePathsCache() resolvedUpstreamNameCache.clear() } @@ -195,6 +202,7 @@ export function getEffectiveUpstreamStatusGenerationCountForTests(): number { export type GetStatusOptions = GitRuntimeOptions & { includeIgnored?: boolean + reuseLineStats?: boolean /** * Max changed-file entries before git is stopped and the result is marked * `didHitLimit`. Defaults to DEFAULT_GIT_STATUS_LIMIT; 0 disables the cap. @@ -243,6 +251,7 @@ function getStatusReadKey(worktreePath: string, options: GetStatusOptions): stri worktreePath, options.wslDistro ?? '', options.includeIgnored === true, + options.reuseLineStats === true, options.bypassEffectiveUpstreamNegativeCache === true, limit ].join('\0') @@ -252,6 +261,8 @@ async function runGetStatus( worktreePath: string, options: GetStatusOptions = {} ): Promise { + const lineStatsCacheKey = getStatusLineStatsCacheKey(worktreePath, options) + const lineStatsWriteToken = beginGitStatusLineStatsCacheWrite(lineStatsCacheKey) let effectiveUpstreamStatus: GitUpstreamStatus | undefined let statusSucceeded = false // Why: a negative/fractional/NaN limit would trigger spurious early-stop or @@ -303,7 +314,12 @@ async function runGetStatus( } didHitLimit = stoppedEarly statusSucceeded = true - } catch { + } catch (error) { + // Why: an aborted scan must reject, not resolve — swallowing here would let + // a cancelled request be mistaken for a completed (empty) status result. + if (options.signal?.aborted) { + throw error + } // Not a git repo or git not available } @@ -334,11 +350,17 @@ async function runGetStatus( options ) try { + // Why: the probe promise and its name/negative caches are shared by + // concurrent status reads, so one caller's abort must not reject the + // shared probe or evict warm cache state for the others. The probe is + // small and its cached result stays useful, so run it unbound from + // this request's signal. + const { signal: _requestSignal, ...sharedProbeOptions } = options effectiveUpstreamStatus = await readOrProbeEffectiveUpstreamStatus( cacheKey, worktreePath, branchName, - options, + sharedProbeOptions, options.bypassEffectiveUpstreamNegativeCache === true ) } catch { @@ -355,7 +377,25 @@ async function runGetStatus( // running numstat over a huge change set would reintroduce the cost the limit // exists to avoid, matching how a "huge" repo disables extra git features. if (!didHitLimit) { - await attachLineStats(worktreePath, entries, options) + await reuseOrRecomputeGitStatusLineStats({ + cacheKey: lineStatsCacheKey, + head, + entries, + writeToken: lineStatsWriteToken, + reuse: options.reuseLineStats === true, + isAborted: () => options.signal?.aborted === true, + recompute: () => attachLineStats(worktreePath, entries, options) + }) + } else { + clearGitStatusLineStatsCacheKey(lineStatsCacheKey, lineStatsWriteToken) + } + + // Why: abort after the stream (e.g. during unmerged/upstream/line-stats work) + // must still reject — never resolve a cancelled scan as a completed result. + if (options.signal?.aborted) { + const error = new Error('The operation was aborted.') + error.name = 'AbortError' + throw error } return { @@ -382,6 +422,12 @@ async function runGetStatus( } } +function getStatusLineStatsCacheKey(worktreePath: string, options: GitRuntimeOptions = {}): string { + // Why: identical path strings can address different Linux filesystems in + // different WSL distros, so derived stats must follow Git's execution host. + return `${options.wslDistro ?? 'native'}\0${worktreePath}` +} + /** * Resolve a submodule's own worktree path from a parent worktree + relative * submodule path, rejecting anything that escapes the parent. Shared by the @@ -505,7 +551,7 @@ async function runNumstat( worktreePath: string, cached: boolean, options: GitRuntimeOptions = {} -): Promise> { +): Promise | null> { try { const { stdout } = await gitExecFileAsync( [ @@ -520,20 +566,28 @@ async function runNumstat( { ...gitOptionsForWorktree(worktreePath, options), env: gitOptionalLocksDisabledEnv() } ) return parseNumstat(stdout) - } catch { + } catch (error) { + // Why: an aborted pass must reject so a cancelled scan is never treated as + // a completed one; only a genuine (non-abort) numstat failure degrades to + // uncounted rows below. + if (options.signal?.aborted) { + throw error + } // Why: a numstat failure (e.g. transient lock) should leave rows without - // counts rather than break the whole status refresh. - return new Map() + // counts rather than break the whole status refresh. Null (vs an empty + // map) tells the caller the pass is incomplete and must not be cached. + return null } } +/** Returns false when a numstat pass failed, so callers skip caching it. */ async function attachLineStats( worktreePath: string, entries: GitStatusEntry[], options: GitRuntimeOptions = {} -): Promise { +): Promise { if (entries.length === 0) { - return + return true } const hasStaged = entries.some((entry) => entry.area === 'staged') const hasUnstaged = entries.some((entry) => entry.area === 'unstaged') @@ -544,18 +598,19 @@ async function attachLineStats( const [stagedStats, unstagedStats, untrackedStats] = await Promise.all([ hasStaged ? runNumstat(worktreePath, true, options) : Promise.resolve(emptyStats), hasUnstaged ? runNumstat(worktreePath, false, options) : Promise.resolve(emptyStats), - collectUntrackedAdditions(worktreePath, untrackedPaths) + collectUntrackedAdditions(worktreePath, untrackedPaths, options.signal) ]) for (const entry of entries) { applyLineStats( entry, entry.area === 'staged' - ? stagedStats.get(entry.path) + ? (stagedStats ?? emptyStats).get(entry.path) : entry.area === 'unstaged' - ? unstagedStats.get(entry.path) + ? (unstagedStats ?? emptyStats).get(entry.path) : untrackedStats.get(entry.path) ) } + return stagedStats !== null && unstagedStats !== null } function getShortBranchName(branch: string | undefined): string | null { @@ -753,7 +808,12 @@ async function probeOrRevalidateEffectiveUpstreamStatus( cached.upstreamName ) return { status, probedSameNameOriginRef: false } - } catch { + } catch (error) { + // Why: an aborted probe says nothing about the ref; evicting the warm + // name cache here would force a pointless full re-resolve next scan. + if (options.signal?.aborted) { + throw error + } // Ref deleted or repo state changed — fall through to a full re-resolve. resolvedUpstreamNameCache.delete(cacheKey) } diff --git a/src/main/github/client-issue-source.test.ts b/src/main/github/client-issue-source.test.ts index 5312343224a..df658007fe5 100644 --- a/src/main/github/client-issue-source.test.ts +++ b/src/main/github/client-issue-source.test.ts @@ -49,6 +49,47 @@ vi.mock('./rate-limit', () => ({ import { countWorkItems, getWorkItem, listWorkItems, _resetOwnerRepoCache } from './client' +const PR_LIST_FIELDS = + 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,reviewRequests' + +function issueSearchArgs( + ownerRepo: string, + options: { noCache?: boolean; query?: string } = {} +): string[] { + const query = options.query ?? 'is:issue is:open' + return [ + 'api', + ...(options.noCache ? [] : ['--cache', '120s']), + `search/issues?q=${encodeURIComponent(`repo:${ownerRepo} ${query}`)}&sort=created&order=desc&per_page=10&page=1`, + '--jq', + '.items' + ] +} + +function prListArgs(ownerRepo: string, query = 'is:pr is:open'): string[] { + return [ + 'pr', + 'list', + '--limit', + '10', + '--state', + 'all', + '--json', + PR_LIST_FIELDS, + '--repo', + ownerRepo, + '--search', + `${query} sort:created-desc` + ] +} + +function decodedIssueSearchPath(callIndex: number): string { + const args = ghExecFileAsyncMock.mock.calls[callIndex]?.[0] as string[] | undefined + const apiPath = args?.find((arg) => arg.startsWith('search/issues?')) + expect(apiPath).toBeDefined() + return decodeURIComponent(apiPath ?? '') +} + describe('GitHub issue source split', () => { beforeEach(() => { execFileAsyncMock.mockReset() @@ -115,26 +156,12 @@ describe('GitHub issue source split', () => { await listWorkItems('/repo-root', 10) - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 1, - [ - 'api', - '--cache', - '120s', - 'repos/stablyai/orca/issues?per_page=10&state=open&sort=updated&direction=desc' - ], - { cwd: '/repo-root' } - ) - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 2, - [ - 'api', - '--cache', - '120s', - 'repos/fork/orca/pulls?per_page=10&state=open&sort=updated&direction=desc' - ], - { cwd: '/repo-root' } - ) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(1, issueSearchArgs('stablyai/orca'), { + cwd: '/repo-root' + }) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(2, prListArgs('fork/orca'), { + cwd: '/repo-root' + }) }) it('omits gh api cache args for no-cache recent work-item requests', async () => { @@ -148,14 +175,12 @@ describe('GitHub issue source split', () => { expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 1, - ['api', 'repos/stablyai/orca/issues?per_page=10&state=open&sort=updated&direction=desc'], - { cwd: '/repo-root' } - ) - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 2, - ['api', 'repos/fork/orca/pulls?per_page=10&state=open&sort=updated&direction=desc'], + issueSearchArgs('stablyai/orca', { noCache: true }), { cwd: '/repo-root' } ) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(2, prListArgs('fork/orca'), { + cwd: '/repo-root' + }) }) it('lists SSH repo work items with explicit owner/repo and no local cwd', async () => { @@ -177,26 +202,8 @@ describe('GitHub issue source split', () => { {} ) expect(getOwnerRepoMock).toHaveBeenCalledWith('/home/jinwoo/orca', 'openclaw-2', {}) - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 1, - [ - 'api', - '--cache', - '120s', - 'repos/stablyai/orca/issues?per_page=10&state=open&sort=updated&direction=desc' - ], - {} - ) - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 2, - [ - 'api', - '--cache', - '120s', - 'repos/fork/orca/pulls?per_page=10&state=open&sort=updated&direction=desc' - ], - {} - ) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(1, issueSearchArgs('stablyai/orca'), {}) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(2, prListArgs('fork/orca'), {}) }) it('uses upstream for issue-only queries and origin for PR-only queries', async () => { @@ -206,10 +213,7 @@ describe('GitHub issue source split', () => { await listWorkItems('/repo-root', 10, 'is:issue') - expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - expect.arrayContaining(['--repo', 'stablyai/orca']), - { cwd: '/repo-root' } - ) + expect(decodedIssueSearchPath(0)).toContain('q=repo:stablyai/orca is:issue') ghExecFileAsyncMock.mockClear() getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) @@ -237,16 +241,9 @@ describe('GitHub issue source split', () => { await listWorkItems('/repo-root', 10, undefined, undefined, 'upstream') - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 2, - [ - 'api', - '--cache', - '120s', - 'repos/stablyai/orca/pulls?per_page=10&state=open&sort=updated&direction=desc' - ], - { cwd: '/repo-root' } - ) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(2, prListArgs('stablyai/orca'), { + cwd: '/repo-root' + }) }) it("uses upstream for queried PRs when preference='upstream'", async () => { @@ -515,16 +512,9 @@ describe('GitHub issue source split', () => { const result = await listWorkItems('/repo-root', 10, undefined, undefined, 'auto') expect(resolveIssueSourceMock).toHaveBeenCalledWith('/repo-root', 'auto', undefined, {}) - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 1, - [ - 'api', - '--cache', - '120s', - 'repos/stablyai/orca/issues?per_page=10&state=open&sort=updated&direction=desc' - ], - { cwd: '/repo-root' } - ) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(1, issueSearchArgs('stablyai/orca'), { + cwd: '/repo-root' + }) expect(result.issueSourceFellBack).toBeUndefined() }) @@ -540,16 +530,9 @@ describe('GitHub issue source split', () => { await listWorkItems('/repo-root', 10, undefined, undefined, 'auto') - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 1, - [ - 'api', - '--cache', - '120s', - 'repos/solo/orca/issues?per_page=10&state=open&sort=updated&direction=desc' - ], - { cwd: '/repo-root' } - ) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(1, issueSearchArgs('solo/orca'), { + cwd: '/repo-root' + }) }) it("preference='upstream' + upstream exists → queries upstream", async () => { @@ -564,13 +547,7 @@ describe('GitHub issue source split', () => { const result = await listWorkItems('/repo-root', 10, undefined, undefined, 'upstream') - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 1, - expect.arrayContaining([ - 'repos/stablyai/orca/issues?per_page=10&state=open&sort=updated&direction=desc' - ]), - { cwd: '/repo-root' } - ) + expect(decodedIssueSearchPath(0)).toContain('q=repo:stablyai/orca is:issue is:open') expect(result.issueSourceFellBack).toBeUndefined() }) @@ -586,13 +563,7 @@ describe('GitHub issue source split', () => { const result = await listWorkItems('/repo-root', 10, undefined, undefined, 'upstream') - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 1, - expect.arrayContaining([ - 'repos/solo/orca/issues?per_page=10&state=open&sort=updated&direction=desc' - ]), - { cwd: '/repo-root' } - ) + expect(decodedIssueSearchPath(0)).toContain('q=repo:solo/orca is:issue is:open') expect(result.issueSourceFellBack).toBe(true) }) @@ -608,13 +579,7 @@ describe('GitHub issue source split', () => { await listWorkItems('/repo-root', 10, undefined, undefined, 'origin') - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 1, - expect.arrayContaining([ - 'repos/fork/orca/issues?per_page=10&state=open&sort=updated&direction=desc' - ]), - { cwd: '/repo-root' } - ) + expect(decodedIssueSearchPath(0)).toContain('q=repo:fork/orca is:issue is:open') }) it("preference='origin' + no upstream → queries origin", async () => { @@ -629,13 +594,7 @@ describe('GitHub issue source split', () => { await listWorkItems('/repo-root', 10, undefined, undefined, 'origin') - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 1, - expect.arrayContaining([ - 'repos/solo/orca/issues?per_page=10&state=open&sort=updated&direction=desc' - ]), - { cwd: '/repo-root' } - ) + expect(decodedIssueSearchPath(0)).toContain('q=repo:solo/orca is:issue is:open') }) it('surfaces upstreamCandidate in sources regardless of effective preference', async () => { diff --git a/src/main/github/client-work-items.test.ts b/src/main/github/client-work-items.test.ts index d0e93eb306c..a30db9f19e0 100644 --- a/src/main/github/client-work-items.test.ts +++ b/src/main/github/client-work-items.test.ts @@ -180,18 +180,12 @@ describe('listWorkItems', () => { expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 1, [ - 'issue', - 'list', - '--limit', - '10', - '--json', - 'number,title,state,url,labels,updatedAt,author,assignees', - '--repo', - 'acme/widgets', - '--assignee', - '@me', - '--search', - 'sort:updated-desc' + 'api', + '--cache', + '120s', + `search/issues?q=${encodeURIComponent('repo:acme/widgets is:issue assignee:@me')}&sort=created&order=desc&per_page=10&page=1`, + '--jq', + '.items' ], { cwd: '/repo-root' } ) @@ -202,14 +196,14 @@ describe('listWorkItems', () => { 'list', '--limit', '10', + '--state', + 'all', '--json', 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,reviewRequests', '--repo', 'acme/widgets', - '--assignee', - '@me', '--search', - 'sort:updated-desc' + 'is:pr assignee:@me sort:created-desc' ], { cwd: '/repo-root' } ) @@ -218,24 +212,6 @@ describe('listWorkItems', () => { expect(prListFields).toContain('reviewRequests') expect(prListFields).not.toContain('mergeStateStatus') expect(items).toEqual([ - { - id: 'issue:12', - type: 'issue', - number: 12, - title: 'Fix bug', - state: 'open', - url: 'https://github.com/acme/widgets/issues/12', - labels: [], - updatedAt: '2026-03-29T00:00:00Z', - author: 'octocat', - assignees: [ - { - login: 'test-assignee', - name: 'Test Assignee', - avatarUrl: 'https://avatars.githubusercontent.com/u/1?v=4' - } - ] - }, { id: 'pr:42', type: 'pr', @@ -257,6 +233,24 @@ describe('listWorkItems', () => { avatarUrl: 'https://avatars.githubusercontent.com/u/1?v=4' } ] + }, + { + id: 'issue:12', + type: 'issue', + number: 12, + title: 'Fix bug', + state: 'open', + url: 'https://github.com/acme/widgets/issues/12', + labels: [], + updatedAt: '2026-03-29T00:00:00Z', + author: 'octocat', + assignees: [ + { + login: 'test-assignee', + name: 'Test Assignee', + avatarUrl: 'https://avatars.githubusercontent.com/u/1?v=4' + } + ] } ]) }) @@ -404,15 +398,14 @@ describe('listWorkItems', () => { 'list', '--limit', '10', + '--state', + 'all', '--json', 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,reviewRequests', '--repo', 'acme/widgets', - '--state', - 'open', - '--draft', '--search', - 'sort:updated-desc' + 'is:pr is:open draft:true sort:created-desc' ], { cwd: '/repo-root' } ) @@ -465,14 +458,14 @@ describe('listWorkItems', () => { 'list', '--limit', '10', + '--state', + 'all', '--json', 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,reviewRequests', '--repo', 'acme/widgets', - '--state', - 'merged', '--search', - 'sort:updated-desc' + 'is:pr is:merged sort:created-desc' ], { cwd: '/repo-root' } ) @@ -536,7 +529,12 @@ describe('listWorkItems', () => { const { items } = await listWorkItems('/repo-root', 10, 'is:pr is:closed') expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - expect.arrayContaining(['--state', 'closed', '--search', '-is:merged sort:updated-desc']), + expect.arrayContaining([ + '--state', + 'all', + '--search', + 'is:pr is:closed -is:merged sort:created-desc' + ]), { cwd: '/repo-root' } ) expect(items).toMatchObject([{ id: 'pr:9', type: 'pr', state: 'closed' }]) @@ -605,7 +603,7 @@ describe('listWorkItems', () => { expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - expect.arrayContaining(['--search', 'review-requested:@me sort:updated-desc']), + expect.arrayContaining(['--search', 'is:pr is:open review-requested:@me sort:created-desc']), { cwd: '/repo-root' } ) expect(ghExecFileAsyncMock).not.toHaveBeenCalledWith( @@ -614,32 +612,86 @@ describe('listWorkItems', () => { ) }) - it('pins list ordering to updated-desc so the updatedAt cursor pages consistently', async () => { + it('uses the requested numbered Search API page for issues', async () => { getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) - await listWorkItems('/repo-root', 10, 'is:issue is:open') + await listWorkItems('/repo-root', 10, 'is:issue is:open', 2) expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - expect.arrayContaining(['--search', 'sort:updated-desc']), + [ + 'api', + '--cache', + '120s', + `search/issues?q=${encodeURIComponent('repo:acme/widgets is:issue is:open')}&sort=created&order=desc&per_page=10&page=2`, + '--jq', + '.items' + ], { cwd: '/repo-root' } ) }) - it('combines the inclusive updatedAt cursor with updated-desc ordering on later pages', async () => { + it('fetches and slices stable PR results for the requested numbered page', async () => { getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify( + [1, 2, 3, 4].map((number) => ({ + number, + title: `PR ${number}`, + state: 'OPEN', + url: `https://github.com/acme/widgets/pull/${number}`, + labels: [], + updatedAt: `2026-07-0${number}T00:00:00Z`, + author: { login: 'octocat' }, + isDraft: false, + headRefName: `feature/${number}`, + headRefOid: `head-${number}`, + baseRefName: 'main' + })) + ) + }) - await listWorkItems('/repo-root', 10, 'is:issue is:open', '2026-07-01T00:00:00Z') + const { items } = await listWorkItems('/repo-root', 2, 'is:pr is:open', 2) - // Why: the bound is inclusive (`<=`) so boundary items sharing the cursor's - // exact updatedAt aren't skipped; the renderer dedupes the re-fetched rows. expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - expect.arrayContaining(['--search', 'updated:<=2026-07-01T00:00:00Z sort:updated-desc']), + expect.arrayContaining(['--limit', '4', '--search', 'is:pr is:open sort:created-desc']), { cwd: '/repo-root' } ) + expect(items.map((item) => item.number)).toEqual([4, 3]) + }) + + it('filters pull request rows out of issue Search API results', async () => { + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify([ + { + number: 2, + title: 'PR-shaped search row', + state: 'open', + html_url: 'https://github.com/acme/widgets/pull/2', + labels: [], + updated_at: '2026-07-02T00:00:00Z', + user: { login: 'octocat' }, + pull_request: { url: 'https://api.github.com/repos/acme/widgets/pulls/2' } + }, + { + number: 1, + title: 'Issue row', + state: 'open', + html_url: 'https://github.com/acme/widgets/issues/1', + labels: [], + updated_at: '2026-07-01T00:00:00Z', + user: { login: 'octocat' } + } + ]) + }) + + const { items } = await listWorkItems('/repo-root', 10, 'is:issue is:open') + + expect(items.map((item) => item.id)).toEqual(['issue:1']) }) it('returns open issues and PRs for the all-open preset query', async () => { @@ -679,18 +731,12 @@ describe('listWorkItems', () => { const { items } = await listWorkItems('/repo-root', 10, 'is:open') expect(ghExecFileAsyncMock).toHaveBeenCalledWith( [ - 'issue', - 'list', - '--limit', - '10', - '--json', - 'number,title,state,url,labels,updatedAt,author,assignees', - '--repo', - 'acme/widgets', - '--state', - 'open', - '--search', - 'sort:updated-desc' + 'api', + '--cache', + '120s', + `search/issues?q=${encodeURIComponent('repo:acme/widgets is:issue is:open')}&sort=created&order=desc&per_page=10&page=1`, + '--jq', + '.items' ], { cwd: '/repo-root' } ) @@ -700,29 +746,18 @@ describe('listWorkItems', () => { 'list', '--limit', '10', + '--state', + 'all', '--json', 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,reviewRequests', '--repo', 'acme/widgets', - '--state', - 'open', '--search', - 'sort:updated-desc' + 'is:pr is:open sort:created-desc' ], { cwd: '/repo-root' } ) expect(items).toEqual([ - { - id: 'issue:1', - type: 'issue', - number: 1, - title: 'Open issue', - state: 'open', - url: 'https://github.com/acme/widgets/issues/1', - labels: [], - updatedAt: '2026-03-31T00:00:00Z', - author: 'octocat' - }, { id: 'pr:2', type: 'pr', @@ -737,6 +772,17 @@ describe('listWorkItems', () => { baseRefName: 'main', headSha: 'head-2', prRepo: { owner: 'acme', repo: 'widgets' } + }, + { + id: 'issue:1', + type: 'issue', + number: 1, + title: 'Open issue', + state: 'open', + url: 'https://github.com/acme/widgets/issues/1', + labels: [], + updatedAt: '2026-03-31T00:00:00Z', + author: 'octocat' } ]) }) diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 15a2f8b34bc..54572bd406f 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -32,7 +32,7 @@ import { isGitHubWorkItemsQueryTooLarge } from '../../shared/github-work-items-q import { parseTaskQuery, type ParsedTaskQuery } from '../../shared/task-query' import { GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE, - sortWorkItemsByUpdatedAt + sortWorkItemsByNumber } from '../../shared/work-items' import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' @@ -449,17 +449,13 @@ export async function getAuthenticatedViewer(): Promise { } // Why: main-process maps omit repoId because the IPC handler never receives -// a repo identifier beyond path. The renderer stamps repoId after IPC so -// single-repo and cross-repo items are uniform downstream. -type MainWorkItem = Omit +// a repo identifier beyond path. Exported because runtime consumers receive +// listWorkItems results before the renderer stamps repoId. +export type MainWorkItem = Omit -const WORK_ITEM_ISSUE_LIST_JSON_FIELDS = 'number,title,state,url,labels,updatedAt,author,assignees' - -// Why: the Tasks pager slices pages on updatedAt, so every list/search fetch -// must return rows newest-updated-first for the cursor to advance correctly. -// This is the search-qualifier spelling of the same contract the REST list -// paths express as `sort=updated&direction=desc`; keep the two in sync (#8649). -const WORK_ITEM_LIST_SORT_QUALIFIER = 'sort:updated-desc' +// Why: issue numbers follow creation order within a repository. Pinning this +// sort keeps gh's rich PR rows aligned with numbered Search API issue pages. +const WORK_ITEM_NUMBER_SORT_QUALIFIER = 'sort:created-desc' const WORK_ITEM_PR_LIST_JSON_FIELDS = 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,reviewRequests' @@ -956,78 +952,98 @@ async function fetchPullRequestWorkItem( return mapPullRequestWorkItem(JSON.parse(stdout) as Record) } -function buildWorkItemListArgs(args: { +type WorkItemListRequest = { + args: string[] + offset: number +} + +function normalizeWorkItemPage(page: number | undefined): number { + return typeof page === 'number' && Number.isFinite(page) && page >= 1 ? Math.floor(page) : 1 +} + +function buildWorkItemListRequest(args: { kind: 'issue' | 'pr' ownerRepo: OwnerRepo | null limit: number query: ParsedTaskQuery - before?: string -}): string[] { - const { kind, ownerRepo, limit, query, before } = args - const fields = kind === 'issue' ? WORK_ITEM_ISSUE_LIST_JSON_FIELDS : WORK_ITEM_PR_LIST_JSON_FIELDS - const command = kind === 'issue' ? ['issue', 'list'] : ['pr', 'list'] - const out = [...command, '--limit', String(limit), '--json', fields] + page: number +}): WorkItemListRequest { + const { kind, ownerRepo, limit, query, page } = args + const searchParts: string[] = [] - if (ownerRepo) { - out.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`) + if (kind === 'issue' && ownerRepo) { + searchParts.push(`repo:${ownerRepo.owner}/${ownerRepo.repo}`) + } + searchParts.push(kind === 'issue' ? 'is:issue' : 'is:pr') + + if (query.state === 'open') { + searchParts.push('is:open') + } else if (query.state === 'closed') { + searchParts.push('is:closed') + if (kind === 'pr') { + searchParts.push('-is:merged') + } + } else if (query.state === 'merged') { + searchParts.push('is:merged') } - if (query.state) { - out.push('--state', query.state) + if (kind === 'pr' && query.draft) { + searchParts.push('draft:true') } - // Why: GitHub considers merged PRs as "closed". When the user filters for - // closed-only, exclude merged PRs via the search predicate so the displayed - // and counted results match the user's intent. - const excludeMergedFromClosed = kind === 'pr' && query.state === 'closed' if (query.assignee) { - out.push('--assignee', query.assignee) + searchParts.push(`assignee:${quoteGitHubSearchValue(query.assignee)}`) } if (query.author) { - out.push('--author', query.author) + searchParts.push(`author:${quoteGitHubSearchValue(query.author)}`) } if (query.labels.length > 0) { for (const label of query.labels) { - out.push('--label', label) + searchParts.push(`label:${quoteGitHubSearchValue(label)}`) } } - // Why: only add --draft when the user explicitly typed `is:draft`. Previously - // this fired for any PR-scoped open query, which made `is:pr is:open` (the - // "PRs" preset) silently filter to drafts-only. - if (kind === 'pr' && query.draft) { - out.push('--draft') - } - - const searchParts: string[] = [] - if (excludeMergedFromClosed) { - searchParts.push('-is:merged') - } - // Why: cursor-based pagination. GitHub search supports updated:<=DATE to - // fetch items at or older than the cursor. We use the oldest item's updatedAt - // from the previous page as the cursor. The bound is inclusive (`<=`) so items - // sharing the boundary row's exact updatedAt aren't skipped between pages; the - // renderer dedupes the re-fetched boundary rows by id (#8649). - if (before) { - searchParts.push(`updated:<=${before}`) - } if (kind === 'pr' && query.reviewRequested) { - searchParts.push(`review-requested:${query.reviewRequested}`) + searchParts.push(`review-requested:${quoteGitHubSearchValue(query.reviewRequested)}`) } if (kind === 'pr' && query.reviewedBy) { - searchParts.push(`reviewed-by:${query.reviewedBy}`) + searchParts.push(`reviewed-by:${quoteGitHubSearchValue(query.reviewedBy)}`) } if (query.freeText) { searchParts.push(query.freeText) } - // Why: pagination cursors slice on updatedAt, but `gh issue list` defaults - // to created-desc and `--search` defaults to best-match. `gh issue/pr list` - // has no --sort flag, so the only lever is the search query — which forces us - // to always emit --search. Without pinning the sort, recently-updated old - // items never appear on any page, so the pager advertises pages the fetch - // chain can never reach (#8649). - searchParts.push(WORK_ITEM_LIST_SORT_QUALIFIER) + + if (kind === 'issue') { + return { + args: [ + 'api', + '--cache', + '120s', + `search/issues?q=${encodeURIComponent(searchParts.join(' '))}&sort=created&order=desc&per_page=${limit}&page=${page}`, + '--jq', + '.items' + ], + offset: 0 + } + } + + // Why: search/issues omits the PR fields used by the Tasks management + // columns. Fetch through gh's rich PR list and slice its stable created sort. + searchParts.push(WORK_ITEM_NUMBER_SORT_QUALIFIER) + const out = [ + 'pr', + 'list', + '--limit', + String(Math.min(page * limit, 1000)), + '--state', + 'all', + '--json', + WORK_ITEM_PR_LIST_JSON_FIELDS + ] + if (ownerRepo) { + out.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`) + } out.push('--search', searchParts.join(' ')) - return out + return { args: out, offset: (page - 1) * limit } } // Why: internal shape shared by listRecentWorkItems / listQueriedWorkItems so @@ -1105,6 +1121,7 @@ async function listRecentWorkItems( issueOwnerRepo: OwnerRepo | null, prOwnerRepo: OwnerRepo | null, limit: number, + page: number, connectionId?: string | null, noCache?: boolean, localGitOptions: LocalGitExecOptions = {} @@ -1112,72 +1129,49 @@ async function listRecentWorkItems( const repoContext = githubRepoContext(repoPath, connectionId, localGitOptions) const ghOptions = ghRepoExecOptions(repoContext) const requiresExplicitRepo = Boolean(connectionId) - const restCacheArgs = noCache ? [] : ['--cache', '120s'] assertSshRepoHasResolvedGitHubSource({ connectionId, issueOwnerRepo, prOwnerRepo }) + const recentQuery = parseTaskQuery('is:open') + const issueRequest = buildWorkItemListRequest({ + kind: 'issue', + ownerRepo: issueOwnerRepo, + limit, + query: recentQuery, + page + }) + const prRequest = buildWorkItemListRequest({ + kind: 'pr', + ownerRepo: prOwnerRepo, + limit, + query: recentQuery, + page + }) + if (noCache) { + issueRequest.args.splice(1, 2) + } if (issueOwnerRepo || prOwnerRepo || requiresExplicitRepo) { // Why: allSettled so a 403 on upstream issues doesn't zero out the origin // PR half — the UI renders partial results plus a banner for the failing // side, matching the parent design doc's partial-failure rule (§2). const [issuesSettled, prsSettled] = await Promise.allSettled([ issueOwnerRepo - ? ghExecFileAsync( - [ - 'api', - ...restCacheArgs, - `repos/${issueOwnerRepo.owner}/${issueOwnerRepo.repo}/issues?per_page=${limit}&state=open&sort=updated&direction=desc` - ], - ghOptions - ) + ? ghExecFileAsync(issueRequest.args, ghOptions) : requiresExplicitRepo ? Promise.resolve({ stdout: '[]' }) - : ghCwdResolvedExec( - repoContext, - [ - 'issue', - 'list', - '--limit', - String(limit), - '--state', - 'open', - '--json', - WORK_ITEM_ISSUE_LIST_JSON_FIELDS - ], - ghOptions - ), + : ghCwdResolvedExec(repoContext, issueRequest.args, ghOptions), prOwnerRepo - ? ghExecFileAsync( - [ - 'api', - ...restCacheArgs, - `repos/${prOwnerRepo.owner}/${prOwnerRepo.repo}/pulls?per_page=${limit}&state=open&sort=updated&direction=desc` - ], - ghOptions - ) + ? ghExecFileAsync(prRequest.args, ghOptions) : requiresExplicitRepo ? Promise.resolve({ stdout: '[]' }) - : ghCwdResolvedExec( - repoContext, - [ - 'pr', - 'list', - '--limit', - String(limit), - '--state', - 'open', - '--json', - WORK_ITEM_PR_LIST_JSON_FIELDS - ], - ghOptions - ) + : ghCwdResolvedExec(repoContext, prRequest.args, ghOptions) ]) let issues: MainWorkItem[] = [] let issuesError: ClassifiedError | undefined if (issuesSettled.status === 'fulfilled') { issues = (JSON.parse(issuesSettled.value.stdout) as Record[]) - // Why: the GitHub issues REST endpoint also returns pull requests with a - // `pull_request` marker. The new-workspace task picker needs distinct - // issue vs PR buckets, so drop PR-shaped issue rows here before merging. + // Why: the GitHub search/issues endpoint may still return PRs with a + // pull_request marker even when queried with is:issue. Filter them out + // here to keep the issue and PR buckets clean. .filter((item) => !('pull_request' in item)) .map(mapIssueWorkItem) } else { @@ -1190,9 +1184,9 @@ async function listRecentWorkItems( let prs: MainWorkItem[] = [] if (prsSettled.status === 'fulfilled') { - prs = (JSON.parse(prsSettled.value.stdout) as Record[]).map((item) => - mapPullRequestWorkItem(item, prOwnerRepo) - ) + prs = (JSON.parse(prsSettled.value.stdout) as Record[]) + .slice(prRequest.offset, prRequest.offset + limit) + .map((item) => mapPullRequestWorkItem(item, prOwnerRepo)) prs = await hydrateWorkItemRepositoryMergeMetadata(prs, prOwnerRepo, ghOptions) } else { // Why: PR-side failures must preserve the pre-diff behavior of @@ -1215,7 +1209,7 @@ async function listRecentWorkItems( } return { - items: sortWorkItemsByUpdatedAt([...issues, ...prs]).slice(0, limit), + items: sortWorkItemsByNumber([...issues, ...prs]).slice(0, limit), issuesError } } @@ -1228,45 +1222,19 @@ async function listRecentWorkItems( // effectively unusable for the feature — reject-all matches reality. If // non-GitHub remotes ever grow source metadata, revisit this symmetry. const [issuesResult, prsResult] = await Promise.all([ - ghCwdResolvedExec( - repoContext, - [ - 'issue', - 'list', - '--limit', - String(limit), - '--state', - 'open', - '--json', - WORK_ITEM_ISSUE_LIST_JSON_FIELDS - ], - ghOptions - ), - ghCwdResolvedExec( - repoContext, - [ - 'pr', - 'list', - '--limit', - String(limit), - '--state', - 'open', - '--json', - WORK_ITEM_PR_LIST_JSON_FIELDS - ], - ghOptions - ) + ghCwdResolvedExec(repoContext, issueRequest.args, ghOptions), + ghCwdResolvedExec(repoContext, prRequest.args, ghOptions) ]) - const issues = (JSON.parse(issuesResult.stdout) as Record[]).map( - mapIssueWorkItem - ) - const prs = (JSON.parse(prsResult.stdout) as Record[]).map((item) => - mapPullRequestWorkItem(item, null) - ) + const issues = (JSON.parse(issuesResult.stdout) as Record[]) + .filter((item) => !('pull_request' in item)) + .map(mapIssueWorkItem) + const prs = (JSON.parse(prsResult.stdout) as Record[]) + .slice(prRequest.offset, prRequest.offset + limit) + .map((item) => mapPullRequestWorkItem(item, null)) return { - items: sortWorkItemsByUpdatedAt([...issues, ...prs]).slice(0, limit) + items: sortWorkItemsByNumber([...issues, ...prs]).slice(0, limit) } } @@ -1276,7 +1244,7 @@ async function listQueriedWorkItems( prOwnerRepo: OwnerRepo | null, query: ParsedTaskQuery, limit: number, - before?: string, + page?: number, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { @@ -1302,19 +1270,21 @@ async function listQueriedWorkItems( if (requiresExplicitRepo && !issueOwnerRepo) { return { items: [] } } - const args = buildWorkItemListArgs({ + const request = buildWorkItemListRequest({ kind: 'issue', ownerRepo: issueOwnerRepo, limit, query, - before + page: page ?? 1 }) try { const { stdout } = issueOwnerRepo - ? await ghExecFileAsync(args, ghOptions) - : await ghCwdResolvedExec(repoContext, args, ghOptions) + ? await ghExecFileAsync(request.args, ghOptions) + : await ghCwdResolvedExec(repoContext, request.args, ghOptions) return { - items: (JSON.parse(stdout) as Record[]).map(mapIssueWorkItem) + items: (JSON.parse(stdout) as Record[]) + .filter((item) => !('pull_request' in item)) + .map(mapIssueWorkItem) } } catch (err) { const stderr = err instanceof Error ? err.message : String(err) @@ -1329,20 +1299,20 @@ async function listQueriedWorkItems( if (requiresExplicitRepo && !prOwnerRepo) { return [] } - const args = buildWorkItemListArgs({ + const request = buildWorkItemListRequest({ kind: 'pr', ownerRepo: prOwnerRepo, limit, query, - before + page: page ?? 1 }) try { const { stdout } = prOwnerRepo - ? await ghExecFileAsync(args, ghOptions) - : await ghCwdResolvedExec(repoContext, args, ghOptions) - const mapped = (JSON.parse(stdout) as Record[]).map((item) => - mapPullRequestWorkItem(item, prOwnerRepo) - ) + ? await ghExecFileAsync(request.args, ghOptions) + : await ghCwdResolvedExec(repoContext, request.args, ghOptions) + const mapped = (JSON.parse(stdout) as Record[]) + .slice(request.offset, request.offset + limit) + .map((item) => mapPullRequestWorkItem(item, prOwnerRepo)) const hydrated = await hydrateWorkItemRepositoryMergeMetadata(mapped, prOwnerRepo, ghOptions) if (query.state === 'closed') { return hydrated.filter((item) => item.state !== 'merged') @@ -1356,7 +1326,7 @@ async function listQueriedWorkItems( const [issueResult, prItems] = await Promise.all([issueFetch, prFetch]) return { - items: sortWorkItemsByUpdatedAt([...issueResult.items, ...prItems]).slice(0, limit), + items: sortWorkItemsByNumber([...issueResult.items, ...prItems]).slice(0, limit), issuesError: issueResult.issuesError } } @@ -1365,13 +1335,14 @@ export async function listWorkItems( repoPath: string, limit = 24, query?: string, - before?: string, + page?: number, preference?: IssueSourcePreference, connectionId?: string | null, noCache?: boolean, localGitOptions: LocalGitExecOptions = {} ): Promise> { const trimmedQuery = query?.trim() ?? '' + const requestedPage = normalizeWorkItemPage(page) if (isGitHubWorkItemsQueryTooLarge(trimmedQuery)) { return { items: [], @@ -1401,6 +1372,7 @@ export async function listWorkItems( issueOwnerRepo, prOwnerRepo, limit, + requestedPage, connectionId, noCache, localGitOptions @@ -1411,7 +1383,7 @@ export async function listWorkItems( prOwnerRepo, parseTaskQuery(trimmedQuery), limit, - before, + requestedPage, connectionId, localGitOptions ) @@ -1480,7 +1452,7 @@ function buildSearchQueryString( } function quoteGitHubSearchValue(value: string): string { - return /\s/.test(value) ? `"${value.replaceAll('"', '\\"')}"` : value + return /[\s"]/.test(value) ? `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"` : value } async function countWorkItemsForQuery( diff --git a/src/main/index.ts b/src/main/index.ts index 21c4a9ab51e..efdd7ee9a6e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -5,7 +5,7 @@ import { existsSync, statSync } from 'node:fs' import { isAbsolute, join } from 'node:path' import os from 'node:os' -import { app, BrowserWindow, dialog, ipcMain, nativeTheme } from 'electron' +import { app, BrowserWindow, dialog, ipcMain, nativeTheme, type Tray } from 'electron' import { electronApp, is } from '@electron-toolkit/utils' import * as QRCode from 'qrcode' import { @@ -54,6 +54,7 @@ import { rebuildAppMenu } from './menu/register-app-menu' import { checkForUpdatesFromMenu, isQuittingForUpdate } from './updater' +import type { UpdateCheckOptions } from '../shared/types' import { recordUpdaterLifecycle } from './updater-lifecycle-diagnostics' import { configureElectronNetworkCompatibility, @@ -118,7 +119,13 @@ import { ensureAutoUpdaterConfigured } from './window/attach-main-window-services' import { createMainWindow, loadMainWindow } from './window/createMainWindow' -import { createSystemTray, destroySystemTray, setTrayAttention } from './tray/system-tray' +import { + createSystemTray, + destroySystemTray, + setMacMenuBarIconVisible, + setTrayAttention, + type SystemTrayOptions +} from './tray/system-tray' import { focusExistingMainWindow } from './window/focus-existing-window' import { notifyMainWindowBecameVisible } from './window/main-window-visibility' import { CodexAccountService } from './codex-accounts/service' @@ -156,7 +163,6 @@ import { } from './ipc/pty' import { AgentBrowserBridge } from './browser/agent-browser-bridge' import { EmulatorBridge } from './emulator/emulator-bridge' -import { serveSimStateWatcher } from './emulator/serve-sim-state-watcher' import { browserManager } from './browser/browser-manager' import { OffscreenBrowserBackend } from './browser/offscreen-browser-backend' import { initializeBrowserSessionsForApp } from './browser/browser-session-startup' @@ -240,6 +246,9 @@ let keybindings: KeybindingService | null = null // sweep spare live sessions across that one reload (#5787). const expectedRendererReload = createWebContentsTimedFlag() const recoveryReloadInFlight = createWebContentsTimedFlag() +// Why: a tray/menu-bar "Settings…" click can precede the renderer attaching +// its ui:openSettings listener; the renderer pulls this one-shot on mount. +const pendingOpenSettings = createWebContentsTimedFlag() let firstWindowStartupServicesReady: Promise = Promise.resolve() let managedWslCliReconciliationReady: Promise = Promise.resolve() let managedWslCliStartupBarrierReady: Promise = Promise.resolve() @@ -667,6 +676,12 @@ ipcMain.handle('app:awaitFirstWindowStartupServices', async () => { await Promise.all([firstWindowStartupServicesReady, managedWslCliStartupBarrierReady]) }) +// Why: the renderer pulls this once its ui:openSettings listener is attached so +// a tray/menu-bar Settings request queued before mount is not lost to a race. +ipcMain.handle('ui:consumePendingOpenSettings', (event) => + pendingOpenSettings.matches(event.sender.id, { consume: true }) +) + ipcMain.handle( 'app:startupDiagnostic', (_event, event: string, details?: Record) => { @@ -789,6 +804,69 @@ function showMainWindowFromTray(): void { } } +function openSettingsFromSystemMenu(): void { + showMainWindowFromTray() + const targetWindow = mainWindow && !mainWindow.isDestroyed() ? mainWindow : null + if (!targetWindow) { + return + } + recordCrashBreadcrumb('settings_opened') + + // Why: no main-side signal proves the renderer listener is attached, so push + // and leave a one-shot intent — a mounted renderer acts on the push, an + // unmounted one pulls the intent at mount; only one fires per renderer life. + targetWindow.webContents.send('ui:openSettings') + // Why: leave an untimed intent — any TTL can be outrun by a slow cold start and + // would silently drop the Settings click. webContents-id scoping plus consume-on- + // read still prevents the intent from leaking to a later, unrelated renderer. + pendingOpenSettings.mark(targetWindow.webContents.id, Number.POSITIVE_INFINITY) +} + +function quitFromSystemTray(): void { + if (mainWindow && !mainWindow.isDestroyed()) { + // Why: a real quit can still surface renderer save/discard prompts; the + // window must be visible if a hidden session vetoes shutdown. + showMainWindowFromTray() + } + // Why: set the quit latch before app.quit() so the window 'close' handler + // proceeds to teardown instead of re-hiding to the Windows tray. + isQuitting = true + app.quit() +} + +// Why: a manual check must run against a configured updater; the menu and +// tray are both clickable before anything else configures it. +function runUserInitiatedUpdateCheck(options?: UpdateCheckOptions): void { + ensureAutoUpdaterConfigured() + checkForUpdatesFromMenu(options) +} + +function getSystemTrayOptions(): SystemTrayOptions | null { + if (!store) { + return null + } + return { + appIcon: store.getSettings().appIcon, + onOpen: showMainWindowFromTray, + onOpenSettings: openSettingsFromSystemMenu, + onCheckForUpdates: () => { + // Why: updater status renders in the main window; with every window + // closed a bare check would complete invisibly. + showMainWindowFromTray() + runUserInitiatedUpdateCheck() + }, + onQuit: quitFromSystemTray + } +} + +function syncMacMenuBarIcon(showMenuBarIcon: boolean): Tray | null { + if (process.platform !== 'darwin' || isServeMode) { + return null + } + const options = getSystemTrayOptions() + return options ? setMacMenuBarIconVisible(showMenuBarIcon, options) : null +} + function openMainWindow(): BrowserWindow { logStartupMilestone('open-main-window-start') if (!store) { @@ -900,38 +978,26 @@ function openMainWindow(): BrowserWindow { }) recordCrashBreadcrumb('main_window_created') logStartupMilestone('window-created') - // Why: new Tray() is a synchronous Shell_NotifyIcon call that can block for - // seconds while explorer.exe's notification area is busy (part of issue - // #7225's pre-paint stall), so create it after first paint. The timer - // fallback covers windows revealed without ready-to-show ever firing - // (createMainWindow's 10s reveal fallback) — those can still be - // hidden to the tray on close, so the icon must exist by then. + // Why: Windows Tray construction can synchronously block on Shell_NotifyIcon, + // so both desktop status-item platforms defer creation to after first paint. let trayCreated = false const createSystemTrayDeferred = (): void => { if (trayCreated || window.isDestroyed() || isQuitting || !store) { return } trayCreated = true - // Why: Windows-only system tray. createSystemTray is idempotent and a - // no-op off win32, so calling it on each window open keeps exactly one - // live icon. - createSystemTray({ - appIcon: store.getSettings().appIcon, - onOpen: showMainWindowFromTray, - onQuit: () => { - if (mainWindow && !mainWindow.isDestroyed()) { - // Why: a real quit can still surface renderer save/discard prompts; - // the window must be visible if a hidden-to-tray session vetoes - // shutdown. - showMainWindowFromTray() - } - // Why: set the quit latch before app.quit() so the window 'close' - // handler proceeds to teardown instead of re-hiding to the tray. - isQuitting = true - app.quit() + if (process.platform === 'darwin') { + // Why: route through syncMacMenuBarIcon so startup and the live toggle + // share one serve-mode/visibility policy. + if (syncMacMenuBarIcon(store.getSettings().showMenuBarIcon !== false)) { + logStartupMilestone('tray-created') } - }) - logStartupMilestone('tray-created') + return + } + const options = getSystemTrayOptions() + if (options && createSystemTray(options)) { + logStartupMilestone('tray-created') + } } window.once('ready-to-show', () => { logStartupMilestone('ready-to-show') @@ -1681,6 +1747,13 @@ app.whenReady().then(async () => { const activeOrcaProfile = ensureActiveOrcaProfile() store = new Store({ dataFile: activeOrcaProfile.dataFile }) logStartupMilestone('store-loaded') + store.onSettingsChanged((updates, settings) => { + if ('showMenuBarIcon' in updates) { + // Why: Store is the mutation authority for renderer, RPC, and future + // settings writes, so every macOS toggle updates the native item live. + syncMacMenuBarIcon(settings.showMenuBarIcon !== false) + } + }) // Why: must run before ClaudeRuntimeAuthService's constructor sync — a Claude // CLI that survived the restart inside the daemon still holds the current // single-use refresh token, and an unguarded early refresh would rotate it @@ -1958,16 +2031,10 @@ app.whenReady().then(async () => { ) // Emulator bridge (serve-sim). macOS-only feature (gated in CLI/runtime); always ship like agent-browser. + // Why: only Orca-managed or explicitly attached helpers belong to a workspace; + // externally started serve-sim processes must remain independent from Orca. const emulatorBridge = new EmulatorBridge() runtimeService.setEmulatorBridge(emulatorBridge) - serveSimStateWatcher.start() - serveSimStateWatcher.onDetected(({ worktreeId, info }) => { - runtimeService.getEmulatorBridge()?.registerActiveEmulator(worktreeId, info, { - managed: false - }) - serveSimStateWatcher.markOrcaManaged(info) - runtimeService.notifyEmulatorAutoAttachFromWatcher(worktreeId, info) - }) nativeTheme.themeSource = store.getSettings().theme ?? 'system' if (shouldInstallManagedHooks(is.dev)) { // Why: the persisted off switch must run before any auto-install path so @@ -2002,22 +2069,14 @@ app.whenReady().then(async () => { logStartupMilestone('i18n-ready') registerAppMenu({ - onCheckForUpdates: (options) => { - // Why: the menu is clickable before first paint; a manual check must - // run against a configured updater (see attach-main-window-services). - ensureAutoUpdaterConfigured() - return checkForUpdatesFromMenu(options) - }, + onCheckForUpdates: (options) => runUserInitiatedUpdateCheck(options), onBeforeReload: ({ ignoreCache, webContentsId }) => { if (mainWindow?.webContents.id === webContentsId) { markExpectedRendererReload(webContentsId) } recordCrashBreadcrumb('manual_reload_requested', { ignoreCache }) }, - onOpenSettings: () => { - recordCrashBreadcrumb('settings_opened') - mainWindow?.webContents.send('ui:openSettings') - }, + onOpenSettings: openSettingsFromSystemMenu, onOpenSetupGuide: (targetWindow) => { recordCrashBreadcrumb('setup_guide_opened') const targetBrowserWindow = targetWindow instanceof BrowserWindow ? targetWindow : null @@ -2335,7 +2394,6 @@ app.on('will-quit', (e) => { // down explicitly on quit alongside the other browser/session shutdowns. runtime?.getOffscreenBrowserBackend()?.destroyAll?.() const emulatorShutdown = runtime?.getEmulatorBridge()?.destroyAllSessions() ?? Promise.resolve() - serveSimStateWatcher.stop() killAllPty() const watcherShutdown = shutdownWatchersOnce() store?.flush() diff --git a/src/main/ipc/filesystem-list-files-git-fallback-real.test.ts b/src/main/ipc/filesystem-list-files-git-fallback-real.test.ts index 95315f6b00d..0cf672fa30f 100644 --- a/src/main/ipc/filesystem-list-files-git-fallback-real.test.ts +++ b/src/main/ipc/filesystem-list-files-git-fallback-real.test.ts @@ -126,6 +126,18 @@ describe('filesystem-list-files real git fallback', () => { ]) }) + it('bounds a non-git readdir fallback without treating the limit as an error', async () => { + checkRgAvailableMock.mockResolvedValue(false) + tempDir = await mkdtemp(join(tmpdir(), 'orca-quick-open-bounded-non-git-')) + await writeRel(tempDir, 'a.ts') + await writeRel(tempDir, 'b.ts') + + const files = await listQuickOpenFiles(tempDir, makeStore(tempDir), undefined, undefined, 1) + + expect(files).toHaveLength(1) + expect(['a.ts', 'b.ts']).toContain(files[0]) + }) + it('rejects abnormal git ls-files failures instead of resolving an empty list', async () => { checkRgAvailableMock.mockResolvedValue(false) tempDir = await mkdtemp(join(tmpdir(), 'orca-quick-open-bad-index-')) diff --git a/src/main/ipc/filesystem-list-files-git-fallback.ts b/src/main/ipc/filesystem-list-files-git-fallback.ts index 616a9a10c93..ff390d13caf 100644 --- a/src/main/ipc/filesystem-list-files-git-fallback.ts +++ b/src/main/ipc/filesystem-list-files-git-fallback.ts @@ -1,10 +1,15 @@ import type { ChildProcess } from 'node:child_process' import { gitSpawn } from '../git/runner' -import { buildGitLsFilesArgsForQuickOpen } from '../../shared/quick-open-filter' +import { + buildGitLsFilesArgsForQuickOpen, + shouldExcludeQuickOpenRelPath, + shouldIncludeQuickOpenPath +} from '../../shared/quick-open-filter' import { createQuickOpenReaddirBudget, expandQuickOpenGitFileListing, - listQuickOpenFilesWithReaddir + listQuickOpenFilesWithReaddir, + parseQuickOpenGitLsFilesEntry } from '../../shared/quick-open-readdir-walk' import { fileListingCancellationError } from '../../shared/file-listing-cancellation' @@ -73,7 +78,8 @@ export async function listFilesWithGit( rootPath: string, excludePathPrefixes: readonly string[], localGitOptions: { wslDistro?: string }, - signal?: AbortSignal + signal?: AbortSignal, + maxResults?: number ): Promise { const isGitWorkTree = await isInsideGitWorkTree(rootPath, localGitOptions, signal) if (signal?.aborted) { @@ -83,17 +89,20 @@ export async function listFilesWithGit( return listQuickOpenFilesWithReaddir(rootPath, { excludePathPrefixes, budget: createQuickOpenReaddirBudget(), + maxResults, signal }) } const gitPaths = new Set() const directoryPaths = new Set() + const directFileCandidates = new Set() const { primary, ignoredPass } = buildGitLsFilesArgsForQuickOpen(excludePathPrefixes) const children: { child: ChildProcess isDone: () => boolean reject: (error: Error) => void + resolve: () => void }[] = [] const runGitLsFiles = (args: string[]): Promise => { @@ -101,15 +110,32 @@ export async function listFilesWithGit( let buf = '' let done = false - const processPath = (path: string): void => { + const processPath = (path: string): boolean => { if (!path) { - return + return false } if (path.endsWith('/')) { directoryPaths.add(path) } else { gitPaths.add(path) + if (maxResults !== undefined) { + // Why: this duplicate classification exists only to stop bounded + // scans; unbounded scans must not retain a second repo-sized set. + const parsed = parseQuickOpenGitLsFilesEntry(path) + const relPath = parsed.path.replace(/\/+$/, '') + if ( + !parsed.isGitlink && + !parsed.isUntrackedDir && + shouldIncludeQuickOpenPath(relPath) && + !shouldExcludeQuickOpenRelPath(relPath, excludePathPrefixes) + ) { + directFileCandidates.add(relPath) + } + } } + // Why: collapsed directories and gitlinks may be discarded during the + // later filesystem classification, so they cannot consume the stop cap. + return maxResults !== undefined && directFileCandidates.size >= maxResults } // Why: git ls-files outputs paths relative to cwd, so we set cwd to @@ -149,14 +175,19 @@ export async function listFilesWithGit( children.push({ child, isDone: () => done, - reject: rejectPass + reject: rejectPass, + resolve: resolvePass }) const handleStdoutData = (chunk: string): void => { buf += chunk let start = 0 let nulIdx = buf.indexOf('\0', start) while (nulIdx !== -1) { - processPath(buf.substring(start, nulIdx)) + if (processPath(buf.substring(start, nulIdx))) { + buf = '' + finishAtLimit() + return + } start = nulIdx + 1 nulIdx = buf.indexOf('\0', start) } @@ -176,8 +207,10 @@ export async function listFilesWithGit( rejectPass(new Error(`git ls-files killed by ${signal}`)) return } - if (buf) { - processPath(buf) + if (buf && processPath(buf)) { + buf = '' + finishAtLimit() + return } if (code === 0) { resolvePass() @@ -213,22 +246,39 @@ export async function listFilesWithGit( } } + function finishAtLimit(): void { + for (const entry of children) { + if (entry.isDone()) { + continue + } + entry.resolve() + if (entry.child.exitCode === null && entry.child.signalCode === null) { + entry.child.kill() + } + } + } + const onAbort = (): void => killSurvivors('git ls-files cancelled') signal?.addEventListener('abort', onAbort, { once: true }) try { - await Promise.all([ - runGitLsFiles(primary), + const runIgnoredPass = () => // Why: ignored files are supplementary — a failed or timed-out ignored - // pass must not discard the primary listing the user actually needs - // (#7719 root cause: the all-or-nothing failure showed zero files). - // Entries streamed before the failure are kept; a cancelled scan still - // rejects via the primary pass or the expansion's cancellation check. + // pass must not discard the primary listing the user actually needs. runGitLsFiles(ignoredPass).catch((err: Error) => { if (!signal?.aborted) { console.warn('[quick-open] git ignored-file pass failed; keeping primary results:', err) } }) - ]) + if (maxResults === undefined) { + await Promise.all([runGitLsFiles(primary), runIgnoredPass()]) + } else { + // Why: give ordinary source files first claim on a bounded autocomplete + // inventory; a large ignored tree must not win a parallel-output race. + await runGitLsFiles(primary) + if (directFileCandidates.size < maxResults) { + await runIgnoredPass() + } + } } catch (err) { killSurvivors() if (signal?.aborted) { @@ -244,9 +294,10 @@ export async function listFilesWithGit( gitPaths, directoryPaths, excludePathPrefixes, - signal + signal, + maxResults }) // Why: directory placeholders are expanded after Git exits; restore Git's // path order so empty queries and fuzzy-score ties remain stable. - return files.sort() + return files.sort().slice(0, maxResults) } diff --git a/src/main/ipc/filesystem-list-files.test.ts b/src/main/ipc/filesystem-list-files.test.ts index ba535a25868..946f8653dd6 100644 --- a/src/main/ipc/filesystem-list-files.test.ts +++ b/src/main/ipc/filesystem-list-files.test.ts @@ -71,6 +71,30 @@ describe('filesystem-list-files', () => { getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({}) }) + it('stops after the primary rg pass fills the result budget', async () => { + const p1 = createMockProcess() + const p2 = createMockProcess() + spawnMock.mockImplementation((_cmd, args: string[]) => (isIgnoredRgPass(args) ? p2 : p1)) + const promise = listQuickOpenFiles( + '/mock/root', + {} as unknown as Store, + undefined, + undefined, + 2 + ) + + setTimeout(() => { + ;(p1.stdout as unknown as EventEmitter).emit('data', 'one.ts\ntwo.ts') + p1.emit('close', 0, null) + }, 0) + const result = await promise + + expect(result).toEqual(['one.ts', 'two.ts']) + expect(p1.kill).toHaveBeenCalled() + expect(p2.kill).not.toHaveBeenCalled() + expect(spawnMock).toHaveBeenCalledTimes(1) + }) + it('merges normal files and ignored files and filters correctly', async () => { const p1 = createMockProcess() const p2 = createMockProcess() @@ -377,6 +401,73 @@ describe('filesystem-list-files', () => { expect(result).not.toContain('node_modules/dep/index.js') }) + it('stops after primary Git files fill the result budget', async () => { + checkRgAvailableMock.mockResolvedValue(false) + const revParseProc = createMockProcess() + const gitP1 = createMockProcess() + const gitP2 = createMockProcess() + let callIndex = 0 + + spawnMock.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'git' && args.includes('rev-parse')) { + return revParseProc + } + if (cmd === 'git' && args.includes('ls-files')) { + callIndex += 1 + return callIndex === 1 ? gitP1 : gitP2 + } + return createMockProcess() + }) + + const promise = listQuickOpenFiles( + '/mock/root', + {} as unknown as Store, + undefined, + undefined, + 2 + ) + setTimeout(() => revParseProc.emit('close', 0, null), 0) + setTimeout(() => { + ;(gitP1.stdout as unknown as EventEmitter).emit('data', 'one.ts\0two.ts') + gitP1.emit('close', 0, null) + }, 10) + + await expect(promise).resolves.toEqual(['one.ts', 'two.ts']) + expect(gitP1.kill).toHaveBeenCalled() + expect(gitP2.kill).not.toHaveBeenCalled() + expect(callIndex).toBe(1) + }) + + it('does not let a discarded Git directory placeholder consume the result budget', async () => { + checkRgAvailableMock.mockResolvedValue(false) + const revParseProc = createMockProcess() + const primary = createMockProcess() + spawnMock.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'git' && args.includes('rev-parse')) { + return revParseProc + } + return primary + }) + + const promise = listQuickOpenFiles( + '/mock/root', + {} as unknown as Store, + undefined, + undefined, + 1 + ) + setTimeout(() => revParseProc.emit('close', 0, null), 0) + setTimeout(() => { + ;(primary.stdout as unknown as EventEmitter).emit( + 'data', + `discarded/\0${staged('100644', 'src/kept.ts')}\0` + ) + }, 10) + + await expect(promise).resolves.toEqual(['src/kept.ts']) + expect(primary.kill).toHaveBeenCalled() + }) + it('git fallback applies hidden dir blocklist', async () => { checkRgAvailableMock.mockResolvedValue(false) diff --git a/src/main/ipc/filesystem-list-files.ts b/src/main/ipc/filesystem-list-files.ts index 9dcd2c45038..58b1c0c0cad 100644 --- a/src/main/ipc/filesystem-list-files.ts +++ b/src/main/ipc/filesystem-list-files.ts @@ -20,7 +20,8 @@ export async function listQuickOpenFiles( rootPath: string, store: Store, excludePaths?: string[], - signal?: AbortSignal + signal?: AbortSignal, + maxResults?: number ): Promise { const authorizedRootPath = await resolveAuthorizedPath(rootPath, store) const localGitOptions = getLocalGitOptionsForRegisteredWorktree( @@ -41,11 +42,21 @@ export async function listQuickOpenFiles( // can run. const rgAvailable = await checkRgAvailable(authorizedRootPath, localGitOptions.wslDistro) if (!rgAvailable) { - return listFilesWithGit(authorizedRootPath, excludePathPrefixes, localGitOptions, signal) + return listFilesWithGit( + authorizedRootPath, + excludePathPrefixes, + localGitOptions, + signal, + maxResults + ) } const files = new Set() - const children: ChildProcess[] = [] + const children: { + child: ChildProcess + isDone: () => boolean + finish: () => void + }[] = [] // Why: WSL-routed rg can emit Linux-native absolute paths. UNC repos carry // their distro in the path; Windows-path repos carry it in project runtime. const wslDistroForOutput = parseWslPath(authorizedRootPath)?.distro ?? localGitOptions.wslDistro @@ -67,7 +78,7 @@ export async function listQuickOpenFiles( let done = false let parseablePathCount = 0 - const processLine = (rawLine: string): void => { + const processLine = (rawLine: string): boolean => { const translated = wslDistroForOutput && rawLine.startsWith('/') ? toWindowsWslPath(rawLine, wslDistroForOutput) @@ -77,16 +88,20 @@ export async function listQuickOpenFiles( getQuickOpenRgOutputMode(rawLine, translated, authorizedRootPath) ) if (relPath === null) { - return + return false } parseablePathCount++ if (!shouldIncludeQuickOpenPath(relPath)) { - return + return false } if (shouldExcludeQuickOpenRelPath(relPath, excludePathPrefixes)) { - return + return false + } + if (maxResults !== undefined && files.size >= maxResults) { + return true } files.add(relPath) + return maxResults !== undefined && files.size >= maxResults } const child = wslAwareSpawn('rg', args, { @@ -94,14 +109,17 @@ export async function listQuickOpenFiles( ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}), stdio: ['ignore', 'pipe', 'pipe'] }) - children.push(child) let timer: ReturnType const handleStdoutData = (chunk: string): void => { buf += chunk let start = 0 let newlineIdx = buf.indexOf('\n', start) while (newlineIdx !== -1) { - processLine(buf.substring(start, newlineIdx)) + if (processLine(buf.substring(start, newlineIdx))) { + buf = '' + finishAtLimit() + return + } start = newlineIdx + 1 newlineIdx = buf.indexOf('\n', start) } @@ -125,8 +143,10 @@ export async function listQuickOpenFiles( finish(new Error(`rg killed by ${signal}`)) return } - if (buf) { - processLine(buf) + if (buf && processLine(buf)) { + buf = '' + finishAtLimit() + return } if (code === 0 || code === 1) { finish() @@ -157,6 +177,8 @@ export async function listQuickOpenFiles( } } + children.push({ child, isDone: () => done, finish }) + child.stdout!.setEncoding('utf-8') child.stdout!.on('data', handleStdoutData) child.stderr!.on('data', handleStderrData) @@ -176,20 +198,45 @@ export async function listQuickOpenFiles( // Why: if one rg pass fails, Promise.all rejects immediately while the // sibling scan can keep walking a huge tree until timeout. Stop it so // repeated Quick Open attempts do not accumulate local rg processes. - for (const child of children) { - if (child.exitCode === null && child.signalCode === null) { - child.kill() + for (const entry of children) { + if (entry.isDone()) { + continue + } + entry.finish() + if (entry.child.exitCode === null && entry.child.signalCode === null) { + entry.child.kill() + } + } + } + + function finishAtLimit(): void { + for (const entry of children) { + if (entry.isDone()) { + continue + } + entry.finish() + if (entry.child.exitCode === null && entry.child.signalCode === null) { + entry.child.kill() } } } try { - await Promise.all([runRg(primary), runRg(ignoredPass)]) + if (maxResults === undefined) { + await Promise.all([runRg(primary), runRg(ignoredPass)]) + } else { + // Why: ignored-file output can be much larger and faster than the primary + // pass; let source files claim the bounded autocomplete budget first. + await runRg(primary) + if (files.size < maxResults) { + await runRg(ignoredPass) + } + } } catch (err) { killSurvivors() throw err } - return Array.from(files) + return Array.from(files).slice(0, maxResults) } function getQuickOpenRgOutputMode( diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index 2e9d1843988..c1c319c55f1 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -1148,6 +1148,72 @@ describe('registerFilesystemHandlers', () => { }) }) + it('forwards line-stat reuse through local and SSH git status IPC', async () => { + registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH]) + getStatusMock.mockResolvedValue({ entries: [], conflictOperation: 'unknown' }) + const sshProvider = { + getStatus: vi.fn().mockResolvedValue({ entries: [], conflictOperation: 'unknown' }) + } + getSshGitProviderMock.mockReturnValue(sshProvider) + registerFilesystemHandlers(store as never) + + await handlers.get('git:status')!(null, { + worktreePath: WORKTREE_FEATURE_PATH, + reuseLineStats: true + }) + await handlers.get('git:status')!(null, { + worktreePath: '/remote/repo', + connectionId: 'ssh-1', + reuseLineStats: true + }) + + expect(getStatusMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, { + includeIgnored: false, + reuseLineStats: true + }) + expect(sshProvider.getStatus).toHaveBeenCalledWith('/remote/repo', { + includeIgnored: false, + reuseLineStats: true + }) + }) + + it('aborts tokenized local status without crossing renderer boundaries', async () => { + registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH]) + const statusSignals: AbortSignal[] = [] + getStatusMock.mockImplementation( + (_worktreePath: string, options: { signal?: AbortSignal }) => + new Promise((_resolve, reject) => { + if (options.signal) { + statusSignals.push(options.signal) + options.signal.addEventListener('abort', () => reject(new Error('aborted')), { + once: true + }) + } + }) + ) + registerFilesystemHandlers(store as never) + + const firstEvent = { sender: { id: 7 } } + const secondEvent = { sender: { id: 8 } } + const firstRequest = handlers.get('git:status')!(firstEvent, { + worktreePath: WORKTREE_FEATURE_PATH, + requestToken: 'status-1' + }) as Promise + const secondRequest = handlers.get('git:status')!(secondEvent, { + worktreePath: WORKTREE_FEATURE_PATH, + requestToken: 'status-1' + }) as Promise + await vi.waitFor(() => expect(statusSignals).toHaveLength(2)) + await handlers.get('git:cancelStatus')!(firstEvent, { requestToken: 'status-1' }) + + expect(statusSignals[0]?.aborted).toBe(true) + expect(statusSignals[1]?.aborted).toBe(false) + await expect(firstRequest).rejects.toThrow('aborted') + + await handlers.get('git:cancelStatus')!(secondEvent, { requestToken: 'status-1' }) + await expect(secondRequest).rejects.toThrow('aborted') + }) + it('checks ignored paths through local and SSH git providers', async () => { registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH]) checkIgnoredPathsMock.mockResolvedValue(['dist/bundle.js']) @@ -2364,20 +2430,23 @@ describe('registerFilesystemHandlers', () => { registerFilesystemHandlers(store as never) - const pending = handlers.get('fs:listFiles')!(null, { + // Why: cancellation keys are scoped to the issuing webContents, so the + // cancel must come from the same sender as the listing request. + const senderEvent = { sender: { id: 7 } } + const pending = handlers.get('fs:listFiles')!(senderEvent, { rootPath: '/home/user/repo', connectionId: 'conn-1', requestToken: 'token-1' }) as Promise expect(capturedSignal?.aborted).toBe(false) - await handlers.get('fs:cancelListFiles')!(null, { requestToken: 'token-1' }) + await handlers.get('fs:cancelListFiles')!(senderEvent, { requestToken: 'token-1' }) expect(capturedSignal?.aborted).toBe(true) await expect(pending).rejects.toThrow('listing cancelled') // Unknown or already-settled tokens are a no-op, not an error. expect(() => - handlers.get('fs:cancelListFiles')!(null, { requestToken: 'unknown' }) + handlers.get('fs:cancelListFiles')!(senderEvent, { requestToken: 'unknown' }) ).not.toThrow() }) }) diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 3918f8d3ea4..931c28c401f 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -122,6 +122,7 @@ import { getRuntimePathBasename } from '../../shared/cross-platform-path' import type { LocalProjectWorktreeGitOptions } from '../project-runtime-git-options' import { registerLocalLogTailHandlers } from './local-log-tail' import { localLogFileIdentity } from '../ai-vault/local-log-tail-reader' +import { createSenderScopedRequestCancellations } from './sender-scoped-request-cancellation' // Why: Monaco has large-file optimizations like VS Code; blocking at 5MB makes // ordinary JSON/log files inaccessible before the editor can degrade features. @@ -1072,11 +1073,11 @@ export function registerFilesystemHandlers( // Why #7721: keyed by renderer-generated token so a workspace switch can // abort the previous workspace's full-tree scan (SSH relays otherwise stack // scans that starve interactive fs.readDir/fs.stat past their 30s timeout). - const listFilesCancellations = new Map() + const listFilesCancellations = createSenderScopedRequestCancellations() ipcMain.handle( 'fs:listFiles', async ( - _event, + event, args: { rootPath: string connectionId?: string @@ -1084,10 +1085,7 @@ export function registerFilesystemHandlers( requestToken?: string } ): Promise => { - const controller = args.requestToken ? new AbortController() : null - if (controller && args.requestToken) { - listFilesCancellations.set(args.requestToken, controller) - } + const controller = listFilesCancellations.begin(event, args.requestToken) try { if (args.connectionId) { const provider = getSshFilesystemProvider(args.connectionId) @@ -1108,53 +1106,66 @@ export function registerFilesystemHandlers( } return await listQuickOpenFiles(args.rootPath, store, args.excludePaths, controller?.signal) } finally { - if (args.requestToken) { - listFilesCancellations.delete(args.requestToken) - } + listFilesCancellations.finish(event, args.requestToken, controller) } } ) - ipcMain.handle('fs:cancelListFiles', (_event, args: { requestToken: string }): void => { - // Why: best-effort — the entry is gone once the listing settles. - listFilesCancellations.get(args.requestToken)?.abort() + ipcMain.handle('fs:cancelListFiles', (event, args: { requestToken: string }): void => { + listFilesCancellations.cancel(event, args.requestToken) }) // ─── Git operations ───────────────────────────────────── + const gitStatusCancellations = createSenderScopedRequestCancellations() ipcMain.handle( 'git:status', async ( - _event, + event, args: { worktreePath: string connectionId?: string includeIgnored?: boolean bypassEffectiveUpstreamNegativeCache?: boolean + reuseLineStats?: boolean + requestToken?: string } ): Promise => { + const controller = gitStatusCancellations.begin(event, args.requestToken) const options = { includeIgnored: args.includeIgnored ?? false, + ...(args.reuseLineStats === true ? { reuseLineStats: true } : {}), ...(args.bypassEffectiveUpstreamNegativeCache === true ? { bypassEffectiveUpstreamNegativeCache: true } - : {}) + : {}), + ...(controller ? { signal: controller.signal } : {}) } - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + try { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + // Why: awaiting here keeps the cancellation token registered until + // the remote request settles instead of running finally immediately. + return await provider.getStatus(args.worktreePath, options) } - return provider.getStatus(args.worktreePath, options) + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return await getStatus(worktreePath, { ...options, ...gitOptions }) + } finally { + gitStatusCancellations.finish(event, args.requestToken, controller) } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - return getStatus(worktreePath, { ...options, ...gitOptions }) } ) + ipcMain.handle('git:cancelStatus', (event, args: { requestToken: string }): void => { + gitStatusCancellations.cancel(event, args.requestToken) + }) + // Why: the parent status only reports one gitlink row per submodule. When the // user expands a dirty submodule, this fetches the inner per-file changes by // running a plain status inside the submodule's own worktree (read-only). diff --git a/src/main/ipc/github.test.ts b/src/main/ipc/github.test.ts index fef0cf7aace..71f5d286624 100644 --- a/src/main/ipc/github.test.ts +++ b/src/main/ipc/github.test.ts @@ -668,7 +668,7 @@ describe('registerGitHubHandlers', () => { repoPath: '/workspace/repo', limit: 10, query: 'is:open', - before: 'cursor-1', + page: 2, noCache: true }) @@ -676,7 +676,7 @@ describe('registerGitHubHandlers', () => { '/workspace/repo', 10, 'is:open', - 'cursor-1', + 2, 'origin', null, true @@ -732,7 +732,7 @@ describe('registerGitHubHandlers', () => { repoPath: '/workspace/repo', limit: 10, query: 'is:open', - before: 'cursor-1', + page: 2, noCache: true }) await handlers['gh:countWorkItems'](null, { @@ -789,7 +789,7 @@ describe('registerGitHubHandlers', () => { '/workspace/repo', 10, 'is:open', - 'cursor-1', + 2, undefined, null, true, diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index a95fb887e9c..84afec20c3b 100644 --- a/src/main/ipc/github.ts +++ b/src/main/ipc/github.ts @@ -433,7 +433,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi repoId?: string limit?: number query?: string - before?: string + page?: number noCache?: boolean } ) => { @@ -442,7 +442,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi repo.path, args.limit, args.query, - args.before, + args.page, repo.issueSourcePreference, repoConnectionId(repo), args.noCache, diff --git a/src/main/ipc/jira.ts b/src/main/ipc/jira.ts index e4a0e7ff895..44335aae86a 100644 --- a/src/main/ipc/jira.ts +++ b/src/main/ipc/jira.ts @@ -91,7 +91,8 @@ export function registerJiraHandlers(): void { const result = await connect({ siteUrl: args.siteUrl, email: args.email, - apiToken: args.apiToken + apiToken: args.apiToken, + authType: args.authType === 'server' ? 'server' : 'cloud' }) if (result.ok) { _resetPreflightCache() diff --git a/src/main/ipc/native-chat-subscribe-lifecycle.test.ts b/src/main/ipc/native-chat-subscribe-lifecycle.test.ts index 53588814848..516f839fca9 100644 --- a/src/main/ipc/native-chat-subscribe-lifecycle.test.ts +++ b/src/main/ipc/native-chat-subscribe-lifecycle.test.ts @@ -30,6 +30,7 @@ import { type TestSubscription = { unsubscribe: ReturnType + watching: boolean } type DeferredSubscription = { @@ -69,7 +70,7 @@ function deferredSubscription(): DeferredSubscription { return { promise, reject: rejectPromise, - resolve: () => resolvePromise({ unsubscribe }), + resolve: () => resolvePromise({ unsubscribe, watching: true }), unsubscribe } } @@ -106,6 +107,23 @@ function subscribe(sender: SenderHarness['sender'], subscriptionId: string): voi listener({ sender }, { subscriptionId, agent: 'claude', sessionId: `session-${subscriptionId}` }) } +type InitialSnapshotCallback = ( + messages: unknown[], + hasMore: boolean, + beforeOffset: number, + error?: string +) => void + +// The onInitialSnapshot callback the handler passed into the Nth subscribeTranscript +// call; transcript-watch fires it during setup, so tests invoke it directly. +function initialSnapshot(callIndex: number): InitialSnapshotCallback { + const call = subscribeTranscript.mock.calls[callIndex] + if (!call) { + throw new Error('subscribeTranscript was not called') + } + return (call[0] as { onInitialSnapshot: InitialSnapshotCallback }).onInitialSnapshot +} + function unsubscribe(sender: SenderHarness['sender'], subscriptionId: string): void { const listener = listeners.get('nativeChat:unsubscribe') if (!listener) { @@ -225,4 +243,39 @@ describe('nativeChat subscribe lifecycle', () => { expect(replacementPending.unsubscribe).toHaveBeenCalledOnce() expect(_getNativeChatSenderCleanupCountForTest()).toBe(0) }) + + it('forwards an initial-drain error onto the snapshot frame', () => { + const pending = deferredSubscription() + subscribeTranscript.mockReturnValueOnce(pending.promise) + const renderer = createSender(5) + + subscribe(renderer.sender, 'drain-error') + // transcript-watch delivers the drain error synchronously via onInitialSnapshot; + // invoke the captured callback to exercise the handler's forwarding closure. + initialSnapshot(0)([], false, 0, 'Transcript unavailable') + + expect(renderer.sender.send).toHaveBeenCalledWith('nativeChat:appended', { + subscriptionId: 'drain-error', + frame: { + type: 'snapshot', + messages: [], + hasMore: false, + error: 'Transcript unavailable' + } + }) + }) + + it('omits error from the snapshot frame on a clean initial drain', () => { + const pending = deferredSubscription() + subscribeTranscript.mockReturnValueOnce(pending.promise) + const renderer = createSender(6) + + subscribe(renderer.sender, 'drain-clean') + initialSnapshot(0)([], false, 0) + + expect(renderer.sender.send).toHaveBeenCalledWith('nativeChat:appended', { + subscriptionId: 'drain-clean', + frame: { type: 'snapshot', messages: [], hasMore: false } + }) + }) }) diff --git a/src/main/ipc/native-chat.test.ts b/src/main/ipc/native-chat.test.ts index d16a39b1f13..4d0e1765a44 100644 --- a/src/main/ipc/native-chat.test.ts +++ b/src/main/ipc/native-chat.test.ts @@ -58,6 +58,7 @@ async function invokeReadSession(args: { agent: string sessionId: string limit?: number + transcriptPath?: string }): Promise { registerNativeChatHandlers() const handler = handlers.get('nativeChat:readSession') @@ -68,6 +69,17 @@ async function invokeReadSession(args: { } describe('nativeChat:readSession handler', () => { + it('preserves notFound so a just-created session stays in retry/loading', async () => { + const result = (await invokeReadSession({ + agent: 'claude', + sessionId: 'missing-session', + transcriptPath: join(tmpdir(), 'orca-native-chat-ipc-does-not-exist.jsonl') + })) as { error?: string; notFound?: true } + + expect(result.error).toBeDefined() + expect(result.notFound).toBe(true) + }) + it('resolves a Claude transcript and returns the full conversation', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-ipc-')) tempRoots.push(root) @@ -152,7 +164,7 @@ describe('nativeChat:readSession handler', () => { } }) - it('emits appended messages over nativeChat:appended and tears down on destroy', async () => { + it('emits snapshot and appended frames and tears down on destroy', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-ipc-sub-')) tempRoots.push(root) const projectsDir = join(root, '.claude', 'projects') @@ -214,13 +226,14 @@ describe('nativeChat:readSession handler', () => { })}\n` ) - // Seed-at-0 means the first appended event carries the whole-file re-read; - // the new turn 'a-1' arrives across one of the appended events. Collect ids - // from every appended event and assert the new turn shows up. + // The first frame is a bounded snapshot and later frames are appends. + // Collect ids across both and assert the new turn shows up. const appendedIds = (): string[] => sent .filter((s) => s.channel === 'nativeChat:appended') - .flatMap((s) => (s.payload as { messages: { id: string }[] }).messages.map((m) => m.id)) + .flatMap((s) => + (s.payload as { frame: { messages: { id: string }[] } }).frame.messages.map((m) => m.id) + ) await waitFor(() => appendedIds().includes('a-1')) const appendedEvent = sent.find((s) => s.channel === 'nativeChat:appended')! const payload = appendedEvent.payload as { subscriptionId: string } diff --git a/src/main/ipc/native-chat.ts b/src/main/ipc/native-chat.ts index 3ed4e9b74a6..227918a68f3 100644 --- a/src/main/ipc/native-chat.ts +++ b/src/main/ipc/native-chat.ts @@ -1,12 +1,10 @@ import { ipcMain, type IpcMainEvent, type WebContents } from 'electron' import type { AgentType, NativeChatMessage } from '../../shared/native-chat-types' -import { - clearNativeChatTranscriptCache, - readNativeChatTranscriptCached -} from '../native-chat/transcript-read-cache' +import { clearNativeChatTranscriptCache } from '../native-chat/transcript-read-cache' import type { ReadTranscriptResult } from '../native-chat/transcript-reader' import { subscribeNativeChatTranscript, + readNativeChatTranscriptTail, type NativeChatTranscriptSubscription } from '../native-chat/transcript-watch' @@ -25,27 +23,20 @@ export type NativeChatReadSessionArgs = { transcriptPath?: string } -// Why: render only the most recent turns so switching to chat view on a long -// session (thousands of messages) doesn't stall on building that many message -// components. The full transcript is still cached; only the returned slice is -// capped. The renderer raises `limit` to page in older history; live appends -// extend it from there. +// Why: render and parse only the recent window so long transcripts do not stall +// either the main process or the message list. Pagination raises this limit. const DESKTOP_READ_WINDOW = 300 -function windowTranscript(result: ReadTranscriptResult, limit: number): ReadTranscriptResult { - if (!('messages' in result) || result.messages.length <= limit) { - return result - } - return { ...result, messages: result.messages.slice(-limit) } -} - async function readSession(args: NativeChatReadSessionArgs): Promise { const { agent, sessionId } = args // Clamp to a positive window; default to the desktop window for the first page. const limit = args.limit && args.limit > 0 ? Math.floor(args.limit) : DESKTOP_READ_WINDOW - // Desktop is full-class: window by count only, no char truncation. - const result = await readNativeChatTranscriptCached(agent, sessionId, args.transcriptPath) - return windowTranscript(result, limit) + return readNativeChatTranscriptTail({ + agent, + sessionId, + transcriptPath: args.transcriptPath, + limit + }) } export type NativeChatSubscribeArgs = { @@ -56,11 +47,15 @@ export type NativeChatSubscribeArgs = { sessionId: string /** Authoritative transcript path from the agent hook (providerSession). */ transcriptPath?: string + limit?: number } export type NativeChatAppendedPayload = { subscriptionId: string - messages: NativeChatMessage[] + frame: + | { type: 'snapshot'; messages: NativeChatMessage[]; hasMore: boolean; error?: string } + | { type: 'replacement'; messages: NativeChatMessage[]; hasMore: boolean } + | { type: 'appended'; messages: NativeChatMessage[] } } type LiveSubscription = { @@ -144,6 +139,7 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg return } const { subscriptionId, agent, sessionId, transcriptPath } = args + const limit = args.limit && args.limit > 0 ? Math.floor(args.limit) : DESKTOP_READ_WINDOW // Replace any prior subscription under the same id (session change/resubscribe). const pendingToken = beginPendingSubscription(sender.id, subscriptionId) registerSenderCleanup(sender) @@ -154,11 +150,36 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg agent, sessionId, transcriptPath, + initialLimit: limit, + onInitialSnapshot: (messages, hasMore, _beforeOffset, error) => { + if (sender.isDestroyed()) { + return + } + // Forward an initial-drain error so a watching client's first frame carries it + // instead of stranding the view at 'loading' when the read keeps throwing. + const payload: NativeChatAppendedPayload = { + subscriptionId, + frame: { type: 'snapshot', messages, hasMore, ...(error ? { error } : {}) } + } + sender.send('nativeChat:appended', payload) + }, + onReplace: (messages, hasMore) => { + if (sender.isDestroyed()) { + return + } + sender.send('nativeChat:appended', { + subscriptionId, + frame: { type: 'replacement', messages, hasMore } + } satisfies NativeChatAppendedPayload) + }, onAppend: (messages) => { if (sender.isDestroyed()) { return } - const payload: NativeChatAppendedPayload = { subscriptionId, messages } + const payload: NativeChatAppendedPayload = { + subscriptionId, + frame: { type: 'appended', messages } + } sender.send('nativeChat:appended', payload) } }) @@ -182,6 +203,18 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg } bySubId.set(subscriptionId, { subscription }) liveSubscriptions.set(sender.id, bySubId) + if (!subscription.watching && !sender.isDestroyed()) { + const payload: NativeChatAppendedPayload = { + subscriptionId, + frame: { + type: 'snapshot', + messages: [], + hasMore: false, + error: 'Transcript unavailable' + } + } + sender.send('nativeChat:appended', payload) + } } /** Test-only: drop all live and pending transcript subscriptions between runs. */ diff --git a/src/main/ipc/notifications.ts b/src/main/ipc/notifications.ts index 4eb8938f2b4..9d6c825498d 100644 --- a/src/main/ipc/notifications.ts +++ b/src/main/ipc/notifications.ts @@ -436,8 +436,8 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime // hold back the dot. It rides the notification dispatch, so it follows the // renderer's per-source decision to notify: bells always reach here, while // an agent completion is suppressed upstream when its notification is - // disabled. Tray exists only on Windows, so setTrayAttention no-ops - // elsewhere. + // disabled. The status item exists on Windows and macOS, so + // setTrayAttention lights its attention dot there and no-ops on Linux. if (args.source === 'agent-task-complete' || args.source === 'terminal-bell') { const activeWindow = BrowserWindow.getAllWindows().find((win) => !win.isDestroyed()) ?? null if (!isMainWindowVisible(activeWindow)) { diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 989561da4e9..4f08581282b 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -6101,6 +6101,7 @@ describe('registerPtyHandlers', () => { env?: Record }): Promise<{ id: string }> hasRendererSerializer?(ptyId: string): boolean + getRendererSerializerGeneration?(ptyId: string): number } let controller: RuntimeSpawnController | null = null const runtime = { @@ -6128,10 +6129,113 @@ describe('registerPtyHandlers', () => { worktreeId: 'wt-1', env: { ORCA_PANE_KEY: ` ${paneKey} ` } }) + const replacementGen = (await handlers.get('pty:declarePendingPaneSerializer')!(null, { + paneKey + })) as number expect(spawnController.hasRendererSerializer?.(result.id)).toBe(false) await handlers.get('pty:settlePaneSerializer')!(null, { paneKey, gen }) + expect(spawnController.hasRendererSerializer?.(result.id)).toBe(false) + expect(spawnController.getRendererSerializerGeneration?.(result.id)).toBe(0) + await handlers.get('pty:settlePaneSerializer')!(null, { paneKey, gen: replacementGen }) expect(spawnController.hasRendererSerializer?.(result.id)).toBe(true) + expect(spawnController.getRendererSerializerGeneration?.(result.id)).toBe(1) + }) + + it('does not let old teardown cancel serializer settlement for a reused PTY id', async () => { + type RuntimeSpawnController = { + spawn(args: { + cols: number + rows: number + worktreeId?: string + env?: Record + }): Promise<{ id: string }> + getRendererSerializerGeneration?(ptyId: string): number + hasRendererSerializer?(ptyId: string): boolean + waitForRendererSerializer?( + ptyId: string, + afterGeneration: number, + timeoutMs?: number + ): Promise + } + const reusedPtyId = 'pty-reused' + setLocalPtyProvider({ + spawn: vi.fn(async () => ({ id: reusedPtyId })), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + shutdown: vi.fn(), + onData: vi.fn(() => vi.fn()), + onExit: vi.fn(() => vi.fn()), + listProcesses: vi.fn(async () => []), + getForegroundProcess: vi.fn(async () => null) + } as never) + let controller: RuntimeSpawnController | null = null + const runtime = { + setPtyController: vi.fn((value) => { + controller = value + }), + preAllocateHandleForPty: vi.fn(() => null), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + registerPtyHandlers(mainWindow as never, runtime as never) + const paneKey = makePaneKey('tab-reused', '33333333-3333-4333-8333-333333333333') + const spawnController = controller as unknown as RuntimeSpawnController + const spawn = async (): Promise => { + await spawnController.spawn({ + cols: 80, + rows: 24, + worktreeId: 'wt-1', + env: { ORCA_PANE_KEY: paneKey } + }) + } + + const firstGen = (await handlers.get('pty:declarePendingPaneSerializer')!(null, { + paneKey + })) as number + await spawn() + await handlers.get('pty:settlePaneSerializer')!(null, { paneKey, gen: firstGen }) + const priorGeneration = spawnController.getRendererSerializerGeneration?.(reusedPtyId) ?? 0 + + const secondGen = (await handlers.get('pty:declarePendingPaneSerializer')!(null, { + paneKey + })) as number + await spawn() + const ready = spawnController.waitForRendererSerializer?.(reusedPtyId, priorGeneration, 1_000) + clearProviderPtyState(reusedPtyId) + clearProviderPtyState(reusedPtyId) + await handlers.get('pty:settlePaneSerializer')!(null, { paneKey, gen: secondGen }) + + await expect(ready).resolves.toBe(true) + expect(spawnController.hasRendererSerializer?.(reusedPtyId)).toBe(true) + }) + + it('tracks exact remote-runtime serializer readiness without a local spawn mapping', async () => { + type RuntimeSpawnController = { + hasRendererSerializer?(ptyId: string): boolean + getRendererSerializerGeneration?(ptyId: string): number + } + let controller: RuntimeSpawnController | null = null + const runtime = { + setPtyController: vi.fn((value) => { + controller = value + }) + } + + registerPtyHandlers(mainWindow as never, runtime as never) + const ptyId = 'remote:env-1@@terminal-1' + const spawnController = controller as unknown as RuntimeSpawnController + + expect(spawnController.hasRendererSerializer?.(ptyId)).toBe(false) + await handlers.get('pty:reportRendererSerializerReady')!(null, { ptyId: 'local-pty' }) + expect(spawnController.hasRendererSerializer?.('local-pty')).toBe(false) + await handlers.get('pty:reportRendererSerializerReady')!(null, { ptyId }) + expect(spawnController.hasRendererSerializer?.(ptyId)).toBe(true) + expect(spawnController.getRendererSerializerGeneration?.(ptyId)).toBe(1) }) it('clears pending pane serializer declarations when their renderer is destroyed', async () => { diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index e01b8544e8e..d04a84a506e 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -97,6 +97,7 @@ import { } from '../../shared/terminal-input' import { isRemoteAgentHooksEnabled } from '../../shared/agent-hook-relay' import { createTerminalSessionStateSaveFailureMessage } from '../../shared/terminal-session-state-save-failure' +import { RendererTerminalSerializerReadiness } from './renderer-terminal-serializer-readiness' import { readShellStartupEnvVar } from '../pty/shell-startup-env' import { isTerminalLeafId, @@ -259,15 +260,13 @@ type PaneSpawnReservationResult = { // race to spawn the same tab/leaf. Key by stable paneKey so the loser adopts // the winner's PTY instead of creating a duplicate shell. const paneSpawnReservationsByPaneKey = new Map() -// Why: at PTY spawn time we capture the gen that was pending for the spawn's -// paneKey, so teardown can settle ONLY that gen. Without this, a paneKey -// remount that replaces the pending entry with a new gen would still get -// stomped by the old PTY's teardown firing settle on the wrong gen. -const ptyPendingGenByPtyId = new Map() +// Why: bind the declaration generation directly to its spawn result. PTY ids +// are reusable and teardown callbacks carry no incarnation token, so teardown +// must never guess which pending renderer generation it owns. +const pendingPtyIdBySerializerGeneration = new Map() // Why: the runtime's hasRendererSerializer probe needs a ptyId-keyed signal. -// Populated on settlePaneSerializer (renderer has registered for this ptyId) -// and cleared on PTY teardown. -const rendererSerializerByPtyId = new Set() +// A later spawn starts a fresh incarnation; subscription abort owns waiter cleanup. +const rendererSerializerReadiness = new RendererTerminalSerializerReadiness() function parseValidPaneKey(paneKey: unknown): ReturnType { if (typeof paneKey !== 'string' || paneKey.length > 256) { @@ -305,6 +304,7 @@ function cleanupPendingPaneSerializersForSender(ownerWebContentsId: number): voi for (const [paneKey, pending] of pendingByPaneKey) { if (pending.ownerWebContentsId === ownerWebContentsId) { pendingByPaneKey.delete(paneKey) + pendingPtyIdBySerializerGeneration.delete(pending.gen) } } } @@ -320,7 +320,15 @@ function registerPendingPaneSerializerCleanup(sender: WebContents | undefined): function declarePendingPaneSerializer(paneKey: string, sender: WebContents | undefined): number { const gen = ++pendingSerializerGenSeq registerPendingPaneSerializerCleanup(sender) + const replaced = pendingByPaneKey.get(paneKey) + if (replaced) { + pendingPtyIdBySerializerGeneration.delete(replaced.gen) + } pendingByPaneKey.set(paneKey, { gen, ownerWebContentsId: sender?.id ?? null }) + const existingPtyId = paneKeyPtyId.get(paneKey) + if (existingPtyId) { + pendingPtyIdBySerializerGeneration.set(gen, existingPtyId) + } return gen } @@ -372,10 +380,12 @@ function resolvePaneSpawnReservation( return response } -function settlePendingPaneSerializer(paneKey: string, gen: number): void { - if (pendingByPaneKey.get(paneKey)?.gen === gen) { - pendingByPaneKey.delete(paneKey) +function settlePendingPaneSerializer(paneKey: string, gen: number): boolean { + if (pendingByPaneKey.get(paneKey)?.gen !== gen) { + return false } + pendingByPaneKey.delete(paneKey) + return true } export function hasPendingRendererSerializerForPaneKey(paneKey: string): boolean { @@ -1211,7 +1221,6 @@ export function clearProviderPtyState(id: string): void { return !paneKey || (stillOwnsPaneKey && stablePaneKey === paneKey) } }) - rendererSerializerByPtyId.delete(id) // Why: the hook server's per-paneKey caches (lastPrompt / lastTool) would // otherwise accumulate entries for dead panes over the process lifetime. // Use the spawn-time paneKey mapping since the server has no other way to @@ -1222,17 +1231,6 @@ export function clearProviderPtyState(id: string): void { paneKeyPtyId.delete(paneKey) } ptyPaneKey.delete(id) - // Why: drop the pre-signal pending entry only if it still belongs to THIS - // PTY's spawn generation. If a remount for the same paneKey has already - // pre-signaled a new gen, this teardown must NOT touch it — otherwise - // the second mount's hydration loses to the daemon-snapshot seed. See - // the generation-token rationale in - // docs/mobile-prefer-renderer-scrollback.md. - const ownedGen = ptyPendingGenByPtyId.get(id) - if (ownedGen !== undefined) { - settlePendingPaneSerializer(paneKey, ownedGen) - } - ptyPendingGenByPtyId.delete(id) if (stillOwnsPaneKey) { // Why: notify registered consumers AFTER we've dropped the paneKey↔ptyId // entries so a listener that re-reads the map sees the post-teardown @@ -1553,6 +1551,7 @@ export function registerPtyHandlers( ipcMain.removeHandler('pty:declarePendingPaneSerializer') ipcMain.removeHandler('pty:settlePaneSerializer') ipcMain.removeHandler('pty:clearPendingPaneSerializer') + ipcMain.removeHandler('pty:reportRendererSerializerReady') ipcMain.removeHandler('pty:getMainBufferSnapshot') ipcMain.removeHandler('pty:sideEffectSnapshot') ipcMain.removeHandler('pty:getRendererDeliveryDebugSnapshot') @@ -2793,7 +2792,13 @@ export function registerPtyHandlers( // not stack listeners and trip Node's MaxListeners=10 warning. Many // sleeping PTYs waking at once (e.g. on relaunch) routinely fan out 10+ // concurrent calls. - type SerializeResult = { data: string; cols: number; rows: number; lastTitle?: string } | null + type SerializeResult = { + data: string + cols: number + rows: number + seq?: number + lastTitle?: string + } | null const pendingSerializeRequests = new Map< string, { resolve: (result: SerializeResult) => void; timeout: NodeJS.Timeout } @@ -2819,6 +2824,7 @@ export function registerPtyHandlers( data?: unknown cols?: unknown rows?: unknown + seq?: unknown lastTitle?: unknown } | null } @@ -2833,11 +2839,20 @@ export function registerPtyHandlers( typeof snapshot.cols === 'number' && typeof snapshot.rows === 'number' ) { - const result: { data: string; cols: number; rows: number; lastTitle?: string } = { + const result: { + data: string + cols: number + rows: number + seq?: number + lastTitle?: string + } = { data: snapshot.data, cols: snapshot.cols, rows: snapshot.rows } + if (typeof snapshot.seq === 'number' && Number.isFinite(snapshot.seq)) { + result.seq = snapshot.seq + } if (typeof snapshot.lastTitle === 'string' && snapshot.lastTitle.length > 0) { result.lastTitle = snapshot.lastTitle } @@ -3335,6 +3350,15 @@ export function registerPtyHandlers( // so record their spawn-time paneKey here too. Synthetic hook titles and // paneKey-scoped cache cleanup both depend on this reverse lookup. const paneKey = rememberPaneKeyForPty(result.id, env?.ORCA_PANE_KEY) + const pendingSerializer = paneKey ? pendingByPaneKey.get(paneKey) : undefined + const inheritRendererReadiness = + result.isReattach === true && + !pendingSerializer && + rendererSerializerReadiness.has(result.id) + rendererSerializerReadiness.beginIncarnation(result.id, inheritRendererReadiness) + if (paneKey && pendingSerializer) { + pendingPtyIdBySerializerGeneration.set(pendingSerializer.gen, result.id) + } if (!args.connectionId) { registerPty({ ptyId: result.id, @@ -3548,7 +3572,13 @@ export function registerPtyHandlers( // seed (no renderer authoritative for this PTY). A registry write happens // when the renderer calls registerPtySerializer; we check via the same // pendingByPaneKey + ptyId pairing that the cooperation gate uses. - return rendererSerializerByPtyId.has(ptyId) + return rendererSerializerReadiness.has(ptyId) + }, + getRendererSerializerGeneration: (ptyId) => { + return rendererSerializerReadiness.generation(ptyId) + }, + waitForRendererSerializer: (ptyId, afterGeneration, timeoutMs, signal) => { + return rendererSerializerReadiness.wait(ptyId, afterGeneration, timeoutMs, signal) }, getSize: (ptyId) => ptySizes.get(ptyId) ?? null, resize: (ptyId, cols, rows) => { @@ -4317,14 +4347,18 @@ export function registerPtyHandlers( const rendererPreSignaled = validatedPaneKey ? pendingByPaneKey.has(validatedPaneKey) : false - const rendererAlreadyRegistered = rendererSerializerByPtyId.has(result.id) + const rendererAlreadyRegistered = + result.isReattach === true && + !rendererPreSignaled && + rendererSerializerReadiness.has(result.id) + rendererSerializerReadiness.beginIncarnation(result.id, rendererAlreadyRegistered) // Why: capture the pending gen at spawn time so teardown for THIS PTY // only settles its own generation. A remount that replaces the entry // with a new gen must not be stomped by the old PTY's teardown. if (validatedPaneKey && rendererPreSignaled) { const pending = pendingByPaneKey.get(validatedPaneKey) if (pending) { - ptyPendingGenByPtyId.set(result.id, pending.gen) + pendingPtyIdBySerializerGeneration.set(pending.gen, result.id) } } @@ -5272,15 +5306,13 @@ export function registerPtyHandlers( if (!isValidPaneKey(args.paneKey) || typeof args.gen !== 'number') { return } - settlePendingPaneSerializer(args.paneKey, args.gen) - // Why: settle means the renderer has registered its serializer locally - // for whatever ptyId came back from spawn. The renderer doesn't carry - // the ptyId back through this IPC because the cooperation gate ran - // pre-spawn; instead we mark the pane as authoritative by paneKey → - // ptyId via the existing paneKeyPtyId mapping populated at spawn. - const ptyId = paneKeyPtyId.get(args.paneKey) - if (ptyId) { - rendererSerializerByPtyId.add(ptyId) + const ptyId = pendingPtyIdBySerializerGeneration.get(args.gen) + const settledCurrentGeneration = settlePendingPaneSerializer(args.paneKey, args.gen) + // Why: the generation-to-PTY binding survives late teardown of a reused + // id; paneKey reverse maps are provider lifecycle state and may already be gone. + pendingPtyIdBySerializerGeneration.delete(args.gen) + if (settledCurrentGeneration && ptyId) { + rendererSerializerReadiness.markReady(ptyId) } } ) @@ -5292,6 +5324,23 @@ export function registerPtyHandlers( return } settlePendingPaneSerializer(args.paneKey, args.gen) + pendingPtyIdBySerializerGeneration.delete(args.gen) + } + ) + + ipcMain.handle( + 'pty:reportRendererSerializerReady', + async (_event, args: { ptyId?: unknown }): Promise => { + if ( + typeof args.ptyId !== 'string' || + !args.ptyId.startsWith('remote:') || + args.ptyId.length > 512 + ) { + return + } + // Why: remote-runtime panes do not pass through the local spawn + // cooperation gate, so their exact PTY id is the only readiness key. + rendererSerializerReadiness.markReady(args.ptyId) } ) } diff --git a/src/main/ipc/renderer-terminal-serializer-readiness.test.ts b/src/main/ipc/renderer-terminal-serializer-readiness.test.ts new file mode 100644 index 00000000000..821aaa8f8aa --- /dev/null +++ b/src/main/ipc/renderer-terminal-serializer-readiness.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it, vi } from 'vitest' +import { RendererTerminalSerializerReadiness } from './renderer-terminal-serializer-readiness' + +describe('RendererTerminalSerializerReadiness', () => { + it('resolves current and future waiters when the renderer settles', async () => { + const readiness = new RendererTerminalSerializerReadiness() + const pending = readiness.wait('pty-1', 0, 1_000) + + readiness.markReady('pty-1') + + await expect(pending).resolves.toBe(true) + expect(readiness.generation('pty-1')).toBe(1) + await expect(readiness.wait('pty-1', 0, 1_000)).resolves.toBe(true) + }) + + it('waits for a fresh settlement when historical readiness is stale', async () => { + const readiness = new RendererTerminalSerializerReadiness() + readiness.markReady('pty-1') + const pending = readiness.wait('pty-1', readiness.generation('pty-1'), 1_000) + + readiness.markReady('pty-1') + + await expect(pending).resolves.toBe(true) + expect(readiness.generation('pty-1')).toBe(2) + }) + + it('releases waiters when the PTY is cleared', async () => { + const readiness = new RendererTerminalSerializerReadiness() + readiness.markReady('pty-1') + const pending = readiness.wait('pty-1', readiness.generation('pty-1'), 1_000) + + readiness.clear('pty-1') + + await expect(pending).resolves.toBe(false) + expect(readiness.has('pty-1')).toBe(false) + }) + + it('releases waiters on abort and timeout', async () => { + vi.useFakeTimers() + const readiness = new RendererTerminalSerializerReadiness() + const abortController = new AbortController() + const aborted = readiness.wait('pty-abort', 0, 1_000, abortController.signal) + const timedOut = readiness.wait('pty-timeout', 0, 1_000) + + abortController.abort() + await vi.advanceTimersByTimeAsync(1_000) + + await expect(aborted).resolves.toBe(false) + await expect(timedOut).resolves.toBe(false) + vi.useRealTimers() + }) + + it('keeps a lifecycle waiter until readiness or abort when no timeout is supplied', async () => { + vi.useFakeTimers() + const readiness = new RendererTerminalSerializerReadiness() + const abortController = new AbortController() + const pending = readiness.wait('pty-lifecycle', 0, undefined, abortController.signal) + + await vi.advanceTimersByTimeAsync(60_000) + abortController.abort() + + await expect(pending).resolves.toBe(false) + vi.useRealTimers() + }) + + it('ignores late teardown from an older incarnation of a reused PTY id', async () => { + const readiness = new RendererTerminalSerializerReadiness() + const oldIncarnation = readiness.beginIncarnation('pty-reused') + readiness.markReady('pty-reused') + const priorGeneration = readiness.generation('pty-reused') + + readiness.beginIncarnation('pty-reused') + const pending = readiness.wait('pty-reused', priorGeneration, 1_000) + const oldTeardownCleared = readiness.clear('pty-reused', oldIncarnation) + readiness.markReady('pty-reused') + + expect(oldTeardownCleared).toBe(false) + await expect(pending).resolves.toBe(true) + expect(readiness.has('pty-reused')).toBe(true) + }) +}) diff --git a/src/main/ipc/renderer-terminal-serializer-readiness.ts b/src/main/ipc/renderer-terminal-serializer-readiness.ts new file mode 100644 index 00000000000..f58a9b2062f --- /dev/null +++ b/src/main/ipc/renderer-terminal-serializer-readiness.ts @@ -0,0 +1,129 @@ +type RendererTerminalSerializerWaiter = { + afterGeneration: number + finish: (ready: boolean) => void +} + +export class RendererTerminalSerializerReadiness { + private incarnationSequence = 0 + private readonly incarnationByPtyId = new Map() + private readonly readyIncarnationByPtyId = new Map() + private readonly generationByPtyId = new Map() + private readonly waitersByPtyId = new Map>() + + has(ptyId: string): boolean { + const incarnation = this.incarnationByPtyId.get(ptyId) + return incarnation !== undefined && this.readyIncarnationByPtyId.get(ptyId) === incarnation + } + + generation(ptyId: string): number { + return this.generationByPtyId.get(ptyId) ?? 0 + } + + beginIncarnation(ptyId: string, inheritReady = false): number { + const wasReady = this.has(ptyId) + const incarnation = ++this.incarnationSequence + this.incarnationByPtyId.set(ptyId, incarnation) + if (inheritReady && wasReady) { + this.readyIncarnationByPtyId.set(ptyId, incarnation) + } else { + this.readyIncarnationByPtyId.delete(ptyId) + } + return incarnation + } + + markReady(ptyId: string): void { + let incarnation = this.incarnationByPtyId.get(ptyId) + if (incarnation === undefined) { + incarnation = ++this.incarnationSequence + this.incarnationByPtyId.set(ptyId, incarnation) + } + this.readyIncarnationByPtyId.set(ptyId, incarnation) + const generation = this.generation(ptyId) + 1 + this.generationByPtyId.set(ptyId, generation) + const waiters = this.waitersByPtyId.get(ptyId) + if (!waiters) { + return + } + for (const waiter of waiters) { + if (generation > waiter.afterGeneration) { + waiter.finish(true) + } + } + } + + clear(ptyId: string, incarnation?: number): boolean { + if (incarnation !== undefined && this.incarnationByPtyId.get(ptyId) !== incarnation) { + return false + } + this.incarnationByPtyId.delete(ptyId) + this.readyIncarnationByPtyId.delete(ptyId) + this.generationByPtyId.delete(ptyId) + this.finishWaiters(ptyId, false) + return true + } + + private finishWaiters(ptyId: string, ready: boolean): void { + const waiters = this.waitersByPtyId.get(ptyId) + if (!waiters) { + return + } + for (const waiter of waiters) { + waiter.finish(ready) + } + } + + wait( + ptyId: string, + afterGeneration: number, + timeoutMs?: number, + signal?: AbortSignal + ): Promise { + if (this.generation(ptyId) > afterGeneration) { + return Promise.resolve(true) + } + if (signal?.aborted) { + return Promise.resolve(false) + } + + return new Promise((resolve) => { + let timer: ReturnType | null = null + let settled = false + let waiters = this.waitersByPtyId.get(ptyId) + if (!waiters) { + waiters = new Set() + this.waitersByPtyId.set(ptyId, waiters) + } + + const onAbort = (): void => finish(false) + const waiter: RendererTerminalSerializerWaiter = { + afterGeneration, + finish: (ready) => finish(ready) + } + const finish = (ready: boolean): void => { + if (settled) { + return + } + settled = true + if (timer) { + clearTimeout(timer) + timer = null + } + signal?.removeEventListener('abort', onAbort) + waiters.delete(waiter) + if (waiters.size === 0) { + this.waitersByPtyId.delete(ptyId) + } + resolve(ready) + } + + waiters.add(waiter) + signal?.addEventListener('abort', onAbort, { once: true }) + if (timeoutMs !== undefined) { + timer = setTimeout(() => finish(false), timeoutMs) + if (typeof timer.unref === 'function') { + timer.unref() + } + } + }) + } +} diff --git a/src/main/ipc/sender-scoped-request-cancellation.ts b/src/main/ipc/sender-scoped-request-cancellation.ts new file mode 100644 index 00000000000..9700be9c3da --- /dev/null +++ b/src/main/ipc/sender-scoped-request-cancellation.ts @@ -0,0 +1,50 @@ +import type { IpcMainInvokeEvent } from 'electron' + +export type SenderScopedRequestCancellations = { + /** Registers a cancellable request; aborts any previous request that reused the token. */ + begin: (event: IpcMainInvokeEvent, requestToken: string | undefined) => AbortController | null + /** Removes the registration once the request settles (no-op if it was replaced). */ + finish: ( + event: IpcMainInvokeEvent, + requestToken: string | undefined, + controller: AbortController | null + ) => void + /** Best-effort abort from the issuing webContents; a settled request is gone. */ + cancel: (event: IpcMainInvokeEvent, requestToken: string) => void +} + +/** + * Registry for renderer-cancellable IPC requests. Keys are scoped to the + * issuing webContents so one window's token can never cancel another window's + * request, and reusing a token aborts the previous request before the new one + * registers. + */ +export function createSenderScopedRequestCancellations(): SenderScopedRequestCancellations { + const controllers = new Map() + const keyFor = (event: IpcMainInvokeEvent, requestToken: string): string => + `${event.sender.id}\0${requestToken}` + return { + begin: (event, requestToken) => { + if (!requestToken) { + return null + } + const key = keyFor(event, requestToken) + controllers.get(key)?.abort() + const controller = new AbortController() + controllers.set(key, controller) + return controller + }, + finish: (event, requestToken, controller) => { + if (!requestToken || !controller) { + return + } + const key = keyFor(event, requestToken) + if (controllers.get(key) === controller) { + controllers.delete(key) + } + }, + cancel: (event, requestToken) => { + controllers.get(keyFor(event, requestToken))?.abort() + } + } +} diff --git a/src/main/ipc/session.ts b/src/main/ipc/session.ts index 87f2f8b63d4..98015aa64a7 100644 --- a/src/main/ipc/session.ts +++ b/src/main/ipc/session.ts @@ -18,6 +18,12 @@ export function registerSessionHandlers(store: Store): void { store.patchWorkspaceSession(args, hostId) }) + ipcMain.handle('session:flush', () => { + // Why: durable lifecycle RPCs must propagate disk failures instead of + // returning success through Store.flush(), which intentionally only logs. + store.flushOrThrow() + }) + // Synchronous variant for the renderer's beforeunload handler. // sendSync blocks the renderer until this returns, guaranteeing the // data (including terminal scrollback buffers) is persisted to disk diff --git a/src/main/ipc/skills.test.ts b/src/main/ipc/skills.test.ts index 680163f8b5d..bc390b2beb7 100644 --- a/src/main/ipc/skills.test.ts +++ b/src/main/ipc/skills.test.ts @@ -1,13 +1,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { handleMock, discoverSkillsMock, getDefaultWslDistroMock, getWslHomeMock } = vi.hoisted( - () => ({ - handleMock: vi.fn(), - discoverSkillsMock: vi.fn(), - getDefaultWslDistroMock: vi.fn(), - getWslHomeMock: vi.fn() - }) -) +const { + handleMock, + discoverSkillsMock, + inventorySkillFreshnessMock, + getDefaultWslDistroMock, + getWslHomeMock +} = vi.hoisted(() => ({ + handleMock: vi.fn(), + discoverSkillsMock: vi.fn(), + inventorySkillFreshnessMock: vi.fn(), + getDefaultWslDistroMock: vi.fn(), + getWslHomeMock: vi.fn() +})) vi.mock('electron', () => ({ ipcMain: { @@ -19,6 +24,10 @@ vi.mock('../skills/discovery', () => ({ discoverSkills: discoverSkillsMock })) +vi.mock('../skills/skill-freshness-inventory', () => ({ + inventorySkillFreshness: inventorySkillFreshnessMock +})) + vi.mock('../wsl', () => ({ getDefaultWslDistro: getDefaultWslDistroMock, getWslHome: getWslHomeMock @@ -39,6 +48,12 @@ describe('registerSkillsHandlers', () => { getDefaultWslDistroMock.mockReset() getWslHomeMock.mockReset() discoverSkillsMock.mockResolvedValue({ skills: [], sources: [], scannedAt: 1 }) + inventorySkillFreshnessMock.mockResolvedValue({ + schemaVersion: 1, + installations: [], + eligibleUpdateNames: [], + scannedAt: 1 + }) getWslHomeMock.mockReturnValue('\\\\wsl.localhost\\Ubuntu\\home\\alice') Object.defineProperty(process, 'platform', { configurable: true, @@ -61,6 +76,17 @@ describe('registerSkillsHandlers', () => { return call[1] as (_event: unknown, target?: unknown) => Promise } + function getFreshnessHandler() { + registerSkillsHandlers(store as never) + const call = handleMock.mock.calls.find( + (entry: unknown[]) => entry[0] === 'skills:freshnessInventory' + ) + if (!call) { + throw new Error('skills:freshnessInventory handler was not registered') + } + return call[1] as (_event: unknown) => Promise + } + it('uses host skill discovery when resolved project runtime overrides stale WSL target state', async () => { const handler = getDiscoverHandler() @@ -136,4 +162,13 @@ describe('registerSkillsHandlers', () => { ).rejects.toThrow('Project runtime requires repair before skill discovery') expect(discoverSkillsMock).not.toHaveBeenCalled() }) + + it('keeps freshness inventory local and read-only over known repositories', async () => { + const handler = getFreshnessHandler() + + await handler(null) + + expect(inventorySkillFreshnessMock).toHaveBeenCalledWith({ repos }) + expect(getWslHomeMock).not.toHaveBeenCalled() + }) }) diff --git a/src/main/ipc/skills.ts b/src/main/ipc/skills.ts index b81fd695790..d210123a744 100644 --- a/src/main/ipc/skills.ts +++ b/src/main/ipc/skills.ts @@ -2,7 +2,9 @@ import { ipcMain } from 'electron' import type { Store } from '../persistence' import { discoverSkills } from '../skills/discovery' import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../../shared/skills' +import type { SkillFreshnessInventory } from '../../shared/skill-freshness' import { getDefaultWslDistro, getWslHome } from '../wsl' +import { inventorySkillFreshness } from '../skills/skill-freshness-inventory' type SkillDiscoveryRuntimeTarget = | { runtime: 'host' } @@ -55,4 +57,10 @@ export function registerSkillsHandlers(store: Store): void { return cwd ? discoverSkills({ repos: [], cwd }) : discoverSkills({ repos: store.getRepos() }) } ) + + ipcMain.handle('skills:freshnessInventory', async (): Promise => { + // Why: the update command targets this machine's global homes. WSL and SSH + // inventories stay out until their installer rail has an equivalent proof. + return inventorySkillFreshness({ repos: store.getRepos() }) + }) } diff --git a/src/main/jira/client.test.ts b/src/main/jira/client.test.ts index 41cb70c5beb..0c0ee11511c 100644 --- a/src/main/jira/client.test.ts +++ b/src/main/jira/client.test.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import type * as Os from 'node:os' import { join } from 'node:path' @@ -402,6 +402,227 @@ describe('Jira client credential storage', () => { expect(jira.isAuthError(new jira.JiraApiError('Forbidden', 403))).toBe(false) }) + it('connects to self-hosted Jira with a Bearer PAT against REST v2', async () => { + netFetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + name: 'wquintal', + key: 'JIRAUSER10101', + displayName: 'William', + emailAddress: 'william@example.com' + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ) + const jira = await loadClientModule() + + await expect( + jira.connect({ + siteUrl: 'jira.example.com', + email: '', + apiToken: 'pat-token', + authType: 'server' + }) + ).resolves.toMatchObject({ + ok: true, + // Server /myself has no accountId; the username stands in for it. + viewer: { displayName: 'William', accountId: 'wquintal' } + }) + + expect(netFetchMock).toHaveBeenCalledWith( + 'https://jira.example.com/rest/api/2/myself', + expect.objectContaining({ headers: expect.any(Headers) }) + ) + const headers = netFetchMock.mock.calls[0]?.[1]?.headers as Headers + expect(headers.get('Authorization')).toBe('Bearer pat-token') + }) + + it('connects to self-hosted Jira with Basic username/password against REST v2', async () => { + netFetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ name: 'jdoe', key: 'JIRAUSER20202', displayName: 'Jane Doe' }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ) + const jira = await loadClientModule() + + await expect( + jira.connect({ + siteUrl: 'jira.example.com', + // A username present means classic Basic auth (older Server/DC that + // predate PATs); the token slot carries the account password. + email: 'jdoe', + apiToken: 'account-password', + authType: 'server' + }) + ).resolves.toMatchObject({ + ok: true, + viewer: { displayName: 'Jane Doe', accountId: 'jdoe' } + }) + + expect(netFetchMock).toHaveBeenCalledWith( + 'https://jira.example.com/rest/api/2/myself', + expect.objectContaining({ headers: expect.any(Headers) }) + ) + const headers = netFetchMock.mock.calls[0]?.[1]?.headers as Headers + expect(headers.get('Authorization')).toBe( + `Basic ${Buffer.from('jdoe:account-password').toString('base64')}` + ) + }) + + it('uses Basic auth for stored self-hosted sites that carry a username', async () => { + const siteId = 'site-server-basic' + const orcaDir = join(tempHome, '.orca') + mkdirSync(join(orcaDir, 'jira-tokens'), { recursive: true }) + writeFileSync( + join(orcaDir, 'jira-sites.json'), + JSON.stringify({ + version: 1, + activeSiteId: siteId, + selectedSiteId: siteId, + sites: [ + { + id: siteId, + siteUrl: 'https://jira.example.com', + email: 'jdoe', + displayName: 'Jane Doe', + accountId: 'jdoe', + authType: 'server' + } + ] + }), + { encoding: 'utf-8' } + ) + writeFileSync(tokenPathForSite(siteId), 'account-password') + netFetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ name: 'jdoe', displayName: 'Jane Doe' }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + ) + const jira = await loadClientModule() + + await expect(jira.testConnection(siteId)).resolves.toMatchObject({ ok: true }) + + const headers = netFetchMock.mock.calls[0]?.[1]?.headers as Headers + expect(headers.get('Authorization')).toBe( + `Basic ${Buffer.from('jdoe:account-password').toString('base64')}` + ) + }) + + it('requires a token but not an email for self-hosted connections', async () => { + const jira = await loadClientModule() + + await expect( + jira.connect({ + siteUrl: 'jira.example.com', + email: '', + apiToken: '', + authType: 'server' + }) + ).resolves.toEqual({ ok: false, error: 'Personal access token is required.' }) + expect(netFetchMock).not.toHaveBeenCalled() + }) + + it('requires a password when a username is present on self-hosted', async () => { + const jira = await loadClientModule() + + await expect( + jira.connect({ + siteUrl: 'jira.example.com', + email: 'jdoe', + apiToken: '', + authType: 'server' + }) + ).resolves.toEqual({ ok: false, error: 'Password is required.' }) + expect(netFetchMock).not.toHaveBeenCalled() + }) + + it('keeps distinct self-hosted PAT accounts on one host as separate sites', async () => { + netFetchMock + .mockResolvedValueOnce( + new Response(JSON.stringify({ name: 'alice', displayName: 'Alice' }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ name: 'bot', displayName: 'Bot' }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + ) + const jira = await loadClientModule() + + await jira.connect({ + siteUrl: 'jira.example.com', + email: '', + apiToken: 'alice-pat', + authType: 'server' + }) + await jira.connect({ + siteUrl: 'jira.example.com', + email: '', + apiToken: 'bot-pat', + authType: 'server' + }) + + // Two PATs (both with empty email) to the same host must not collide onto + // one id and silently overwrite each other — the viewer identity keys them. + const stored = JSON.parse( + readFileSync(join(tempHome, '.orca', 'jira-sites.json'), 'utf-8') + ) as { + sites: { accountId: string }[] + } + expect(stored.sites).toHaveLength(2) + expect(stored.sites.map((site) => site.accountId).sort()).toEqual(['alice', 'bot']) + }) + + it('uses Bearer auth and REST v2 for stored self-hosted sites', async () => { + const siteId = 'site-server' + const orcaDir = join(tempHome, '.orca') + mkdirSync(join(orcaDir, 'jira-tokens'), { recursive: true }) + writeFileSync( + join(orcaDir, 'jira-sites.json'), + JSON.stringify({ + version: 1, + activeSiteId: siteId, + selectedSiteId: siteId, + sites: [ + { + id: siteId, + siteUrl: 'https://jira.example.com', + email: '', + displayName: 'William', + accountId: 'wquintal', + authType: 'server' + } + ] + }), + { encoding: 'utf-8' } + ) + writeFileSync(tokenPathForSite(siteId), 'pat-token') + netFetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ name: 'wquintal', displayName: 'William' }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + ) + const jira = await loadClientModule() + + await expect(jira.testConnection(siteId)).resolves.toMatchObject({ + ok: true, + viewer: { displayName: 'William' } + }) + + expect(netFetchMock).toHaveBeenCalledWith( + 'https://jira.example.com/rest/api/2/myself', + expect.objectContaining({ headers: expect.any(Headers) }) + ) + const headers = netFetchMock.mock.calls[0]?.[1]?.headers as Headers + expect(headers.get('Authorization')).toBe('Bearer pat-token') + }) + it('bridges proxy environment settings before Jira connect requests', async () => { netFetchMock.mockResolvedValueOnce( new Response( diff --git a/src/main/jira/client.ts b/src/main/jira/client.ts index cce8ee85b67..4c182ee2ad5 100644 --- a/src/main/jira/client.ts +++ b/src/main/jira/client.ts @@ -14,6 +14,7 @@ import { import { ensureElectronProxyFromEnvironment } from '../network/proxy-settings' import { withSpan } from '../observability/tracer' import type { + JiraAuthType, JiraConnectArgs, JiraConnectionStatus, JiraSite, @@ -65,6 +66,13 @@ export type JiraClientForSite = { authorization: string } +// Self-hosted Jira Server/Data Center only exposes REST v2; Cloud endpoints +// in this codebase are written against v3. Callers build paths with this +// prefix so one code path serves both deployments. +export function apiBasePath(site: JiraSite): string { + return site.authType === 'server' ? '/rest/api/2' : '/rest/api/3' +} + export class JiraApiError extends Error { status: number | null @@ -143,7 +151,9 @@ function normalizeSite(input: unknown): JiraSite | null { siteUrl: record.siteUrl, email: record.email, displayName: record.displayName, - accountId: record.accountId + accountId: record.accountId, + // Sites saved before self-hosted support have no authType; they are Cloud. + authType: record.authType === 'server' ? 'server' : 'cloud' } } @@ -284,8 +294,17 @@ function getSiteId(siteUrl: string, email: string): string { function toViewer(data: Record, fallbackEmail: string): JiraViewer { const avatarUrls = data.avatarUrls as Record | undefined + // Server/DC /myself has no accountId; its stable identifiers are name/key. + const accountId = + typeof data.accountId === 'string' + ? data.accountId + : typeof data.name === 'string' + ? data.name + : typeof data.key === 'string' + ? data.key + : '' return { - accountId: typeof data.accountId === 'string' ? data.accountId : '', + accountId, displayName: typeof data.displayName === 'string' ? data.displayName : fallbackEmail, email: typeof data.emailAddress === 'string' ? data.emailAddress : fallbackEmail, avatarUrl: @@ -308,7 +327,14 @@ function siteToViewer(site: JiraSite | null): JiraViewer | null { } } -function authHeader(email: string, apiToken: string): string { +function authHeader(email: string, apiToken: string, authType?: JiraAuthType): string { + // Self-hosted with no username = a personal access token (Bearer); Basic auth + // with a PAT in the password slot is what produces the 401s users report. + // Self-hosted WITH a username is classic username+password Basic auth, which + // older Server/DC instances (predating PATs) require. Cloud is always Basic. + if (authType === 'server' && !email) { + return `Bearer ${apiToken}` + } return `Basic ${Buffer.from(`${email}:${apiToken}`).toString('base64')}` } @@ -366,13 +392,14 @@ async function requestWithCredentials( email: string, apiToken: string, path: string, - init?: RequestInit + init?: RequestInit, + authType?: JiraAuthType ): Promise { const headers = new Headers(init?.headers) headers.set('Accept', 'application/json') headers.set('Content-Type', 'application/json') headers.set('User-Agent', JIRA_API_USER_AGENT) - headers.set('Authorization', authHeader(email, apiToken)) + headers.set('Authorization', authHeader(email, apiToken, authType)) const response = await jiraFetch(`${siteUrl}${path}`, { ...init, headers @@ -453,7 +480,7 @@ export function getClients(selection?: JiraSiteSelection | null): JiraClientForS } throw error } - return token ? [{ site, authorization: authHeader(site.email, token) }] : [] + return token ? [{ site, authorization: authHeader(site.email, token, site.authType) }] : [] }) } @@ -484,28 +511,48 @@ export async function connect( return { ok: false, error: 'Enter a valid Jira site URL.' } } + const authType: JiraAuthType = args.authType === 'server' ? 'server' : 'cloud' const email = args.email.trim() const apiToken = args.apiToken.trim() - if (!email || !apiToken) { + if (authType === 'server') { + if (!apiToken) { + // A username present means classic Basic auth (password); its absence + // means the credential is a personal access token sent as Bearer. + return { + ok: false, + error: email ? 'Password is required.' : 'Personal access token is required.' + } + } + } else if (!email || !apiToken) { return { ok: false, error: 'Email and API token are required.' } } await acquire() try { + const myselfPath = authType === 'server' ? '/rest/api/2/myself' : '/rest/api/3/myself' const viewer = toViewer( - (await requestWithCredentials(siteUrl, email, apiToken, '/rest/api/3/myself')) as Record< - string, - unknown - >, - email + (await requestWithCredentials( + siteUrl, + email, + apiToken, + myselfPath, + undefined, + authType + )) as Record, + email || siteUrl ) - const id = getSiteId(siteUrl, email) + // PAT sites have no email, so keying on it alone would collide every PAT + // connection to the same host into one id (silently overwriting a prior + // account + token). Fall back to the verified viewer identity so distinct + // accounts stay distinct. Cloud/Basic keep keying on their non-empty email. + const id = getSiteId(siteUrl, email || viewer.accountId) const site: JiraSite = { id, siteUrl, email, displayName: viewer.displayName, - accountId: viewer.accountId + accountId: viewer.accountId, + authType } saveToken(id, apiToken) const file = getSiteFile() @@ -565,7 +612,7 @@ export async function testConnection( await acquire() try { const viewer = toViewer( - (await jiraRequest(client, '/rest/api/3/myself')) as Record, + (await jiraRequest(client, `${apiBasePath(client.site)}/myself`)) as Record, client.site.email ) return { ok: true, viewer } diff --git a/src/main/jira/issues.test.ts b/src/main/jira/issues.test.ts index 1cf9ab31212..4b20b0c4a2f 100644 --- a/src/main/jira/issues.test.ts +++ b/src/main/jira/issues.test.ts @@ -12,6 +12,8 @@ const { clearTokenMock, getClientsMock, isAuthErrorMock, jiraRequestMock } = vi. vi.mock('./client', () => ({ acquire: vi.fn().mockResolvedValue(undefined), release: vi.fn(), + apiBasePath: (site: { authType?: string }) => + site.authType === 'server' ? '/rest/api/2' : '/rest/api/3', clearToken: (...args: unknown[]) => clearTokenMock(...args), getClients: (...args: unknown[]) => getClientsMock(...args), isAuthError: (...args: unknown[]) => isAuthErrorMock(...args), @@ -31,6 +33,20 @@ function makeEntry(id = 'site-1'): JiraClientForSite { } } +function makeServerEntry(id = 'server-1'): JiraClientForSite { + return { + site: { + id, + siteUrl: 'https://jira.example.com', + email: '', + displayName: 'Self-hosted Jira', + accountId: 'wquintal', + authType: 'server' + }, + authorization: 'Bearer pat-token' + } +} + describe('Jira issue operations', () => { beforeEach(() => { vi.clearAllMocks() @@ -60,6 +76,67 @@ describe('Jira issue operations', () => { ).rejects.toThrow(error.message) }) + it('uses classic /search and REST v2 for self-hosted sites', async () => { + getClientsMock.mockReturnValue([makeServerEntry()]) + jiraRequestMock.mockResolvedValueOnce({ issues: [] }) + const { searchIssues } = await import('./issues') + + await searchIssues('project = ALP', 20, 'server-1') + + expect(jiraRequestMock).toHaveBeenCalledWith( + expect.objectContaining({ site: expect.objectContaining({ authType: 'server' }) }), + '/rest/api/2/search', + expect.objectContaining({ method: 'POST' }) + ) + }) + + it('sends plain-text bodies and v2 paths for self-hosted issue creation', async () => { + getClientsMock.mockReturnValue([makeServerEntry()]) + jiraRequestMock.mockResolvedValueOnce({ id: '1', key: 'ALP-1', self: '' }) + const { createIssue } = await import('./issues') + + await createIssue({ + siteId: 'server-1', + projectId: '10000', + issueTypeId: '10001', + title: 'Fix auth', + description: 'Body text' + }) + + const [, path, init] = jiraRequestMock.mock.calls[0] + expect(path).toBe('/rest/api/2/issue') + const body = JSON.parse((init as { body: string }).body) as { + fields: { description: unknown } + } + // REST v2 rejects ADF documents; the description must stay a plain string. + expect(body.fields.description).toBe('Body text') + }) + + it('assigns by username on self-hosted sites', async () => { + getClientsMock.mockReturnValue([makeServerEntry()]) + jiraRequestMock.mockResolvedValue(null) + const { updateIssue } = await import('./issues') + + await updateIssue('ALP-1', { assigneeAccountId: 'wquintal' }, 'server-1') + + expect(jiraRequestMock).toHaveBeenCalledWith( + expect.anything(), + '/rest/api/2/issue/ALP-1/assignee', + expect.objectContaining({ body: JSON.stringify({ name: 'wquintal' }) }) + ) + }) + + it('lists self-hosted projects from the unpaged /project resource', async () => { + getClientsMock.mockReturnValue([makeServerEntry()]) + jiraRequestMock.mockResolvedValueOnce([{ id: '1', key: 'ALP', name: 'Alpha' }]) + const { listProjects } = await import('./issues') + + const projects = await listProjects('server-1') + + expect(jiraRequestMock).toHaveBeenCalledWith(expect.anything(), '/rest/api/2/project') + expect(projects).toMatchObject([{ key: 'ALP', name: 'Alpha' }]) + }) + it('rejects single-site search failures so the UI can surface them', async () => { getClientsMock.mockReturnValue([makeEntry('site-1')]) jiraRequestMock.mockRejectedValueOnce(new Error('Forbidden')) diff --git a/src/main/jira/issues.ts b/src/main/jira/issues.ts index 5a19b9897b3..2ce24a87420 100644 --- a/src/main/jira/issues.ts +++ b/src/main/jira/issues.ts @@ -23,6 +23,7 @@ import type { } from '../../shared/types' import { acquire, + apiBasePath, clearToken, getClients, isAuthError, @@ -191,7 +192,8 @@ function avatarUrl(value: unknown): string | undefined { function mapUser(value: unknown): JiraUser | undefined { const user = asRecord(value) - const accountId = asString(user.accountId) + // Server/DC users have no accountId; name (login) and key are its stable ids. + const accountId = asString(user.accountId) || asString(user.name) || asString(user.key) if (!accountId) { return undefined } @@ -307,6 +309,11 @@ function issueUrl(site: JiraSite, key: string): string { return `${site.siteUrl}/browse/${encodeURIComponent(key)}` } +// REST v2 (Server/DC) bodies are plain text; v3 (Cloud) requires ADF documents. +function toBodyText(site: JiraSite, text: string): unknown { + return site.authType === 'server' ? text : textToAdf(text) +} + export function mapJiraIssue(site: JiraSite, raw: JiraRecord): JiraIssue { const fields = asRecord(raw.fields) const key = asString(raw.key) @@ -354,7 +361,12 @@ async function searchIssuesForClient( jql: string, limit: number ): Promise { - const result = await jiraRequest(entry, '/rest/api/3/search/jql', { + // Server/DC only has the classic /search resource; /search/jql is Cloud-only. + const searchPath = + entry.site.authType === 'server' + ? `${apiBasePath(entry.site)}/search` + : '/rest/api/3/search/jql' + const result = await jiraRequest(entry, searchPath, { method: 'POST', body: JSON.stringify({ jql, @@ -429,7 +441,7 @@ export async function getIssue( try { const issue = await jiraRequest( entry, - `/rest/api/3/issue/${encodeURIComponent(key)}?fields=${encodeURIComponent( + `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}?fields=${encodeURIComponent( ISSUE_FIELDS.join(',') )}` ) @@ -468,7 +480,7 @@ export async function createIssue(args: JiraCreateIssueArgs): Promise( entry, - '/rest/api/3/issue', + `${apiBasePath(entry.site)}/issue`, { method: 'POST', body: JSON.stringify({ fields }) @@ -517,20 +529,27 @@ export async function updateIssue( if (updates.priorityId !== undefined) { fields.priority = updates.priorityId ? { id: updates.priorityId } : null } + const issueBase = `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}` if (Object.keys(fields).length > 0) { - await jiraRequest(entry, `/rest/api/3/issue/${encodeURIComponent(key)}`, { + await jiraRequest(entry, issueBase, { method: 'PUT', body: JSON.stringify({ fields }) }) } if (updates.assigneeAccountId !== undefined) { - await jiraRequest(entry, `/rest/api/3/issue/${encodeURIComponent(key)}/assignee`, { + // Server/DC identifies assignees by username (`name`), not accountId; + // mapUser stores the Server username in the accountId slot. + const assigneeBody = + entry.site.authType === 'server' + ? { name: updates.assigneeAccountId } + : { accountId: updates.assigneeAccountId } + await jiraRequest(entry, `${issueBase}/assignee`, { method: 'PUT', - body: JSON.stringify({ accountId: updates.assigneeAccountId }) + body: JSON.stringify(assigneeBody) }) } if (updates.transitionId) { - await jiraRequest(entry, `/rest/api/3/issue/${encodeURIComponent(key)}/transitions`, { + await jiraRequest(entry, `${issueBase}/transitions`, { method: 'POST', body: JSON.stringify({ transition: { id: updates.transitionId } }) }) @@ -560,10 +579,10 @@ export async function addIssueComment( try { const comment = await jiraRequest<{ id: string }>( entry, - `/rest/api/3/issue/${encodeURIComponent(key)}/comment`, + `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}/comment`, { method: 'POST', - body: JSON.stringify({ body: textToAdf(body) }) + body: JSON.stringify({ body: toBodyText(entry.site, body) }) } ) return { ok: true, id: comment.id } @@ -604,7 +623,7 @@ export async function getIssueComments( orderBy: 'created', startAt: String(startAt) }) - return `/rest/api/3/issue/${encodeURIComponent(key)}/comment?${params.toString()}` + return `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}/comment?${params.toString()}` }) return comments.map(mapComment) } catch (error) { @@ -628,13 +647,18 @@ export async function listProjects(siteId?: JiraSiteSelection | null): Promise { await acquire() try { - const projects = await fetchPagedRecords(entry, 'values', (startAt, maxResults) => { - const params = new URLSearchParams({ - maxResults: String(maxResults), - startAt: String(startAt) - }) - return `/rest/api/3/project/search?${params.toString()}` - }) + // Server/DC has no /project/search resource; /project returns the + // full list as a plain (unpaged) array. + const projects = + entry.site.authType === 'server' + ? await jiraRequest(entry, `${apiBasePath(entry.site)}/project`) + : await fetchPagedRecords(entry, 'values', (startAt, maxResults) => { + const params = new URLSearchParams({ + maxResults: String(maxResults), + startAt: String(startAt) + }) + return `/rest/api/3/project/search?${params.toString()}` + }) return projects.map((project) => mapProject(project, entry.site)) } catch (error) { if (isAuthError(error)) { @@ -669,7 +693,8 @@ export async function listIssueTypes( maxResults: String(maxResults), startAt: String(startAt) }) - return `/rest/api/3/issue/createmeta/${encodeURIComponent( + // Per-project createmeta paths exist on Server/DC from Jira 8.4 onward. + return `${apiBasePath(entry.site)}/issue/createmeta/${encodeURIComponent( projectIdOrKey )}/issuetypes?${params.toString()}` }) @@ -707,7 +732,7 @@ export async function listCreateFields( }) const response = await jiraRequest>( entry, - `/rest/api/3/issue/createmeta/${encodeURIComponent( + `${apiBasePath(entry.site)}/issue/createmeta/${encodeURIComponent( projectIdOrKey )}/issuetypes/${encodeURIComponent(issueTypeId)}?${params.toString()}` ) @@ -742,7 +767,7 @@ export async function listPriorities(siteId?: string | null): Promise(entry, '/rest/api/3/priority') + const response = await jiraRequest(entry, `${apiBasePath(entry.site)}/priority`) return response.map(mapPriority).filter((priority): priority is JiraPriority => !!priority) } catch (error) { if (isAuthError(error)) { @@ -765,15 +790,17 @@ export async function listAssignableUsers( if (!entry) { return [] } + const isServer = entry.site.authType === 'server' const params = new URLSearchParams({ issueKey: key, maxResults: '50' }) if (query?.trim()) { - params.set('query', query.trim()) + // Server/DC filters assignable users by `username`; `query` is Cloud-only. + params.set(isServer ? 'username' : 'query', query.trim()) } await acquire() try { const response = await jiraRequest( entry, - `/rest/api/3/user/assignable/search?${params.toString()}` + `${apiBasePath(entry.site)}/user/assignable/search?${params.toString()}` ) return response.map(mapUser).filter((user): user is JiraUser => !!user) } catch (error) { @@ -800,7 +827,7 @@ export async function listTransitions( try { const response = await jiraRequest<{ transitions?: JiraRecord[] }>( entry, - `/rest/api/3/issue/${encodeURIComponent(key)}/transitions` + `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}/transitions` ) return (response.transitions ?? []).map((transition) => ({ id: asString(transition.id), diff --git a/src/main/linear/issues.test.ts b/src/main/linear/issues.test.ts index ef9ca38678e..2b7469caf7b 100644 --- a/src/main/linear/issues.test.ts +++ b/src/main/linear/issues.test.ts @@ -34,11 +34,12 @@ function makeEntry(options?: { } as unknown as LinearClientForWorkspace } -function rawIssue(id: string, updatedAt = '2026-01-01T00:00:00.000Z') { +function rawIssue(id: string, updatedAt = '2026-01-01T00:00:00.000Z', branchName?: string | null) { return { id, identifier: id, title: id, + branchName, description: 'Description', url: `https://linear.app/${id}`, estimate: 3, @@ -120,6 +121,17 @@ describe('Linear issue queries', () => { expect(rawRequest.mock.calls[0][0]).toContain('estimate') }) + it('maps Linear branch names from raw issue reads', async () => { + rawRequest.mockResolvedValueOnce({ + data: { issues: { nodes: [rawIssue('LIN-1', undefined, 'team/lin-1-fix')] } } + }) + const { listIssues } = await import('./issues') + + await expect(listIssues('all', 10, 'workspace-1')).resolves.toMatchObject({ + items: [{ branchName: 'team/lin-1-fix' }] + }) + }) + it('fetches issue comments with one request (no per-comment user N+1)', async () => { rawRequest.mockResolvedValueOnce({ data: { diff --git a/src/main/linear/issues.ts b/src/main/linear/issues.ts index f71ab7e35e1..e4509c9e6a6 100644 --- a/src/main/linear/issues.ts +++ b/src/main/linear/issues.ts @@ -38,6 +38,7 @@ type LinearIssueNode = { id: string identifier: string title: string + branchName?: string | null description?: string | null url: string dueDate?: string | null @@ -143,6 +144,7 @@ const LINEAR_ISSUE_NODE_FIELDS = ` id identifier title + branchName description url dueDate @@ -399,6 +401,7 @@ function mapRawIssueForWorkspace( id: issue.id, identifier: issue.identifier, title: issue.title, + branchName: issue.branchName ?? undefined, description: issue.description ?? undefined, url: issue.url, state: { diff --git a/src/main/linear/mappers.test.ts b/src/main/linear/mappers.test.ts index 4c5a25c635d..0fba648efc8 100644 --- a/src/main/linear/mappers.test.ts +++ b/src/main/linear/mappers.test.ts @@ -7,6 +7,7 @@ describe('mapLinearIssue', () => { id: 'issue-1', identifier: 'LIN-1', title: 'Investigate mobile detail', + branchName: 'team/lin-1-investigate-mobile-detail', description: 'Body', url: 'https://linear.app/acme/issue/LIN-1', estimate: 2, @@ -39,6 +40,7 @@ describe('mapLinearIssue', () => { title: 'Investigate mobile detail', labels: ['Bug'], subIssues: [{ id: 'child-1', identifier: 'LIN-2' }], + branchName: 'team/lin-1-investigate-mobile-detail', state: { name: '' }, team: { id: '' }, assignee: undefined, diff --git a/src/main/linear/mappers.ts b/src/main/linear/mappers.ts index b49e75cba07..7ea55be7fca 100644 --- a/src/main/linear/mappers.ts +++ b/src/main/linear/mappers.ts @@ -75,6 +75,10 @@ export async function mapLinearIssue( id: issue.id, identifier: issue.identifier, title: issue.title, + branchName: + 'branchName' in issue + ? ((issue.branchName as string | null | undefined) ?? undefined) + : undefined, description: issue.description ?? undefined, url: issue.url, state: { diff --git a/src/main/native-chat/session-file-resolver.test.ts b/src/main/native-chat/session-file-resolver.test.ts index 5dd3cbc8dab..85cd653185b 100644 --- a/src/main/native-chat/session-file-resolver.test.ts +++ b/src/main/native-chat/session-file-resolver.test.ts @@ -39,6 +39,19 @@ describe('resolveSessionFilePath', () => { expect(resolved).toBe(target) }) + it('resolves OpenClaude sessions from the Claude transcript layout', async () => { + const root = await makeRoot('orca-native-chat-resolve-openclaude-') + const claudeProjectsDir = join(root, 'claude-projects') + const projectDir = join(claudeProjectsDir, '-Users-ada-repo') + await mkdir(projectDir, { recursive: true }) + const target = join(projectDir, 'sess-openclaude.jsonl') + await writeFile(target, '{}\n') + + await expect( + resolveSessionFilePath('openclaude', 'sess-openclaude', { claudeProjectsDir }) + ).resolves.toBe(target) + }) + it('resolves Grok chat_history.jsonl under encodeURIComponent(cwd)/sessionId', async () => { const root = await makeRoot('orca-native-chat-resolve-grok-') const grokSessionsDir = join(root, 'grok-sessions') diff --git a/src/main/native-chat/session-file-resolver.ts b/src/main/native-chat/session-file-resolver.ts index adfa3856f0e..33cf8c23823 100644 --- a/src/main/native-chat/session-file-resolver.ts +++ b/src/main/native-chat/session-file-resolver.ts @@ -2,6 +2,7 @@ import { existsSync } from 'node:fs' import { homedir } from 'node:os' import { basename, extname, join } from 'node:path' import type { AgentType } from '../../shared/native-chat-types' +import { resolveNativeChatTranscriptAgent } from '../../shared/native-chat-agent-support' import { walkSessionFiles } from '../ai-vault/session-scanner-discovery' import { getOrcaManagedCodexHomePath } from '../codex/codex-home-paths' import { @@ -66,6 +67,10 @@ export async function resolveSessionFilePath( sessionId: string, options: ResolveSessionFileOptions = {} ): Promise { + const transcriptAgent = resolveNativeChatTranscriptAgent(agent) + if (!transcriptAgent) { + return null + } // Why: the hook's transcript_path is the exact file the agent is writing, so it // beats reconstructing a path from the session id. Guard with existsSync so a // stale/remote path falls through to the id-based search rather than returning @@ -80,13 +85,13 @@ export async function resolveSessionFilePath( return null } - if (agent === 'claude') { + if (transcriptAgent === 'claude') { return resolveClaudeSessionFile(trimmedId, options.claudeProjectsDir ?? claudeProjectsDir()) } - if (agent === 'codex') { + if (transcriptAgent === 'codex') { return resolveCodexSessionFile(trimmedId, options.codexSessionsDirs ?? codexSessionsDirs()) } - if (agent === 'grok') { + if (transcriptAgent === 'grok') { return resolveGrokSessionFile(trimmedId, options.grokSessionsDir ?? grokSessionsDir()) } return null diff --git a/src/main/native-chat/transcript-incremental-reader.ts b/src/main/native-chat/transcript-incremental-reader.ts new file mode 100644 index 00000000000..ffb3ad83cae --- /dev/null +++ b/src/main/native-chat/transcript-incremental-reader.ts @@ -0,0 +1,103 @@ +import { open, stat } from 'node:fs/promises' +import type { NativeChatMessage } from '../../shared/native-chat-types' +import { transcriptFallbackId } from './transcript-fallback-id' +import { + MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES, + type NativeChatLineDecoder +} from './transcript-tail-reader' + +const APPEND_BATCH_MESSAGE_LIMIT = 40 + +export type IncrementalTranscriptState = { + offset: number + pendingChunks: Buffer[] + pendingStart: number + pendingBytes: number + droppingOversizedRecord: boolean +} + +export function resetIncrementalTranscriptState(state: IncrementalTranscriptState): void { + state.offset = 0 + state.pendingChunks.length = 0 + state.pendingStart = 0 + state.pendingBytes = 0 + state.droppingOversizedRecord = false +} + +export async function readIncrementalTranscriptMessages( + filePath: string, + state: IncrementalTranscriptState, + decode: NativeChatLineDecoder, + onBatch?: (messages: NativeChatMessage[]) => void +): Promise { + const end = (await stat(filePath)).size + if (end <= state.offset) { + return [] + } + const messages: NativeChatMessage[] = [] + const handle = await open(filePath, 'r') + try { + const stream = handle.createReadStream({ start: state.offset, end: end - 1, autoClose: false }) + let absoluteOffset = state.offset + for await (const rawChunk of stream) { + const chunk = Buffer.isBuffer(rawChunk) ? rawChunk : Buffer.from(rawChunk) + let segmentStart = 0 + let newline = chunk.indexOf(0x0a) + while (newline >= 0) { + retainPart(chunk.subarray(segmentStart, newline)) + if (!state.droppingOversizedRecord) { + decodeLine() + } + resetPendingLine(absoluteOffset + newline + 1) + segmentStart = newline + 1 + newline = chunk.indexOf(0x0a, segmentStart) + } + if (segmentStart < chunk.length) { + retainPart(chunk.subarray(segmentStart)) + } + absoluteOffset += chunk.length + state.offset = absoluteOffset + } + return messages + } finally { + await handle.close() + } + + function retainPart(part: Buffer): void { + if (state.droppingOversizedRecord) { + return + } + state.pendingBytes += part.length + if (state.pendingBytes > MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES) { + state.pendingChunks.length = 0 + state.droppingOversizedRecord = true + return + } + state.pendingChunks.push(part) + } + + function resetPendingLine(nextStart: number): void { + state.pendingChunks.length = 0 + state.pendingBytes = 0 + state.droppingOversizedRecord = false + state.pendingStart = nextStart + } + + function decodeLine(): void { + let line = Buffer.concat(state.pendingChunks).toString('utf8') + if (line.endsWith('\r')) { + line = line.slice(0, -1) + } + if (!line) { + return + } + const message = decode(line, transcriptFallbackId(filePath, state.pendingStart)) + if (!message) { + return + } + messages.push(message) + if (onBatch && messages.length >= APPEND_BATCH_MESSAGE_LIMIT) { + onBatch(messages.splice(0)) + } + } +} diff --git a/src/main/native-chat/transcript-reader.test.ts b/src/main/native-chat/transcript-reader.test.ts index cd87f2bdd59..ff48548804d 100644 --- a/src/main/native-chat/transcript-reader.test.ts +++ b/src/main/native-chat/transcript-reader.test.ts @@ -3,6 +3,11 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { readNativeChatTranscript } from './transcript-reader' +import { + nativeChatLineDecoderForAgent, + readNativeChatTranscriptTail, + readNativeChatTranscriptTailFile +} from './transcript-tail-reader' let tempRoots: string[] = [] @@ -24,6 +29,20 @@ async function writeFixture(prefix: string, records: unknown[]): Promise } describe('readNativeChatTranscript (claude)', () => { + it('decodes OpenClaude with the Claude transcript format', async () => { + const filePath = await writeFixture('orca-native-chat-openclaude-', [ + { + type: 'assistant', + uuid: 'openclaude-assistant', + message: { role: 'assistant', content: [{ type: 'text', text: 'hello' }] } + } + ]) + + await expect( + readNativeChatTranscript('openclaude', 'session', { filePath }) + ).resolves.toMatchObject({ messages: [{ id: 'openclaude-assistant' }] }) + }) + it('returns ordered user/assistant/tool messages with no 5-message cap', async () => { const records: unknown[] = [] // 4 user/assistant turns = 8 messages, well past the AI-Vault preview cap. @@ -280,3 +299,37 @@ describe('readNativeChatTranscript (errors)', () => { } }) }) + +describe('readNativeChatTranscriptTailFile', () => { + it('keeps a missing tail retry-worthy for the live-session seed', async () => { + const result = await readNativeChatTranscriptTail({ + agent: 'claude', + sessionId: 'sess', + filePath: join(tmpdir(), 'orca-native-chat-tail-does-not-exist.jsonl'), + limit: 40 + }) + + expect(result).toMatchObject({ notFound: true }) + }) + + it('windows to nothing for a non-positive limit instead of the whole tail', async () => { + const decode = nativeChatLineDecoderForAgent('claude')! + const filePath = await writeFixture('orca-native-chat-tail-limit-', [ + { + type: 'assistant', + uuid: 'a-1', + message: { role: 'assistant', content: [{ type: 'text', text: 'one' }] } + }, + { + type: 'assistant', + uuid: 'a-2', + message: { role: 'assistant', content: [{ type: 'text', text: 'two' }] } + } + ]) + + const result = await readNativeChatTranscriptTailFile(filePath, 0, decode, true) + + expect(result.messages).toEqual([]) + expect(result.hasMore).toBe(false) + }) +}) diff --git a/src/main/native-chat/transcript-reader.ts b/src/main/native-chat/transcript-reader.ts index e2ea727c809..182296bda1d 100644 --- a/src/main/native-chat/transcript-reader.ts +++ b/src/main/native-chat/transcript-reader.ts @@ -1,5 +1,6 @@ import { createReadStream } from 'node:fs' import type { AgentType, NativeChatMessage } from '../../shared/native-chat-types' +import { resolveNativeChatTranscriptAgent } from '../../shared/native-chat-agent-support' import { errorMessage } from '../ai-vault/session-scanner-values' import { resolveSessionFilePath, type ResolveSessionFileOptions } from './session-file-resolver' import { @@ -37,13 +38,14 @@ export async function readNativeChatTranscript( return { error: `No transcript found for ${agent} session ${sessionId}`, notFound: true } } try { - if (agent === 'claude') { + const transcriptAgent = resolveNativeChatTranscriptAgent(agent) + if (transcriptAgent === 'claude') { return { messages: await readTranscript(filePath, decodeClaudeTranscriptLine) } } - if (agent === 'codex') { + if (transcriptAgent === 'codex') { return { messages: await readTranscript(filePath, decodeCodexTranscriptLine) } } - if (agent === 'grok') { + if (transcriptAgent === 'grok') { return { messages: await readTranscript(filePath, decodeGrokTranscriptLine) } } return { error: `Unsupported agent for native chat transcript: ${agent}` } diff --git a/src/main/native-chat/transcript-tail-reader.ts b/src/main/native-chat/transcript-tail-reader.ts new file mode 100644 index 00000000000..9ac029d5913 --- /dev/null +++ b/src/main/native-chat/transcript-tail-reader.ts @@ -0,0 +1,200 @@ +import { open, stat } from 'node:fs/promises' +import type { AgentType, NativeChatMessage } from '../../shared/native-chat-types' +import { resolveNativeChatTranscriptAgent } from '../../shared/native-chat-agent-support' +import { resolveSessionFilePath, type ResolveSessionFileOptions } from './session-file-resolver' +import { + decodeClaudeTranscriptLine, + decodeCodexTranscriptLine, + decodeGrokTranscriptLine +} from './transcript-line-decoders' +import { transcriptFallbackId } from './transcript-fallback-id' + +export const MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES = 2 * 1024 * 1024 +const TAIL_CHUNK_BYTES = 64 * 1024 + +export type NativeChatLineDecoder = (line: string, fallbackId: string) => NativeChatMessage | null + +export function nativeChatLineDecoderForAgent(agent: AgentType): NativeChatLineDecoder | null { + const transcriptAgent = resolveNativeChatTranscriptAgent(agent) + if (transcriptAgent === 'claude') { + return decodeClaudeTranscriptLine + } + if (transcriptAgent === 'codex') { + return decodeCodexTranscriptLine + } + if (transcriptAgent === 'grok') { + return decodeGrokTranscriptLine + } + return null +} + +export async function readNativeChatTranscriptTailFile( + filePath: string, + limit: number, + decode: NativeChatLineDecoder, + includeTrailingLine = false, + endOffset?: number +): Promise<{ + messages: NativeChatMessage[] + consumedTo: number + hasMore: boolean + beforeOffset: number +}> { + const end = Math.min((await stat(filePath)).size, endOffset ?? Number.MAX_SAFE_INTEGER) + if (end === 0) { + return { messages: [], consumedTo: 0, hasMore: false, beforeOffset: 0 } + } + const handle = await open(filePath, 'r') + const lineParts: Buffer[] = [] + let lineBytes = 0 + let lineOversized = false + try { + const consumedTo = includeTrailingLine ? end : await findLastCompleteLineEnd(handle, end) + if (consumedTo === 0) { + return { messages: [], consumedTo: 0, hasMore: false, beforeOffset: 0 } + } + const newestFirst: { message: NativeChatMessage; offset: number }[] = [] + const finalByte = Buffer.allocUnsafe(1) + await handle.read(finalByte, 0, 1, consumedTo - 1) + let cursor = consumedTo - (finalByte[0] === 0x0a ? 1 : 0) + while (cursor > 0 && newestFirst.length <= limit) { + const start = Math.max(0, cursor - TAIL_CHUNK_BYTES) + const buffer = Buffer.allocUnsafe(cursor - start) + const { bytesRead } = await handle.read(buffer, 0, buffer.length, start) + let segmentEnd = bytesRead + for (let index = bytesRead - 1; index >= 0 && newestFirst.length <= limit; index--) { + if (buffer[index] !== 0x0a) { + continue + } + retainPart(buffer.subarray(index + 1, segmentEnd)) + if (!lineOversized) { + decodeLine(start + index + 1, newestFirst) + } + resetLine() + segmentEnd = index + } + if (segmentEnd > 0) { + retainPart(buffer.subarray(0, segmentEnd)) + } + cursor = start + } + if (cursor === 0 && lineParts.length > 0 && newestFirst.length <= limit) { + decodeLine(0, newestFirst) + } + const chronological = newestFirst.toReversed() + // Why: slice(-0) returns the whole array, so a non-positive limit must + // window to nothing explicitly rather than leak every buffered record. + const selected = limit > 0 ? chronological.slice(Math.max(0, chronological.length - limit)) : [] + return { + messages: selected.map((entry) => entry.message), + consumedTo, + hasMore: limit > 0 && chronological.length > limit, + beforeOffset: selected[0]?.offset ?? end + } + } finally { + await handle.close() + } + + function retainPart(part: Buffer): void { + if (lineOversized) { + return + } + lineBytes += part.length + if (lineBytes > MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES) { + lineParts.length = 0 + lineOversized = true + return + } + lineParts.push(part) + } + + function resetLine(): void { + lineParts.length = 0 + lineBytes = 0 + lineOversized = false + } + + function decodeLine( + lineOffset: number, + messages: { message: NativeChatMessage; offset: number }[] + ): void { + let line = Buffer.concat([...lineParts].toReversed()).toString('utf8') + if (line.endsWith('\r')) { + line = line.slice(0, -1) + } + if (!line) { + return + } + const message = decode(line, transcriptFallbackId(filePath, lineOffset)) + if (message) { + messages.push({ message, offset: lineOffset }) + } + } +} + +async function findLastCompleteLineEnd( + handle: Awaited>, + end: number +): Promise { + const lastByte = Buffer.allocUnsafe(1) + await handle.read(lastByte, 0, 1, end - 1) + if (lastByte[0] === 0x0a) { + return end + } + let cursor = end + while (cursor > 0) { + const start = Math.max(0, cursor - TAIL_CHUNK_BYTES) + const buffer = Buffer.allocUnsafe(cursor - start) + const { bytesRead } = await handle.read(buffer, 0, buffer.length, start) + const newline = buffer.subarray(0, bytesRead).lastIndexOf(0x0a) + if (newline >= 0) { + return start + newline + 1 + } + cursor = start + } + return 0 +} + +export async function readNativeChatTranscriptTail( + args: ResolveSessionFileOptions & { + agent: AgentType + sessionId: string + transcriptPath?: string + filePath?: string + limit: number + beforeOffset?: number + } +): Promise< + | { messages: NativeChatMessage[]; hasMore: boolean; beforeOffset: number } + | { error: string; notFound?: true } +> { + const decode = nativeChatLineDecoderForAgent(args.agent) + const filePath = args.filePath ?? (await resolveSessionFilePath(args.agent, args.sessionId, args)) + if (!decode) { + return { error: 'Transcript unavailable' } + } + // Why: a new agent session can report its id before the first JSONL flush; + // callers keep that miss in loading/retry rather than showing a false error. + if (!filePath) { + return { error: 'Transcript unavailable', notFound: true } + } + try { + const result = await readNativeChatTranscriptTailFile( + filePath, + args.limit, + decode, + true, + args.beforeOffset + ) + return { + messages: result.messages, + hasMore: result.hasMore, + beforeOffset: result.beforeOffset + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' + ? { error: message, notFound: true } + : { error: message } + } +} diff --git a/src/main/native-chat/transcript-watch-engine.ts b/src/main/native-chat/transcript-watch-engine.ts new file mode 100644 index 00000000000..d610f5a1536 --- /dev/null +++ b/src/main/native-chat/transcript-watch-engine.ts @@ -0,0 +1,310 @@ +import { watch, type FSWatcher } from 'node:fs' +import { open, stat } from 'node:fs/promises' +import { basename, dirname } from 'node:path' +import type { AgentType, NativeChatMessage } from '../../shared/native-chat-types' +import type { ResolveSessionFileOptions } from './session-file-resolver' +import { + readIncrementalTranscriptMessages, + resetIncrementalTranscriptState, + type IncrementalTranscriptState +} from './transcript-incremental-reader' +import { readNativeChatTranscriptTailFile } from './transcript-tail-reader' + +export type SubscribeNativeChatTranscriptArgs = ResolveSessionFileOptions & { + agent: AgentType + sessionId: string + onAppend: (messages: NativeChatMessage[]) => void + onInitialSnapshot?: ( + messages: NativeChatMessage[], + hasMore: boolean, + beforeOffset: number, + /** Set when the initial drain could not deliver a transcript; the subscriber + * surfaces it as an error snapshot so a watching client never sticks on + * 'loading'. Empty messages accompany it. */ + error?: string + ) => void + onReplace?: (messages: NativeChatMessage[], hasMore: boolean, beforeOffset: number) => void + initialLimit?: number + filePath?: string + debounceMs?: number + /** Overrides the resolve-poll interval (see subscribeViaResolvePoll) so tests + * don't wait out the production backoff. Production ignores this and backs + * off from 500ms to a 5s cap. */ + resolvePollIntervalMs?: number +} + +export type NativeChatTranscriptSubscription = { + unsubscribe: () => void + watching: boolean +} + +const DEFAULT_DEBOUNCE_MS = 40 +const ROTATION_RETRY_MS = 25 +const MAX_ROTATION_RETRY_MS = 2_000 +let activeWatcherCount = 0 + +export function getActiveNativeChatWatcherCount(): number { + return activeWatcherCount +} + +async function fileVersion(filePath: string): Promise<{ identity: string; size: number }> { + const value = await stat(filePath) + return { identity: `${value.dev}:${value.ino}`, size: value.size } +} + +async function boundaryFingerprint(filePath: string, offset: number): Promise { + if (offset <= 0) { + return '' + } + const start = Math.max(0, offset - 64) + const handle = await open(filePath, 'r') + try { + const buffer = Buffer.allocUnsafe(offset - start) + const { bytesRead } = await handle.read(buffer, 0, buffer.length, start) + return buffer.subarray(0, bytesRead).toString('base64') + } finally { + await handle.close() + } +} + +/** + * Install the live-tail engine on an already-resolved file path. Returns null + * (rather than throwing) when the file doesn't exist yet or `watch()` fails — + * e.g. an explicit transcriptPath whose first JSONL line hasn't flushed, or a + * file that vanished between resolve and install — so the caller falls back to + * the resolve-poll below instead of surfacing a hard error (#8401). The + * existence gate matters because the engine watches the parent directory (see + * bindWatcher), which succeeds even when the file itself is missing. + */ +export async function installTranscriptWatcher( + filePath: string, + decode: (line: string, fallbackId: string) => NativeChatMessage | null, + args: SubscribeNativeChatTranscriptArgs +): Promise { + try { + await stat(filePath) + } catch { + return null + } + const { onAppend, onInitialSnapshot, onReplace, initialLimit, debounceMs } = args + + const state: IncrementalTranscriptState = { + offset: 0, + pendingChunks: [], + pendingStart: 0, + pendingBytes: 0, + droppingOversizedRecord: false + } + let watchedIdentity: string | null = null + let watchedBoundary = '' + let initialDrain = true + // Guards the one-time error snapshot emitted when the initial drain throws, so + // a persistently-failing retry loop can't spam the subscriber with error frames. + let initialErrorEmitted = false + let closed = false + let reading = false + let pendingReadRequested = false + let rotationRetryCount = 0 + let debounceTimer: ReturnType | null = null + let watcher: FSWatcher | null = null + let watcherNeedsRebind = false + + function scheduleDrain(): void { + if (closed) { + return + } + if (debounceTimer) { + clearTimeout(debounceTimer) + } + debounceTimer = setTimeout(() => { + debounceTimer = null + void drain() + }, debounceMs ?? DEFAULT_DEBOUNCE_MS) + } + + function scheduleRotationRetry(): void { + if (closed || debounceTimer) { + return + } + const retryDelay = Math.min( + ROTATION_RETRY_MS * 2 ** Math.min(rotationRetryCount, 7), + MAX_ROTATION_RETRY_MS + ) + rotationRetryCount += 1 + debounceTimer = setTimeout(() => { + debounceTimer = null + void drain() + }, retryDelay) + } + + async function readAndEmitAppends(): Promise { + const remaining = await readIncrementalTranscriptMessages( + filePath, + state, + decode, + (messages) => { + if (!closed) { + onAppend(messages) + } + } + ) + if (!closed && remaining.length > 0) { + onAppend(remaining) + } + } + + async function drainOnce(): Promise { + const current = await fileVersion(filePath) + const currentBoundary = await boundaryFingerprint(filePath, state.offset) + if (closed) { + return + } + if (watcherNeedsRebind) { + bindWatcher() + } + const identityChanged = watchedIdentity !== null && current.identity !== watchedIdentity + const contentReplaced = + identityChanged || + current.size < state.offset || + (state.offset > 0 && watchedBoundary !== currentBoundary) + if (contentReplaced) { + resetIncrementalTranscriptState(state) + } + watchedIdentity = current.identity + + const replacementSnapshot = + // Why: 0 is a valid window — an explicit undefined check keeps an empty + // snapshot empty instead of falling back to an unbounded incremental read. + contentReplaced && !initialDrain && onReplace && initialLimit !== undefined + ? await readNativeChatTranscriptTailFile(filePath, initialLimit, decode) + : null + if (closed) { + return + } + if (replacementSnapshot && onReplace) { + state.offset = replacementSnapshot.consumedTo + state.pendingStart = state.offset + onReplace( + replacementSnapshot.messages, + replacementSnapshot.hasMore, + replacementSnapshot.beforeOffset + ) + await readAndEmitAppends() + watchedBoundary = await boundaryFingerprint(filePath, state.offset) + rotationRetryCount = 0 + return + } + + const initialSnapshot = + initialDrain && onInitialSnapshot && initialLimit !== undefined + ? await readNativeChatTranscriptTailFile(filePath, initialLimit, decode) + : null + if (closed) { + return + } + if (initialDrain && onInitialSnapshot) { + initialDrain = false + if (initialSnapshot) { + state.offset = initialSnapshot.consumedTo + state.pendingStart = state.offset + onInitialSnapshot( + initialSnapshot.messages, + initialSnapshot.hasMore, + initialSnapshot.beforeOffset + ) + await readAndEmitAppends() + } else { + const messages = await readIncrementalTranscriptMessages(filePath, state, decode) + onInitialSnapshot(messages, false, 0) + } + } else { + initialDrain = false + await readAndEmitAppends() + } + watchedBoundary = await boundaryFingerprint(filePath, state.offset) + rotationRetryCount = 0 + } + + async function drain(): Promise { + if (closed) { + return + } + if (reading) { + pendingReadRequested = true + return + } + reading = true + try { + do { + pendingReadRequested = false + try { + await drainOnce() + } catch { + // Why: unlink/recreate can detach fs.watch from the pathname. Keep one + // capped-backoff retry alive until a successor appears or we unsubscribe. + // A still-pending initial drain also surfaces one error snapshot so a + // watching client isn't stranded at 'loading' when the read keeps + // throwing; initialDrain stays true so a recovered read can still win. + if (initialDrain && onInitialSnapshot && !initialErrorEmitted) { + initialErrorEmitted = true + onInitialSnapshot([], false, 0, 'Transcript unavailable') + } + scheduleRotationRetry() + break + } + } while (pendingReadRequested && !closed) + } finally { + reading = false + } + } + + function bindWatcher(): void { + const watchedName = basename(filePath) + // Why: file watchers stay bound to an unlinked inode on macOS. Watching + // the parent keeps observing a successor even after a long recreate gap. + const nextWatcher = watch(dirname(filePath), (_event, changedName) => { + if (changedName === null || changedName.toString() === watchedName) { + scheduleDrain() + } + }) + nextWatcher.on('error', () => { + // Why: Windows can emit EPERM when a watched directory disappears. An + // error listener prevents a process crash and retries against its successor. + if (closed || watcher !== nextWatcher) { + return + } + watcherNeedsRebind = true + nextWatcher.close() + watcher = null + scheduleRotationRetry() + }) + watcher = nextWatcher + watcherNeedsRebind = false + } + + try { + bindWatcher() + } catch { + // File vanished between resolve and watch. + return null + } + activeWatcherCount++ + scheduleDrain() + + return { + watching: true, + unsubscribe: () => { + if (closed) { + return + } + closed = true + if (debounceTimer) { + clearTimeout(debounceTimer) + debounceTimer = null + } + watcher?.close() + watcher = null + activeWatcherCount-- + } + } +} diff --git a/src/main/native-chat/transcript-watch-error.test.ts b/src/main/native-chat/transcript-watch-error.test.ts new file mode 100644 index 00000000000..2d3b0828664 --- /dev/null +++ b/src/main/native-chat/transcript-watch-error.test.ts @@ -0,0 +1,172 @@ +import { EventEmitter } from 'node:events' +import type { FSWatcher } from 'node:fs' +import type * as NodeFs from 'node:fs' +import { appendFile, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { watchers, watchCallbacks, watchMock } = vi.hoisted(() => ({ + watchers: [] as (EventEmitter & { close: ReturnType })[], + watchCallbacks: [] as ((event: string, filename: string | Buffer | null) => void)[], + watchMock: vi.fn() +})) + +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs') + watchMock.mockImplementation((_path, callback) => { + const watcher = Object.assign(new EventEmitter(), { close: vi.fn() }) + watchers.push(watcher) + watchCallbacks.push(callback) + return watcher as unknown as FSWatcher + }) + return { ...actual, watch: watchMock } +}) + +import { subscribeNativeChatTranscript } from './transcript-watch' + +const roots: string[] = [] + +afterEach(async () => { + watchers.length = 0 + watchCallbacks.length = 0 + watchMock.mockClear() + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('native chat transcript watcher errors', () => { + it('handles a watcher error and rebinds after the directory is readable', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-watch-error-')) + roots.push(root) + const filePath = join(root, 'transcript.jsonl') + await writeFile(filePath, '') + const subscription = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'session', + filePath, + onAppend: () => {}, + debounceMs: 0 + }) + + expect(() => watchers[0]!.emit('error', new Error('EPERM'))).not.toThrow() + await vi.waitFor(() => expect(watchMock).toHaveBeenCalledTimes(2)) + + subscription.unsubscribe() + expect(watchers[0]!.close).toHaveBeenCalledOnce() + expect(watchers[1]!.close).toHaveBeenCalledOnce() + }) + + it('keeps retrying after the old recovery window and tails a recreated directory', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-watch-gap-')) + roots.push(root) + const filePath = join(root, 'transcript.jsonl') + await writeFile(filePath, '') + const onInitialSnapshot = vi.fn() + const onAppend = vi.fn() + const subscription = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'session', + filePath, + onInitialSnapshot, + onAppend, + initialLimit: 40, + debounceMs: 0 + }) + await vi.waitFor(() => expect(onInitialSnapshot).toHaveBeenCalledOnce()) + + await rm(root, { recursive: true, force: true }) + watchers[0]!.emit('error', new Error('EPERM')) + await new Promise((resolve) => setTimeout(resolve, 1_200)) + expect(watchMock).toHaveBeenCalledTimes(1) + + await mkdir(root, { recursive: true }) + await writeFile(filePath, claudeLine('u-recreated', 'user', 'back')) + await vi.waitFor(() => expect(watchMock).toHaveBeenCalledTimes(2), { timeout: 2_000 }) + + await appendFile(filePath, claudeLine('a-recreated', 'assistant', 'reply')) + watchCallbacks[1]!('change', 'transcript.jsonl') + await vi.waitFor(() => + expect(onAppend.mock.calls.flat(2)).toEqual( + expect.arrayContaining([expect.objectContaining({ id: 'a-recreated' })]) + ) + ) + + subscription.unsubscribe() + expect(watchers[1]!.close).toHaveBeenCalledOnce() + }) + + it('surfaces an error snapshot when the initial drain throws', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-initial-error-')) + roots.push(root) + // A directory sitting at the transcript path: it exists (so install does not + // defer to the not-yet-flushed resolve poll, #8401) but every tail read + // throws EISDIR — a persistent real read error, not a missing file. + const filePath = join(root, 'transcript.jsonl') + await mkdir(filePath) + const onInitialSnapshot = vi.fn() + const onAppend = vi.fn() + const subscription = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'session', + filePath, + onInitialSnapshot, + onAppend, + initialLimit: 40, + debounceMs: 0 + }) + expect(subscription.watching).toBe(true) + + await vi.waitFor(() => + expect(onInitialSnapshot).toHaveBeenCalledWith([], false, 0, 'Transcript unavailable') + ) + // Surfaced once, not spammed by the capped rotation retry loop. + await new Promise((resolve) => setTimeout(resolve, 120)) + expect(onInitialSnapshot).toHaveBeenCalledOnce() + + subscription.unsubscribe() + }) + + it('still wins with a real initial snapshot once the transcript becomes readable', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-initial-recover-')) + roots.push(root) + // Same unreadable-directory setup as above; the error frame must not be + // terminal once the path is replaced with a readable transcript. + const filePath = join(root, 'transcript.jsonl') + await mkdir(filePath) + const onInitialSnapshot = vi.fn() + const subscription = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'session', + filePath, + onInitialSnapshot, + onAppend: () => {}, + initialLimit: 40, + debounceMs: 0 + }) + await vi.waitFor(() => + expect(onInitialSnapshot).toHaveBeenCalledWith([], false, 0, 'Transcript unavailable') + ) + + // initialDrain stays true after the error, so a recovered read delivers the + // real snapshot instead of stranding the client on the error frame. + await rm(filePath, { recursive: true, force: true }) + await writeFile(filePath, claudeLine('u-recovered', 'user', 'back')) + watchCallbacks[0]!('change', 'transcript.jsonl') + await vi.waitFor(() => + expect(onInitialSnapshot.mock.calls.flat(2)).toEqual( + expect.arrayContaining([expect.objectContaining({ id: 'u-recovered' })]) + ) + ) + + subscription.unsubscribe() + }) +}) + +function claudeLine(uuid: string, role: 'user' | 'assistant', text: string): string { + return `${JSON.stringify({ + type: role, + uuid, + timestamp: '2026-06-01T10:00:00.000Z', + message: { role, content: role === 'user' ? text : [{ type: 'text', text }] } + })}\n` +} diff --git a/src/main/native-chat/transcript-watch-resolve-poll.test.ts b/src/main/native-chat/transcript-watch-resolve-poll.test.ts new file mode 100644 index 00000000000..be4e60dede3 --- /dev/null +++ b/src/main/native-chat/transcript-watch-resolve-poll.test.ts @@ -0,0 +1,62 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + install: vi.fn(), + resolve: vi.fn() +})) + +vi.mock('./session-file-resolver', () => ({ + resolveSessionFilePath: mocks.resolve +})) +vi.mock('./transcript-watch-engine', () => ({ + getActiveNativeChatWatcherCount: vi.fn(() => 0), + installTranscriptWatcher: mocks.install +})) + +import { subscribeNativeChatTranscript } from './transcript-watch' + +describe('native chat transcript resolve polling', () => { + beforeEach(() => { + vi.useFakeTimers() + mocks.install.mockReset().mockReturnValue(null) + mocks.resolve.mockReset().mockResolvedValue(null) + }) + + afterEach(() => vi.useRealTimers()) + + it('fast-probes an exact hook path without repeatedly scanning the session tree', async () => { + const subscription = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'session-id', + transcriptPath: '/missing/exact.jsonl', + resolvePollIntervalMs: 10, + onAppend: () => {} + }) + expect(mocks.resolve).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(100) + expect(mocks.install.mock.calls.length).toBeGreaterThan(1) + expect(mocks.resolve).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(4_900) + expect(mocks.resolve).toHaveBeenCalledTimes(2) + + subscription.unsubscribe() + const callsAfterUnsubscribe = mocks.install.mock.calls.length + await vi.advanceTimersByTimeAsync(100) + expect(mocks.install).toHaveBeenCalledTimes(callsAfterUnsubscribe) + }) + + it('keeps resolving on every retry when no exact hook path is available', async () => { + const subscription = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'session-id', + resolvePollIntervalMs: 10, + onAppend: () => {} + }) + + await vi.advanceTimersByTimeAsync(35) + expect(mocks.resolve.mock.calls.length).toBeGreaterThan(1) + subscription.unsubscribe() + }) +}) diff --git a/src/main/native-chat/transcript-watch.test.ts b/src/main/native-chat/transcript-watch.test.ts index 4ff621d05bc..512da6b014e 100644 --- a/src/main/native-chat/transcript-watch.test.ts +++ b/src/main/native-chat/transcript-watch.test.ts @@ -1,9 +1,13 @@ -import { appendFile, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { appendFile, mkdtemp, rename, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { NativeChatMessage } from '../../shared/native-chat-types' -import { getActiveNativeChatWatcherCount, subscribeNativeChatTranscript } from './transcript-watch' +import { + getActiveNativeChatWatcherCount, + readNativeChatTranscriptTail, + subscribeNativeChatTranscript +} from './transcript-watch' let tempRoots: string[] = [] @@ -52,6 +56,191 @@ async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise { + it('delivers the offset-0 drain as one initial snapshot, then only live appends', async () => { + const filePath = await tempFile(claudeLine('u-1', 'user', 'first')) + const snapshots: NativeChatMessage[][] = [] + const appends: NativeChatMessage[][] = [] + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + onInitialSnapshot: (messages) => snapshots.push(messages), + onAppend: (messages) => appends.push(messages), + debounceMs: 5 + }) + + expect(sub.watching).toBe(true) + await waitFor(() => snapshots.length === 1) + expect(snapshots[0]?.map((message) => message.id)).toEqual(['u-1']) + expect(appends).toEqual([]) + + await appendFile(filePath, claudeLine('a-1', 'assistant', 'reply')) + await waitFor(() => appends.flat().some((message) => message.id === 'a-1')) + sub.unsubscribe() + + expect(snapshots).toHaveLength(1) + expect(appends.flat().map((message) => message.id)).toEqual(['a-1']) + }) + + it('delivers an empty initial snapshot so clients do not remain loading', async () => { + const filePath = await tempFile('') + const snapshots: NativeChatMessage[][] = [] + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + onInitialSnapshot: (messages) => snapshots.push(messages), + onAppend: () => {}, + debounceMs: 5 + }) + + await waitFor(() => snapshots.length === 1) + sub.unsubscribe() + expect(snapshots).toEqual([[]]) + }) + + it('emits a bulk append in bounded ordered batches', async () => { + const filePath = await tempFile('') + const batches: NativeChatMessage[][] = [] + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + onAppend: (messages) => batches.push(messages), + debounceMs: 5 + }) + await new Promise((resolve) => setTimeout(resolve, 20)) + await appendFile( + filePath, + Array.from({ length: 95 }, (_unused, index) => + claudeLine(`bulk-${index}`, 'user', `message-${index}`) + ).join('') + ) + + await waitFor(() => batches.flat().length === 95) + sub.unsubscribe() + expect(batches.map((batch) => batch.length)).toEqual([40, 40, 15]) + expect(batches.flat().map((message) => message.id)).toEqual( + Array.from({ length: 95 }, (_unused, index) => `bulk-${index}`) + ) + }) + + it('drops one oversized record without retaining or blocking the next append', async () => { + const filePath = await tempFile('') + const seen: NativeChatMessage[] = [] + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + onAppend: (messages) => seen.push(...messages), + debounceMs: 5 + }) + await new Promise((resolve) => setTimeout(resolve, 20)) + await appendFile( + filePath, + claudeLine('oversized', 'user', 'x'.repeat(2 * 1024 * 1024)) + + claudeLine('after-oversized', 'user', 'still delivered') + ) + + await waitFor(() => seen.some((message) => message.id === 'after-oversized')) + sub.unsubscribe() + expect(seen.some((message) => message.id === 'oversized')).toBe(false) + }) + + it('returns a bounded tail snapshot with exact pagination state', async () => { + const transcript = Array.from({ length: 800 }, (_unused, index) => + claudeLine(`u-${index}`, 'user', `message-${index}-${'x'.repeat(100)}`) + ).join('') + const filePath = await tempFile(transcript) + const snapshots: { ids: string[]; hasMore: boolean }[] = [] + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + initialLimit: 2, + onInitialSnapshot: (messages, hasMore) => + snapshots.push({ ids: messages.map((message) => message.id), hasMore }), + onAppend: () => {}, + debounceMs: 5 + }) + + await waitFor(() => snapshots.length === 1) + sub.unsubscribe() + expect(snapshots).toEqual([{ ids: ['u-798', 'u-799'], hasMore: true }]) + }) + + it('keeps an explicit zero-limit snapshot empty instead of reading unbounded', async () => { + const filePath = await tempFile(claudeLine('u-0', 'user', 'hello')) + const snapshots: { ids: string[]; hasMore: boolean }[] = [] + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + initialLimit: 0, + onInitialSnapshot: (messages, hasMore) => + snapshots.push({ ids: messages.map((message) => message.id), hasMore }), + onAppend: () => {}, + debounceMs: 5 + }) + + await waitFor(() => snapshots.length === 1) + sub.unsubscribe() + expect(snapshots).toEqual([{ ids: [], hasMore: false }]) + }) + + it('pages older history by byte cursor without resending the growing tail', async () => { + const filePath = await tempFile( + Array.from({ length: 10 }, (_unused, index) => + claudeLine(`page-${index}`, 'user', `message-${index}`) + ).join('') + ) + const newest = await readNativeChatTranscriptTail({ + agent: 'claude', + sessionId: 'ignored', + filePath, + limit: 3 + }) + if ('error' in newest) { + throw new Error(newest.error) + } + const older = await readNativeChatTranscriptTail({ + agent: 'claude', + sessionId: 'ignored', + filePath, + limit: 3, + beforeOffset: newest.beforeOffset + }) + + expect(newest.messages.map((message) => message.id)).toEqual(['page-7', 'page-8', 'page-9']) + expect('messages' in older && older.messages.map((message) => message.id)).toEqual([ + 'page-4', + 'page-5', + 'page-6' + ]) + }) + + it('decodes multi-chunk records once into the bounded tail order', async () => { + const large = 'x'.repeat(200_000) + const filePath = await tempFile( + claudeLine('u-large-1', 'user', large) + claudeLine('u-large-2', 'user', large) + ) + const snapshots: { ids: string[]; hasMore: boolean }[] = [] + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + initialLimit: 1, + onInitialSnapshot: (messages, hasMore) => + snapshots.push({ ids: messages.map((message) => message.id), hasMore }), + onAppend: () => {}, + debounceMs: 5 + }) + + await waitFor(() => snapshots.length === 1) + sub.unsubscribe() + expect(snapshots).toEqual([{ ids: ['u-large-2'], hasMore: true }]) + }) + it('re-emits from the top on first drain so appended turns are never dropped', async () => { const filePath = await tempFile(claudeLine('u-1', 'user', 'first')) const batches: NativeChatMessage[][] = [] @@ -233,6 +422,109 @@ describe('subscribeNativeChatTranscript', () => { expect(ids).toContain('a-2') }) + it('detects same-size and larger in-place transcript replacement', async () => { + const filePath = await tempFile(claudeLine('u-old', 'user', 'old')) + const seen: NativeChatMessage[] = [] + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + onAppend: (messages) => seen.push(...messages), + debounceMs: 5 + }) + await waitFor(() => seen.some((message) => message.id === 'u-old')) + + await writeFile(filePath, claudeLine('u-new', 'user', 'new')) + await waitFor(() => seen.some((message) => message.id === 'u-new')) + await writeFile(filePath, claudeLine('u-bigger', 'user', 'larger replacement text')) + await waitFor(() => seen.some((message) => message.id === 'u-bigger')) + sub.unsubscribe() + + expect(seen.filter((message) => message.id === 'u-new')).toHaveLength(1) + expect(seen.filter((message) => message.id === 'u-bigger')).toHaveLength(1) + }) + + it('keeps watching after atomic rename replacement', async () => { + const filePath = await tempFile(claudeLine('atomic-old', 'user', 'old')) + const replacementPath = `${filePath}.replacement` + const seen: NativeChatMessage[] = [] + const replacements: string[][] = [] + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + initialLimit: 40, + onInitialSnapshot: (messages) => seen.push(...messages), + onReplace: (messages) => { + replacements.push(messages.map((message) => message.id)) + seen.splice(0, seen.length, ...messages) + }, + onAppend: (messages) => seen.push(...messages), + debounceMs: 5 + }) + await waitFor(() => seen.some((message) => message.id === 'atomic-old')) + + await writeFile(replacementPath, claudeLine('atomic-new', 'user', 'replacement')) + await rename(replacementPath, filePath) + await waitFor(() => seen.some((message) => message.id === 'atomic-new')) + await appendFile(filePath, claudeLine('atomic-followup', 'assistant', 'still watched')) + await waitFor(() => seen.some((message) => message.id === 'atomic-followup')) + sub.unsubscribe() + + expect(replacements).toEqual([['atomic-new']]) + expect(seen.some((message) => message.id === 'atomic-old')).toBe(false) + expect(seen.filter((message) => message.id === 'atomic-followup')).toHaveLength(1) + }) + + it('does not resurrect the watcher when unsubscribe races an atomic replacement', async () => { + const filePath = await tempFile(claudeLine('race-old', 'user', 'old')) + const replacementPath = `${filePath}.replacement` + const before = getActiveNativeChatWatcherCount() + const seen: NativeChatMessage[] = [] + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + onAppend: (messages) => seen.push(...messages), + debounceMs: 0 + }) + await waitFor(() => seen.some((message) => message.id === 'race-old')) + + await writeFile(replacementPath, claudeLine('race-new', 'user', 'replacement')) + await rename(replacementPath, filePath) + await new Promise((resolve) => setTimeout(resolve, 0)) + sub.unsubscribe() + await new Promise((resolve) => setTimeout(resolve, 30)) + + expect(getActiveNativeChatWatcherCount()).toBe(before) + await appendFile(filePath, claudeLine('race-after', 'assistant', 'must stay closed')) + await new Promise((resolve) => setTimeout(resolve, 30)) + expect(seen.some((message) => message.id === 'race-after')).toBe(false) + }) + + it('recovers after an unlink/recreate gap outlasts the fast retry window', async () => { + const filePath = await tempFile(claudeLine('unlink-old', 'user', 'old')) + const seen: NativeChatMessage[] = [] + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + onAppend: (messages) => seen.push(...messages), + debounceMs: 0 + }) + await waitFor(() => seen.some((message) => message.id === 'unlink-old')) + + await rm(filePath) + await new Promise((resolve) => setTimeout(resolve, 1_200)) + await writeFile(filePath, claudeLine('unlink-new', 'user', 'replacement')) + await waitFor(() => seen.some((message) => message.id === 'unlink-new')) + await appendFile(filePath, claudeLine('unlink-after', 'assistant', 'still watched')) + await waitFor(() => seen.some((message) => message.id === 'unlink-after')) + sub.unsubscribe() + + expect(seen.filter((message) => message.id === 'unlink-after')).toHaveLength(1) + }) + it('returns a no-op unsubscribe when the file cannot be resolved', async () => { const before = getActiveNativeChatWatcherCount() const sub = await subscribeNativeChatTranscript({ @@ -240,6 +532,7 @@ describe('subscribeNativeChatTranscript', () => { sessionId: '', onAppend: () => {} }) + expect(sub.watching).toBe(false) expect(getActiveNativeChatWatcherCount()).toBe(before) // Must not throw. sub.unsubscribe() diff --git a/src/main/native-chat/transcript-watch.ts b/src/main/native-chat/transcript-watch.ts index 1f55c33124a..f3228d970fc 100644 --- a/src/main/native-chat/transcript-watch.ts +++ b/src/main/native-chat/transcript-watch.ts @@ -1,225 +1,19 @@ -// Live transcript tailing: watch a resolved session JSONL file and emit only -// the messages parsed from bytes appended since the last read. Modeled on the -// incremental byte-offset read in codex-usage/scanner.ts (parseCodexUsageFile's -// skipInitialBytes), but specialized to the NativeChatMessage record decoders. -// -// Teardown discipline (plan U4 risk: file-watch fd leaks): every subscription -// owns exactly one fs.FSWatcher and one debounce timer. unsubscribe() closes -// the watcher and clears the timer synchronously, and the module tracks the live -// watcher count so tests can assert no watcher survives teardown. - -import { watch, type FSWatcher } from 'node:fs' -import { open, stat } from 'node:fs/promises' -import type { AgentType, NativeChatMessage } from '../../shared/native-chat-types' -import { resolveSessionFilePath, type ResolveSessionFileOptions } from './session-file-resolver' +import { extname } from 'node:path' +import type { NativeChatMessage } from '../../shared/native-chat-types' +import { resolveSessionFilePath } from './session-file-resolver' import { - decodeClaudeTranscriptLine, - decodeCodexTranscriptLine, - decodeGrokTranscriptLine -} from './transcript-line-decoders' -import { decodeTranscriptStream } from './transcript-stream-lines' + installTranscriptWatcher, + type NativeChatTranscriptSubscription, + type SubscribeNativeChatTranscriptArgs +} from './transcript-watch-engine' +import { nativeChatLineDecoderForAgent } from './transcript-tail-reader' -export type SubscribeNativeChatTranscriptArgs = ResolveSessionFileOptions & { - agent: AgentType - sessionId: string - /** Called with the newly-appended messages whenever the file grows. Never - * called with an empty array. */ - onAppend: (messages: NativeChatMessage[]) => void - /** Resolve directly to this file, skipping path discovery (used by tests). */ - filePath?: string - /** Coalesce window for rapid fs.watch events (ms). Defaults to 40ms. */ - debounceMs?: number - /** Overrides the resolve-poll interval (see subscribeViaResolvePoll) so tests - * don't wait out the production backoff. Production ignores this and backs - * off from 500ms to a 5s cap. */ - resolvePollIntervalMs?: number -} - -export type NativeChatTranscriptSubscription = { - /** Closes the watcher and releases the file handle. Idempotent. */ - unsubscribe: () => void -} - -// Why: a single watch event can fire several times for one append; we read from -// the last byte offset so re-entrant reads never re-emit prior messages. Each -// decoder is stateless per-line, so tailing reuses the same record→message -// mapping the full reader uses. -const DEFAULT_DEBOUNCE_MS = 40 - -// Why: process-wide count of live FSWatchers opened by this module. The U4 leak -// test asserts this returns to zero after unsubscribe so a forgotten handle is -// caught deterministically rather than relying on OS fd inspection. -let activeWatcherCount = 0 - -/** Test-only: number of fs watchers this module currently holds open. */ -export function getActiveNativeChatWatcherCount(): number { - return activeWatcherCount -} - -function lineDecoderForAgent( - agent: AgentType -): ((line: string, fallbackId: string) => NativeChatMessage | null) | null { - if (agent === 'claude') { - return decodeClaudeTranscriptLine - } - if (agent === 'codex') { - return decodeCodexTranscriptLine - } - if (agent === 'grok') { - return decodeGrokTranscriptLine - } - return null -} - -async function fileSize(filePath: string): Promise { - try { - return (await stat(filePath)).size - } catch { - return 0 - } -} - -/** - * Read bytes [start, end) of the file and decode each complete line into a - * NativeChatMessage. Opens its own fd and always closes it (no leak on the read - * path, distinct from the long-lived watcher). Returns the messages plus the - * byte offset actually consumed so a partially-written trailing line is re-read - * on the next append rather than dropped. - */ -async function readAppendedMessages( - filePath: string, - start: number, - decode: (line: string, fallbackId: string) => NativeChatMessage | null -): Promise<{ messages: NativeChatMessage[]; consumedTo: number }> { - const end = await fileSize(filePath) - if (end <= start) { - // File shrank (rotation/replacement) or unchanged — caller resets offset. - return { messages: [], consumedTo: end } - } - - const handle = await open(filePath, 'r') - try { - const stream = handle.createReadStream({ - encoding: 'utf-8', - start, - end: end - 1, - autoClose: false - }) - const { messages, consumedBytes } = await decodeTranscriptStream( - stream, - filePath, - start, - decode, - false - ) - return { messages, consumedTo: start + consumedBytes } - } finally { - await handle.close() - } -} - -/** - * Install a live-tail watcher on an already-resolved file path. Returns null - * (rather than throwing) when `watch()` fails — e.g. the file vanished between - * resolve and install — so the caller can fall back to the resolve-poll below - * instead of surfacing a hard error. - */ -function installTranscriptWatcher( - filePath: string, - decode: (line: string, fallbackId: string) => NativeChatMessage | null, - onAppend: (messages: NativeChatMessage[]) => void, - debounceMs?: number -): NativeChatTranscriptSubscription | null { - // Why: seed the offset at 0 so the FIRST drain re-reads the whole file. This - // closes the read/subscribe race — a turn appended between the caller's - // readSession EOF and the watcher install is still emitted. Re-emitted lines - // collapse by deterministic id in the assembler (no dup, no drop). Subsequent - // drains use the incremental offset so the full re-read happens only once. - let offset = 0 - let closed = false - let reading = false - let pendingReadRequested = false - let debounceTimer: ReturnType | null = null - - async function drain(): Promise { - if (closed) { - return - } - if (reading) { - // A read is already in flight; mark that another pass is needed so rapid - // successive appends coalesce without dropping the trailing one. - pendingReadRequested = true - return - } - reading = true - try { - do { - pendingReadRequested = false - try { - const currentSize = await fileSize(filePath) - if (currentSize < offset) { - // Rotation/replacement/truncation: re-read from the top. - offset = 0 - } - const { messages, consumedTo } = await readAppendedMessages(filePath, offset, decode) - offset = consumedTo - if (!closed && messages.length > 0) { - onAppend(messages) - } - } catch { - // Why: a transient read failure (EACCES/EIO/ENOENT during rotation) - // must not leave the subscription permanently deaf. Stop this drain; - // the finally resets `reading` so a later fs event re-arms the read. - break - } - } while (pendingReadRequested && !closed) - } finally { - reading = false - } - } - - function scheduleDrain(): void { - if (closed) { - return - } - if (debounceTimer) { - clearTimeout(debounceTimer) - } - debounceTimer = setTimeout(() => { - debounceTimer = null - void drain() - }, debounceMs ?? DEFAULT_DEBOUNCE_MS) - } - - let watcher: FSWatcher - try { - watcher = watch(filePath, scheduleDrain) - } catch { - // File vanished between resolve and watch. - return null - } - activeWatcherCount++ - - // Why: on some platforms fs.watch can miss the very first append that lands - // between offset-seed and watcher install. Kick one debounced drain so a - // turn written immediately after subscribe is still picked up. - scheduleDrain() - - return { - unsubscribe: () => { - if (closed) { - return - } - closed = true - if (debounceTimer) { - clearTimeout(debounceTimer) - debounceTimer = null - } - watcher.close() - activeWatcherCount-- - } - } -} +export { readNativeChatTranscriptTail } from './transcript-tail-reader' +export { + getActiveNativeChatWatcherCount, + type NativeChatTranscriptSubscription, + type SubscribeNativeChatTranscriptArgs +} from './transcript-watch-engine' /** One resolve+install attempt. Returns null when the file isn't resolvable * yet, or vanished between resolve and `watch()` — either case is retried by @@ -232,22 +26,30 @@ async function attemptInstall( if (!filePath) { return null } - return installTranscriptWatcher(filePath, decode, args.onAppend, args.debounceMs) + return installTranscriptWatcher(filePath, decode, args) } // Why: Claude Code (and other agents) can take from ~3s to minutes to flush a // brand-new session's first JSONL line (#8401) — resolveSessionFilePath -// genuinely has nothing to find yet. Poll for it instead of going deaf; each -// attempt is a cheap glob, so the cost of polling is negligible next to the -// gap it covers. +// genuinely has nothing to find yet. Poll for it instead of going deaf. Exact +// hook paths are probed on every retry; the recursive +// session-id fallback runs less often because a large Claude tree is expensive. const INITIAL_RESOLVE_POLL_MS = 500 const MAX_RESOLVE_POLL_MS = 5_000 +const FALLBACK_RESOLVE_POLL_MS = 5_000 + +function exactTranscriptPath(args: SubscribeNativeChatTranscriptArgs): string | null { + const path = args.transcriptPath?.trim() + return path && extname(path) === '.jsonl' ? path : null +} /** * Background retry loop for a transcript that hasn't been resolvable yet. * Returns a subscription immediately (per subscribeNativeChatTranscript's * contract); the loop keeps retrying resolve+install until it succeeds or - * unsubscribe() cancels it. + * unsubscribe() cancels it. Reports watching:true — the engine's first drain + * delivers the initial snapshot once the file appears, so subscribers must not + * settle a merely not-yet-flushed transcript into a permanent error (#8401). */ function subscribeViaResolvePoll( args: SubscribeNativeChatTranscriptArgs, @@ -257,15 +59,23 @@ function subscribeViaResolvePoll( let installed: NativeChatTranscriptSubscription | null = null let pollTimer: ReturnType | null = null let delay = args.resolvePollIntervalMs ?? INITIAL_RESOLVE_POLL_MS + let lastFallbackResolveAt = Date.now() + const exactPath = exactTranscriptPath(args) function scheduleAttempt(): void { if (closed) { return } - pollTimer = setTimeout(() => { - pollTimer = null - void runAttempt() - }, delay) + const untilFallbackResolve = exactPath + ? Math.max(0, FALLBACK_RESOLVE_POLL_MS - (Date.now() - lastFallbackResolveAt)) + : delay + pollTimer = setTimeout( + () => { + pollTimer = null + void runAttempt() + }, + Math.min(delay, untilFallbackResolve) + ) // Why: never hold the event loop open (headless `orca serve` shutdown) for // a session that may genuinely never resolve. pollTimer.unref?.() @@ -282,7 +92,14 @@ function subscribeViaResolvePoll( } let result: NativeChatTranscriptSubscription | null try { - result = await attemptInstall(args, decode) + result = exactPath ? await attemptInstall({ ...args, filePath: exactPath }, decode) : null + if ( + !result && + (!exactPath || Date.now() - lastFallbackResolveAt >= FALLBACK_RESOLVE_POLL_MS) + ) { + lastFallbackResolveAt = Date.now() + result = await attemptInstall(args, decode) + } } catch { // Why: a transient resolve failure (EACCES/EIO during the glob) must not // kill the poll loop with an unhandled rejection — retry like a miss. @@ -303,6 +120,7 @@ function subscribeViaResolvePoll( scheduleAttempt() return { + watching: true, unsubscribe: () => { if (closed) { return @@ -334,16 +152,16 @@ function subscribeViaResolvePoll( export async function subscribeNativeChatTranscript( args: SubscribeNativeChatTranscriptArgs ): Promise { - const decode = lineDecoderForAgent(args.agent) + const decode = nativeChatLineDecoderForAgent(args.agent) if (!decode) { // Nothing watchable — return a no-op teardown so callers can unconditionally // unsubscribe without null-checks. - return { unsubscribe: () => {} } + return { unsubscribe: () => {}, watching: false } } // Why: a blank session id (and no explicit file) can never resolve — bail out // instead of resolve-polling an unresolvable target forever. if (!args.filePath && !args.sessionId.trim()) { - return { unsubscribe: () => {} } + return { unsubscribe: () => {}, watching: false } } const installed = await attemptInstall(args, decode) diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 3564b00b82b..acde5dd02c3 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -21,6 +21,7 @@ import type { ProjectGroup, ProjectHostSetup, Repo, + GlobalSettings, TerminalPaneLayoutNode, TerminalTab, WorktreeLineage, @@ -41,6 +42,7 @@ import { toRuntimeExecutionHostId, toSshExecutionHostId } from '../shared/execut import { SshConnectionStore } from './ssh/ssh-connection-store' import { setSourceControlActionDefault } from '../shared/source-control-ai-actions' import { LEGACY_DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../shared/ssh-types' +import { closeTerminalTabInWorkspaceSession } from '../shared/workspace-session-terminal-tab-close' // Shared mutable state so the electron mock can reference a per-test directory const testState = { dir: '' } @@ -337,6 +339,58 @@ describe('Store', () => { expect(store.getRepos()).toEqual([]) }, 15_000) + it('does not restore a terminal tab after its durable close flush returns', async () => { + const store = await createStore() + const worktreeId = 'repo-1::/tmp/worktree-1' + const tabId = 'terminal-1' + const session: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + activeWorktreeId: worktreeId, + activeTabId: tabId, + tabsByWorktree: { + [worktreeId]: [ + { + id: tabId, + ptyId: 'pty-1', + worktreeId, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + [tabId]: { + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-1' } + } + }, + activeTabIdByWorktree: { [worktreeId]: tabId }, + defaultTerminalTabsAppliedByWorktreeId: { [worktreeId]: true } + } + store.setWorkspaceSession(session) + store.flushOrThrow() + + const closed = closeTerminalTabInWorkspaceSession( + store.getWorkspaceSession(), + worktreeId, + tabId + ) + store.setWorkspaceSession(closed.session) + store.flushOrThrow() + + const reloaded = await createStore() + expect(reloaded.getWorkspaceSession().tabsByWorktree[worktreeId]).toEqual([]) + expect(reloaded.getWorkspaceSession().terminalLayoutsByTabId[tabId]).toBeUndefined() + expect( + reloaded.getWorkspaceSession().defaultTerminalTabsAppliedByWorktreeId?.[worktreeId] + ).toBe(true) + }) + it('loads state from an explicit profile data file path', async () => { const profileDataDirectory = join(testState.dir, 'profiles', 'local-default') const profileDataFile = join(profileDataDirectory, 'orca-data.json') @@ -648,6 +702,72 @@ describe('Store', () => { expect(store.getSettings().minimizeToTrayOnClose).toBe(false) }) + it('defaults the menu bar icon on regardless of platform', async () => { + await withPlatform('darwin', async () => { + const store = await createStore() + expect(store.getSettings().showMenuBarIcon).toBe(true) + }) + + await withPlatform('linux', async () => { + const store = await createStore() + expect(store.getSettings().showMenuBarIcon).toBe(true) + }) + }) + + it('enables the menu bar icon when an existing macOS profile has no stored value', async () => { + await withPlatform('darwin', async () => { + const persisted = getDefaultPersistedState(testState.dir) + delete (persisted.settings as Partial).showMenuBarIcon + writeDataFile(persisted) + + const store = await createStore() + + expect(store.getSettings().showMenuBarIcon).toBe(true) + }) + }) + + it('persists an explicit macOS menu bar opt-out', async () => { + await withPlatform('darwin', async () => { + const store = await createStore() + store.updateSettings({ showMenuBarIcon: false }) + store.flush() + + expect((readDataFile() as PersistedState).settings.showMenuBarIcon).toBe(false) + expect((await createStore()).getSettings().showMenuBarIcon).toBe(false) + }) + }) + + it('normalizes menu bar icon writes to a strict boolean', async () => { + await withPlatform('darwin', async () => { + const store = await createStore() + store.updateSettings({ showMenuBarIcon: 'true' as unknown as boolean }) + expect(store.getSettings().showMenuBarIcon).toBe(false) + store.updateSettings({ showMenuBarIcon: true }) + expect(store.getSettings().showMenuBarIcon).toBe(true) + }) + }) + + it('round-trips a macOS menu bar opt-out through a non-mac host unchanged', async () => { + await withPlatform('darwin', async () => { + const store = await createStore() + store.updateSettings({ showMenuBarIcon: false }) + store.flush() + }) + + // Why: a profile opened on another OS must not rewrite the mac preference + // on its next flush; only the darwin consumers act on the value. + await withPlatform('win32', async () => { + const store = await createStore() + store.updateSettings({ minimizeToTrayOnClose: true }) + store.flush() + expect((readDataFile() as PersistedState).settings.showMenuBarIcon).toBe(false) + }) + + await withPlatform('darwin', async () => { + expect((await createStore()).getSettings().showMenuBarIcon).toBe(false) + }) + }) + it('defaults trayMinimizeNoticeShown to false and persists it strictly', async () => { const store = await createStore() expect(store.getUI().trayMinimizeNoticeShown).toBe(false) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index d407b9362d3..a237e941150 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -3200,6 +3200,9 @@ export class Store { // Why: persisted settings can be user-edited or written by older // builds; keep tray-minimize false unless the stored value is true. minimizeToTrayOnClose: parsed.settings?.minimizeToTrayOnClose === true, + // Why: missing means default-on, and the value must round-trip + // unchanged on non-mac hosts; the darwin consumers gate the effect. + showMenuBarIcon: parsed.settings?.showMenuBarIcon !== false, uiLanguage: normalizeUiLanguage(parsed.settings?.uiLanguage), defaultTaskSource: taskProviderSettings.defaultTaskSource, visibleTaskProviders: taskProviderSettings.visibleTaskProviders, @@ -3806,7 +3809,7 @@ export class Store { } } - private flushOrThrow(): void { + flushOrThrow(): void { if (this.writeTimer) { clearTimeout(this.writeTimer) this.writeTimer = null @@ -5265,6 +5268,9 @@ export class Store { if ('minimizeToTrayOnClose' in updates) { sanitizedUpdates.minimizeToTrayOnClose = updates.minimizeToTrayOnClose === true } + if ('showMenuBarIcon' in updates) { + sanitizedUpdates.showMenuBarIcon = updates.showMenuBarIcon === true + } if ('disabledTuiAgents' in updates) { sanitizedUpdates.disabledTuiAgents = normalizeDisabledTuiAgents(updates.disabledTuiAgents) } diff --git a/src/main/pi/agent-status-extension-source.test.ts b/src/main/pi/agent-status-extension-source.test.ts index 47ac259f1bb..c77b094b97a 100644 --- a/src/main/pi/agent-status-extension-source.test.ts +++ b/src/main/pi/agent-status-extension-source.test.ts @@ -5,7 +5,11 @@ import { describe, expect, it, vi } from 'vitest' import { getPiAgentStatusExtensionSource } from './agent-status-extension-source' -type HookHandler = (event?: unknown) => Promise | void +type HookContext = { + isIdle: () => boolean +} + +type HookHandler = (event?: unknown, context?: HookContext) => Promise | void type FakeCurlChild = { on: ReturnType @@ -24,7 +28,11 @@ type Harness = { readFileSync: ReturnType } handlers: Record - callHook: (name: string, event?: unknown) => Promise + processEnv: Record + callHook: (name: string, event?: unknown, context?: HookContext) => Promise + // Re-invoke the extension factory in the same process (as Pi does on an + // in-process extension reload), swapping in the freshly registered handlers. + reload: () => void } const BASE_ENV = { @@ -38,9 +46,14 @@ const BASE_ENV = { ORCA_AGENT_HOOK_VERSION: '1.2.3' } satisfies Record +// Why: ownership keys on process.pid, so reload and child-process tests need +// stable, distinct identities. +const SELF_PID = 4242 + function createHarness(args: { kind: 'pi' | 'omp' env?: Record + pid?: number title?: string argv?: string[] existsSync?: (path: string) => boolean @@ -95,6 +108,7 @@ function createHarness(args: { ...BASE_ENV, ...args.env }, + pid: args.pid ?? SELF_PID, title: args.title ?? 'node', argv: args.argv ?? ['node', '/usr/bin/orca'] } @@ -134,11 +148,14 @@ function createHarness(args: { } const handlers: Record = {} - register({ - on(name: string, handler: HookHandler) { - handlers[name] = handler - } - }) + const registerInto = (target: Record): void => { + register({ + on(name: string, handler: HookHandler) { + target[name] = handler + } + }) + } + registerInto(handlers) return { fetchMock, @@ -146,8 +163,15 @@ function createHarness(args: { spawnedChildren, fsMock, handlers, - callHook: async (name, event) => { - await handlers[name]?.(event) + processEnv: processMock.env, + callHook: async (name, event, hookContext) => { + await handlers[name]?.(event, hookContext) + }, + reload: () => { + for (const key of Object.keys(handlers)) { + delete handlers[key] + } + registerInto(handlers) } } } @@ -167,6 +191,51 @@ describe('getPiAgentStatusExtensionSource', () => { expect(harness.spawnMock).not.toHaveBeenCalled() }) + it.each(['pi', 'omp'] as const)( + 'registers no status handlers for a nested %s subagent process', + (kind) => { + // Why: inheriting the lead's owner PID must disable the extension as a + // whole, so future hook additions cannot reopen the notification leak. + const lead = createHarness({ kind, pid: SELF_PID }) + const child = createHarness({ kind, pid: SELF_PID + 1, env: lead.processEnv }) + const grandchild = createHarness({ kind, pid: SELF_PID + 2, env: child.processEnv }) + + expect(child.handlers).toEqual({}) + expect(grandchild.handlers).toEqual({}) + expect(child.processEnv.ORCA_PI_STATUS_OWNED).toBe(String(SELF_PID)) + expect(grandchild.processEnv.ORCA_PI_STATUS_OWNED).toBe(String(SELF_PID)) + expect(child.fetchMock).not.toHaveBeenCalled() + expect(child.spawnMock).not.toHaveBeenCalled() + } + ) + + it('reports agent_end for a top-level run (including non-interactive) and claims the pane by pid', async () => { + // Why: non-interactive top-level runs still own their pane and must report. + const harness = createHarness({ kind: 'pi', pid: SELF_PID, argv: ['node', 'pi', '-p'] }) + + await harness.callHook('agent_end') + + expect(harness.fetchMock).toHaveBeenCalledTimes(1) + const body = JSON.parse(String(harness.fetchMock.mock.calls[0]?.[1]?.body)) + expect(body.payload).toEqual({ hook_event_name: 'agent_end' }) + expect(harness.processEnv.ORCA_PI_STATUS_OWNED).toBe(String(SELF_PID)) + }) + + it('keeps reporting after the lead re-runs the extension factory on reload', async () => { + // Why: Pi reloads extensions in-process, so the lead must recognize its PID + // instead of mistaking its own marker for a nested child. + const harness = createHarness({ kind: 'pi', pid: SELF_PID }) + + expect(harness.processEnv.ORCA_PI_STATUS_OWNED).toBe(String(SELF_PID)) + + harness.reload() + await harness.callHook('agent_end') + + expect(harness.fetchMock).toHaveBeenCalledTimes(1) + const body = JSON.parse(String(harness.fetchMock.mock.calls[0]?.[1]?.body)) + expect(body.payload).toEqual({ hook_event_name: 'agent_end' }) + }) + it('keeps native fetch as the only path even when the runtime looks like WSL', async () => { const harness = createHarness({ kind: 'omp', @@ -371,6 +440,130 @@ describe('getPiAgentStatusExtensionSource', () => { } }) + it('reports only agent_settled after multiple first-run agent_end events', async () => { + vi.useFakeTimers() + try { + const harness = createHarness({ kind: 'pi' }) + const context = { isIdle: vi.fn(() => false) } + + for (let index = 0; index < 3; index += 1) { + await harness.callHook('agent_end', undefined, context) + await vi.advanceTimersByTimeAsync(700) + } + expect(harness.fetchMock).not.toHaveBeenCalled() + + await harness.callHook('agent_settled') + await vi.advanceTimersByTimeAsync(0) + + expect(harness.fetchMock).toHaveBeenCalledTimes(1) + expect(JSON.parse(String(harness.fetchMock.mock.calls[0]?.[1]?.body)).payload).toEqual({ + hook_event_name: 'agent_end' + }) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('does not duplicate completion when idle is observed before agent_settled', async () => { + vi.useFakeTimers() + try { + const harness = createHarness({ kind: 'pi' }) + const context = { isIdle: vi.fn(() => true) } + + await harness.callHook('agent_end', undefined, context) + await vi.advanceTimersByTimeAsync(0) + expect(harness.fetchMock).toHaveBeenCalledTimes(1) + + await harness.callHook('agent_settled') + await vi.advanceTimersByTimeAsync(0) + + expect(harness.fetchMock).toHaveBeenCalledTimes(1) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('cancels an ambiguous agent_end when modern Pi resumes work', async () => { + vi.useFakeTimers() + try { + const harness = createHarness({ kind: 'pi' }) + const context = { isIdle: vi.fn(() => false) } + + await harness.callHook('agent_end', undefined, context) + await vi.advanceTimersByTimeAsync(100) + await harness.callHook('agent_start') + await harness.callHook('agent_end', undefined, context) + await vi.advanceTimersByTimeAsync(2_000) + await harness.callHook('agent_settled') + await vi.advanceTimersByTimeAsync(0) + + const events = harness.fetchMock.mock.calls.map( + (call) => JSON.parse(String(call[1]?.body)).payload.hook_event_name + ) + expect(events).toEqual(['agent_start', 'agent_end']) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('drops a pending legacy fallback when its context becomes stale on reload', async () => { + vi.useFakeTimers() + try { + const harness = createHarness({ kind: 'pi' }) + let active = true + const context = { + isIdle: vi.fn(() => { + if (!active) { + throw new Error('stale extension context') + } + return false + }) + } + + await harness.callHook('agent_end', undefined, context) + await vi.advanceTimersByTimeAsync(100) + active = false + harness.reload() + await vi.advanceTimersByTimeAsync(100) + + expect(harness.fetchMock).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('keeps reporting legacy Pi and OMP once their agent_end handlers settle', async () => { + vi.useFakeTimers() + try { + for (const kind of ['pi', 'omp'] as const) { + const harness = createHarness({ kind }) + let idle = false + const context = { isIdle: vi.fn(() => idle) } + + await harness.callHook('agent_end', undefined, context) + await vi.advanceTimersByTimeAsync(100) + expect(harness.fetchMock).not.toHaveBeenCalled() + + idle = true + await vi.advanceTimersByTimeAsync(100) + expect(harness.fetchMock).toHaveBeenCalledTimes(1) + } + } finally { + vi.useRealTimers() + } + }) + + it('keeps immediate agent_end fallback for runtimes without an idle context', async () => { + const harness = createHarness({ kind: 'omp' }) + + await harness.callHook('agent_end') + await vi.waitFor(() => expect(harness.fetchMock).toHaveBeenCalledTimes(1)) + }) + it('does not treat WSLENV alone as WSL evidence', async () => { const harness = createHarness({ kind: 'omp', diff --git a/src/main/pi/agent-status-handler-source.ts b/src/main/pi/agent-status-handler-source.ts index 625e89df7c0..38c4354ee13 100644 --- a/src/main/pi/agent-status-handler-source.ts +++ b/src/main/pi/agent-status-handler-source.ts @@ -29,12 +29,20 @@ export function getPiAgentStatusHandlerSourceLines(): string[] { '// etc.), so we forward the raw object verbatim under the same field', '// names Claude uses (tool_name / tool_input) and let the server pick the', '// preview. Keeps tool-name knowledge centralized on the receiver side.', + "// Why: child agents inherit the lead's pane env; only its process may", + '// register status hooks. PID identity keeps in-process reloads reporting.', 'export default function (pi): void {', + ' const ownerPid = process.env.ORCA_PI_STATUS_OWNED', + ' const selfPid = String(process.pid)', + ' if (ownerPid && ownerPid !== selfPid) return', + ' process.env.ORCA_PI_STATUS_OWNED = selfPid', " pi.on('before_agent_start', (event) => {", " post('before_agent_start', { prompt: event.prompt ?? '' })", ' })', '', " pi.on('agent_start', () => {", + ' clearPendingAgentEndCheck()', + ' agentEndReported = false', " post('agent_start')", ' })', '', @@ -69,8 +77,69 @@ export function getPiAgentStatusHandlerSourceLines(): string[] { " post('message_end', { role: 'assistant', text })", ' })', '', - " pi.on('agent_end', () => {", + ' // Why: modern Pi stays non-idle across retry/compaction/follow-up work,', + ' // while legacy Pi/OMP becomes idle after its final agent_end handlers.', + ' const AGENT_END_IDLE_RECHECK_MS = 25', + ' const AGENT_END_IDLE_RECHECK_MAX_MS = 250', + ' let agentSettledSupported = false', + ' let agentEndReported = false', + ' let agentEndIdleRecheckMs = AGENT_END_IDLE_RECHECK_MS', + ' let pendingAgentEndCheck: ReturnType | null = null', + ' let pendingAgentEndContext: { isIdle: () => boolean } | null = null', + '', + ' function clearPendingAgentEndCheck(): void {', + ' if (pendingAgentEndCheck !== null) clearTimeout(pendingAgentEndCheck)', + ' pendingAgentEndCheck = null', + ' pendingAgentEndContext = null', + ' }', + '', + ' // Why: isIdle flips before agent_settled handlers run, so both paths', + ' // share a per-run guard instead of racing duplicate completion posts.', + ' function postAgentEndOnce(): void {', + ' if (agentEndReported) return', + ' agentEndReported = true', " post('agent_end')", + ' }', + '', + ' function checkPendingAgentEnd(): void {', + ' pendingAgentEndCheck = null', + ' const ctx = pendingAgentEndContext', + ' if (!ctx || agentSettledSupported || agentEndReported) {', + ' pendingAgentEndContext = null', + ' return', + ' }', + ' try {', + ' if (ctx.isIdle()) {', + ' pendingAgentEndContext = null', + ' postAgentEndOnce()', + ' return', + ' }', + ' } catch {', + ' pendingAgentEndContext = null', + ' return', + ' }', + ' pendingAgentEndCheck = setTimeout(checkPendingAgentEnd, agentEndIdleRecheckMs)', + " if (typeof pendingAgentEndCheck.unref === 'function') pendingAgentEndCheck.unref()", + ' agentEndIdleRecheckMs = Math.min(agentEndIdleRecheckMs * 2, AGENT_END_IDLE_RECHECK_MAX_MS)', + ' }', + '', + " pi.on('agent_settled', () => {", + ' agentSettledSupported = true', + ' clearPendingAgentEndCheck()', + ' postAgentEndOnce()', + ' })', + '', + " pi.on('agent_end', (_event, ctx) => {", + ' if (agentSettledSupported) return', + " if (!ctx || typeof ctx.isIdle !== 'function') {", + ' postAgentEndOnce()', + ' return', + ' }', + ' clearPendingAgentEndCheck()', + ' agentEndIdleRecheckMs = AGENT_END_IDLE_RECHECK_MS', + ' pendingAgentEndContext = ctx', + ' pendingAgentEndCheck = setTimeout(checkPendingAgentEnd, 0)', + " if (typeof pendingAgentEndCheck.unref === 'function') pendingAgentEndCheck.unref()", ' })', '}', '' diff --git a/src/main/providers/git-provider-status-options.ts b/src/main/providers/git-provider-status-options.ts new file mode 100644 index 00000000000..3d03ca6a269 --- /dev/null +++ b/src/main/providers/git-provider-status-options.ts @@ -0,0 +1,7 @@ +// Kept separate so the central cross-provider contract stays within its enforced size limit. +export type GitProviderStatusOptions = { + includeIgnored?: boolean + bypassEffectiveUpstreamNegativeCache?: boolean + reuseLineStats?: boolean + signal?: AbortSignal +} diff --git a/src/main/providers/ssh-filesystem-provider.test.ts b/src/main/providers/ssh-filesystem-provider.test.ts index 13ab2846baa..55659b18c20 100644 --- a/src/main/providers/ssh-filesystem-provider.test.ts +++ b/src/main/providers/ssh-filesystem-provider.test.ts @@ -497,16 +497,18 @@ describe('SshFilesystemProvider', () => { expect(result).toEqual(['src/index.ts', 'package.json']) }) - it('listFiles forwards excludePaths when provided', async () => { + it('listFiles forwards supported list options', async () => { mux.request.mockResolvedValue([]) await provider.listFiles('/home/user/project', { - excludePaths: ['/home/user/project/worktrees/b'] + excludePaths: ['/home/user/project/worktrees/b'], + maxResults: 20_000 }) expect(mux.request).toHaveBeenCalledWith( 'fs.listFiles', { rootPath: '/home/user/project', - excludePaths: ['/home/user/project/worktrees/b'] + excludePaths: ['/home/user/project/worktrees/b'], + maxResults: 20_000 }, { signal: undefined } ) diff --git a/src/main/providers/ssh-filesystem-provider.ts b/src/main/providers/ssh-filesystem-provider.ts index 507bfd98fdb..b17b3c4f783 100644 --- a/src/main/providers/ssh-filesystem-provider.ts +++ b/src/main/providers/ssh-filesystem-provider.ts @@ -293,12 +293,15 @@ export class SshFilesystemProvider implements IFilesystemProvider { async listFiles( rootPath: string, - options?: { excludePaths?: string[]; signal?: AbortSignal } + options?: { excludePaths?: string[]; signal?: AbortSignal; maxResults?: number } ): Promise { const params: Record = { rootPath } if (options?.excludePaths && options.excludePaths.length > 0) { params.excludePaths = options.excludePaths } + if (options?.maxResults !== undefined) { + params.maxResults = options.maxResults + } // Why #7721: the signal lets a workspace switch send rpc.cancel so the // relay aborts the full-tree scan instead of stacking abandoned scans // that starve interactive fs.readDir/fs.stat on the shared SSH channel. diff --git a/src/main/providers/ssh-git-provider.test.ts b/src/main/providers/ssh-git-provider.test.ts index eb8383a3f60..d4a275a89ba 100644 --- a/src/main/providers/ssh-git-provider.test.ts +++ b/src/main/providers/ssh-git-provider.test.ts @@ -97,6 +97,22 @@ describe('SshGitProvider', () => { }) }) + it('getStatus forwards line-stat reuse and cancellation to the relay', async () => { + const controller = new AbortController() + mux.request.mockResolvedValue({ entries: [], conflictOperation: 'unknown' }) + + await provider.getStatus('/home/user/repo', { + reuseLineStats: true, + signal: controller.signal + }) + + expect(mux.request).toHaveBeenCalledWith( + 'git.status', + { worktreePath: '/home/user/repo', reuseLineStats: true }, + { signal: controller.signal } + ) + }) + it('getSubmoduleStatus sends git.submoduleStatus request', async () => { const statusResult = { entries: [], conflictOperation: 'unknown' } mux.request.mockResolvedValue(statusResult) diff --git a/src/main/providers/ssh-git-provider.ts b/src/main/providers/ssh-git-provider.ts index 4bca707a0fb..1aa1cd271a2 100644 --- a/src/main/providers/ssh-git-provider.ts +++ b/src/main/providers/ssh-git-provider.ts @@ -106,10 +106,12 @@ export class SshGitProvider implements IGitProvider { const upstreamCacheBypassArgs = options?.bypassEffectiveUpstreamNegativeCache ? { bypassEffectiveUpstreamNegativeCache: true } : {} + const lineStatsReuseArgs = options?.reuseLineStats ? { reuseLineStats: true } : {} const request = { worktreePath, ...includeIgnoredArgs, - ...upstreamCacheBypassArgs + ...upstreamCacheBypassArgs, + ...lineStatsReuseArgs } return (await (options?.signal ? this.mux.request('git.status', request, { signal: options.signal }) diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index dc38e108daa..6dc7b6ec1a3 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -23,6 +23,7 @@ import type { WorkspaceSpaceDirectoryScanResult } from '../../shared/workspace-s import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' import type { TerminalGitHubPRLink } from '../../shared/terminal-github-pr-link-detector' +import type { GitProviderStatusOptions } from './git-provider-status-options' // ─── PTY Provider ─────────────────────────────────────────────────── @@ -285,7 +286,7 @@ export type IFilesystemProvider = { search(opts: SearchOptions): Promise listFiles( rootPath: string, - options?: { excludePaths?: string[]; signal?: AbortSignal } + options?: { excludePaths?: string[]; signal?: AbortSignal; maxResults?: number } ): Promise scanWorkspaceSpace?( rootPath: string, @@ -316,11 +317,7 @@ export type TerminalArtifactAccessOptions = { // ─── Git Provider ─────────────────────────────────────────────────── -export type GitProviderStatusOptions = { - includeIgnored?: boolean - bypassEffectiveUpstreamNegativeCache?: boolean - signal?: AbortSignal -} +export type { GitProviderStatusOptions } from './git-provider-status-options' export type IGitProvider = { getStatus(worktreePath: string, options?: GitProviderStatusOptions): Promise diff --git a/src/main/rate-limits/grok-fetcher.test.ts b/src/main/rate-limits/grok-fetcher.test.ts index e387a13e3a1..b077b880576 100644 --- a/src/main/rate-limits/grok-fetcher.test.ts +++ b/src/main/rate-limits/grok-fetcher.test.ts @@ -241,6 +241,13 @@ describe('fetchGrokRateLimits', () => { const result = await fetchGrokRateLimits() expect(result.status).toBe('error') expect(result.error).toMatch(/expired/i) + expect(result.error).toMatch(/run grok on the computer running Orca/i) + expect(result.error).toMatch(/sign in if prompted/i) + expect(result.error).toMatch(/no chat message is needed/i) + expect(result.usageMetadata).toEqual({ + failureKind: 'delegated-refresh-required', + source: 'oauth' + }) // Why: a stored-but-expired access token is refreshed by Grok CLI on next // use (a genuine sign-out returns 'missing'), so the message must not tell // users to re-run `grok login` (#8497). diff --git a/src/main/rate-limits/grok-fetcher.ts b/src/main/rate-limits/grok-fetcher.ts index eaa140eb2be..1e6d20f5cce 100644 --- a/src/main/rate-limits/grok-fetcher.ts +++ b/src/main/rate-limits/grok-fetcher.ts @@ -1,5 +1,9 @@ import { net } from 'electron' -import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types' +import type { + ProviderRateLimits, + RateLimitWindow, + UsageRateLimitMetadata +} from '../../shared/rate-limit-types' import { isGrokAccessTokenFresh, readGrokAuthSession, @@ -47,14 +51,19 @@ type GrokBillingResponse = GrokBillingConfig & { config?: GrokBillingConfig } -function result(status: ProviderRateLimits['status'], error: string | null): ProviderRateLimits { +function result( + status: ProviderRateLimits['status'], + error: string | null, + usageMetadata?: UsageRateLimitMetadata +): ProviderRateLimits { return { provider: 'grok', session: null, weekly: null, updatedAt: Date.now(), error, - status + status, + ...(usageMetadata ? { usageMetadata } : {}) } } @@ -225,7 +234,11 @@ export async function fetchGrokRateLimits( // Why: a genuine sign-out returns 'missing' earlier, so reaching here always // means a stored, refreshable session — Grok CLI refreshes the access token // on its next run, so don't tell users to re-run `grok login` (#8497). - return result('error', 'Grok access token expired — Grok CLI will refresh it on next use') + return result( + 'error', + 'Grok sign-in expired — run grok on the computer running Orca; sign in if prompted. No chat message is needed.', + { failureKind: 'delegated-refresh-required', source: 'oauth' } + ) } try { diff --git a/src/main/rate-limits/kimi-fetcher.test.ts b/src/main/rate-limits/kimi-fetcher.test.ts index a6274097f5f..58f11dfdb03 100644 --- a/src/main/rate-limits/kimi-fetcher.test.ts +++ b/src/main/rate-limits/kimi-fetcher.test.ts @@ -140,6 +140,11 @@ describe('fetchKimiRateLimits', () => { const result = await fetchKimiRateLimits() expect(result.status).toBe('error') expect(result.error).toMatch(/expired/i) + expect(result.error).toMatch(/run kimi on the computer running Orca/i) + expect(result.usageMetadata).toEqual({ + failureKind: 'delegated-refresh-required', + source: 'oauth' + }) expect(netFetchMock).not.toHaveBeenCalled() }) }) diff --git a/src/main/rate-limits/kimi-fetcher.ts b/src/main/rate-limits/kimi-fetcher.ts index f957e171d88..778b90b796c 100644 --- a/src/main/rate-limits/kimi-fetcher.ts +++ b/src/main/rate-limits/kimi-fetcher.ts @@ -2,7 +2,11 @@ import { existsSync, readFileSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' import { net } from 'electron' -import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types' +import type { + ProviderRateLimits, + RateLimitWindow, + UsageRateLimitMetadata +} from '../../shared/rate-limit-types' // Why: Kimi Code's managed coding plan exposes subscription usage at // `${base}/usages` (see packages/oauth/src/managed-usage.ts in the CLI bundle). @@ -208,8 +212,20 @@ function mapUsageResponse(data: KimiUsageResponse): ProviderRateLimits { } } -function result(status: ProviderRateLimits['status'], error: string | null): ProviderRateLimits { - return { provider: 'kimi', session: null, weekly: null, updatedAt: Date.now(), error, status } +function result( + status: ProviderRateLimits['status'], + error: string | null, + usageMetadata?: UsageRateLimitMetadata +): ProviderRateLimits { + return { + provider: 'kimi', + session: null, + weekly: null, + updatedAt: Date.now(), + error, + status, + ...(usageMetadata ? { usageMetadata } : {}) + } } /** @@ -239,7 +255,11 @@ export async function fetchKimiRateLimits(): Promise { // Why: don't refresh — the CLI owns the token lifecycle. Report a transient // error so the rate-limit service keeps the last good snapshot (stale // policy) until the user next runs Kimi and the CLI refreshes the file. - return result('error', 'Kimi token expired — open Kimi to refresh') + return result( + 'error', + 'Kimi session expired — run kimi on the computer running Orca, then retry usage.', + { failureKind: 'delegated-refresh-required', source: 'oauth' } + ) } try { diff --git a/src/main/runtime/mobile-presence-lock.test.ts b/src/main/runtime/mobile-presence-lock.test.ts index 6213765bb20..b10a268ac79 100644 --- a/src/main/runtime/mobile-presence-lock.test.ts +++ b/src/main/runtime/mobile-presence-lock.test.ts @@ -193,6 +193,87 @@ describe('mobile presence lock — driver state machine', () => { expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) }) + it('restores the pre-write driver after overlapping claims from one phone both fail', async () => { + const { runtime } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + await runtime.reclaimTerminalForDesktop('pty-1') + + const first = runtime.beginMobileInputFloor('pty-1', 'phone-A')! + const second = runtime.beginMobileInputFloor('pty-1', 'phone-A')! + first.rollback() + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' }) + + second.rollback() + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'desktop' }) + }) + + it('keeps a successful overlapping claim as the rollback baseline', async () => { + const { runtime, ptySizes } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + await runtime.reclaimTerminalForDesktop('pty-1') + + const successful = runtime.beginMobileInputFloor('pty-1', 'phone-A')! + const rejected = runtime.beginMobileInputFloor('pty-1', 'phone-A')! + await successful.commit() + rejected.rollback() + + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' }) + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + }) + + it('does not let an older phone-fit completion retake a newer writer floor', async () => { + const { runtime } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + await runtime.handleMobileSubscribe('pty-1', 'phone-B', { cols: 38, rows: 18 }) + await runtime.reclaimTerminalForDesktop('pty-1') + + let releaseFirstLayout!: () => void + vi.spyOn(runtime, 'applyMobileDisplayMode').mockImplementationOnce( + () => + new Promise((resolve) => { + releaseFirstLayout = () => resolve(true) + }) + ) + const first = runtime.beginMobileInputFloor('pty-1', 'phone-A')! + const firstCommit = first.commit() + await vi.waitFor(() => expect(releaseFirstLayout).toBeTypeOf('function')) + + const second = runtime.beginMobileInputFloor('pty-1', 'phone-B')! + await second.commit() + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-B' }) + + releaseFirstLayout() + await firstCommit + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-B' }) + }) + + it('mobile input without an active subscriber cannot create an orphaned floor lock', async () => { + const { runtime } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + runtime.handleMobileUnsubscribe('pty-1', 'phone-A') + await vi.advanceTimersByTimeAsync(250) + + await runtime.mobileTookFloor('pty-1', 'phone-A') + + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'idle' }) + }) + + it('admits a soft-leaving client to reserve the input floor within the grace window', async () => { + const { runtime } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + runtime.handleMobileUnsubscribe('pty-1', 'phone-A') + + // Inside the soft-leave grace a late write still reserves and commits the floor. + const claim = runtime.beginMobileInputFloor('pty-1', 'phone-A') + expect(claim).not.toBeNull() + await claim!.commit() + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' }) + + // Past the grace the client is fully gone and is rejected. + await vi.advanceTimersByTimeAsync(250) + expect(runtime.beginMobileInputFloor('pty-1', 'phone-A')).toBeNull() + }) + it('handleMobileUnsubscribe last leaver flips driver to idle after soft-leave grace', async () => { const { runtime, driverEvents } = createRuntime() await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) diff --git a/src/main/runtime/orca-runtime-emulator.ts b/src/main/runtime/orca-runtime-emulator.ts index 31e4ad1d36a..9bda55ca899 100644 --- a/src/main/runtime/orca-runtime-emulator.ts +++ b/src/main/runtime/orca-runtime-emulator.ts @@ -7,7 +7,6 @@ import { } from '../emulator/emulator-availability' import { resolveDefaultAttachDevice } from '../emulator/emulator-default-attach-device' import { setConfiguredAndroidSdkPath } from '../emulator/android/android-sdk-host-discovery' -import { serveSimStateWatcher } from '../emulator/serve-sim-state-watcher' import type { EmulatorGesturePoint } from '../emulator/emulator-gesture-sender' import type { EmulatorSessionInfo } from '../emulator/emulator-types' import type { SimulatorDevice } from '../emulator/simctl-simulator-devices' @@ -148,7 +147,6 @@ export class RuntimeEmulatorCommands { if (reusable) { // Why: renderer remounts should reconnect to the existing stream, not // kill it and create the stream-disconnected reload loop users see. - serveSimStateWatcher.markOrcaManaged(reusable) this.notifyRendererEmulatorAutoAttach(worktreeId, reusable) if (params.focus) { this.notifyRendererEmulatorPaneFocus(worktreeId) @@ -157,15 +155,11 @@ export class RuntimeEmulatorCommands { } // A different requested device is an explicit switch; the bridge keeps a // slow-to-boot Android emulator alive for instant switch-back. - const stoppedUdid = await bridge.stopActiveForSwitch(worktreeId) - if (stoppedUdid) { - serveSimStateWatcher.unmarkOrcaManaged(stoppedUdid) - } + await bridge.stopActiveForSwitch(worktreeId) } const info = await bridge.startHelperForDevice(device) if (worktreeId) { bridge.registerActiveEmulator(worktreeId, info, { managed: true }) - serveSimStateWatcher.markOrcaManaged(info) this.notifyRendererEmulatorAutoAttach(worktreeId, info) if (params.focus) { this.notifyRendererEmulatorPaneFocus(worktreeId) @@ -281,7 +275,6 @@ export class RuntimeEmulatorCommands { const bridge = this.requireEmulatorBridge() const worktreeId = await this.resolveWorktreeId(params.worktree) const killedUdid = await bridge.kill(params.device ?? params.emulator, worktreeId) - serveSimStateWatcher.unmarkOrcaManaged(killedUdid) return { ok: true, deviceUdid: killedUdid } } @@ -295,13 +288,9 @@ export class RuntimeEmulatorCommands { const worktreeId = await this.resolveWorktreeId(params.worktree) if (params.managedOnly && worktreeId && !params.device && !params.emulator) { const shutdownUdid = await bridge.shutdownActiveManagedForWorktree(worktreeId) - if (shutdownUdid) { - serveSimStateWatcher.unmarkOrcaManaged(shutdownUdid) - } return { ok: true, deviceUdid: shutdownUdid ?? undefined } } const shutdownUdid = await bridge.shutdown(params.device ?? params.emulator, worktreeId) - serveSimStateWatcher.unmarkOrcaManaged(shutdownUdid) return { ok: true, deviceUdid: shutdownUdid } } diff --git a/src/main/runtime/orca-runtime-files.ts b/src/main/runtime/orca-runtime-files.ts index f261e98dbe3..5af3d0d22a3 100644 --- a/src/main/runtime/orca-runtime-files.ts +++ b/src/main/runtime/orca-runtime-files.ts @@ -81,9 +81,16 @@ import { } from '../ipc/parcel-watcher-process-failure' import { assertNoClobberRenameDestinationAvailable } from '../../shared/filesystem-rename-collision' import { joinWorktreeRelativePath, normalizeRuntimeRelativePath } from './runtime-relative-paths' +import { + rankRuntimeMobileFilePaths, + RuntimeMobileFilePathSearchCache +} from './runtime-mobile-file-path-search' import { beginWatcherInstall } from '../ipc/watcher-removal-gate' const MOBILE_FILE_LIST_LIMIT = 5000 +const MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT = 20_000 +const MOBILE_FILE_PATH_SEARCH_CACHE_ENTRIES = 8 +const MOBILE_FILE_PATH_SEARCH_CACHE_TTL_MS = 30_000 const MOBILE_FILE_READ_MAX_BYTES = 512 * 1024 const RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES = 10 * 1024 * 1024 const WINDOWS_RUNTIME_FILE_WATCH_DEBOUNCE_MS = 150 @@ -408,6 +415,10 @@ export type RuntimeFileCommandHost = { export class RuntimeFileCommands { private activeRuntimeTextSearches = new Map() private terminalFileGrants = new Map() + private mobileFilePathSearchCache = new RuntimeMobileFilePathSearchCache( + MOBILE_FILE_PATH_SEARCH_CACHE_ENTRIES, + MOBILE_FILE_PATH_SEARCH_CACHE_TTL_MS + ) constructor(private readonly host: RuntimeFileCommandHost) {} @@ -437,6 +448,52 @@ export class RuntimeFileCommands { } } + async searchMobileFilePaths( + worktreeSelector: string, + query: string, + limit: number + ): Promise { + const store = this.host.requireStore() + const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) + const { worktree, connectionId } = target + const cacheKey = `${connectionId ?? 'local'}:${worktree.id}:${worktree.path}` + const inventory = await this.mobileFilePathSearchCache.get(cacheKey, async () => { + const listed = connectionId + ? await this.listRemoteMobileFiles( + worktree.path, + connectionId, + MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT + 1 + ) + : await listQuickOpenFiles( + worktree.path, + store, + undefined, + undefined, + MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT + 1 + ) + const safePaths = listed + .filter((relativePath) => isSafeMobileRelativePath(relativePath)) + .sort((a, b) => a.localeCompare(b)) + return { + paths: safePaths.slice(0, MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT), + totalCount: safePaths.length, + truncated: safePaths.length > MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT + } + }) + const matches = rankRuntimeMobileFilePaths(inventory.paths, query, limit) + return { + worktree: worktree.id, + rootPath: worktree.path, + files: matches.paths.map((relativePath) => ({ + relativePath, + basename: basenameFromRelativePath(relativePath), + kind: isMobileBinaryPath(relativePath) ? ('binary' as const) : ('text' as const) + })), + totalCount: matches.totalCount, + truncated: inventory.truncated || matches.totalCount > limit + } + } + async openMobileFile( worktreeSelector: string, relativePath: string @@ -1730,12 +1787,16 @@ export class RuntimeFileCommands { } } - private async listRemoteMobileFiles(rootPath: string, connectionId: string): Promise { + private async listRemoteMobileFiles( + rootPath: string, + connectionId: string, + maxResults?: number + ): Promise { const provider = getSshFilesystemProvider(connectionId) if (!provider) { return [] } - return provider.listFiles(rootPath) + return provider.listFiles(rootPath, { maxResults }) } private async readRemoteMobileFile(filePath: string, connectionId: string): Promise { diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 2545980a8eb..15a59321409 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -5185,7 +5185,7 @@ describe('OrcaRuntimeService', () => { listGitHubLabelsMock.mockResolvedValueOnce([]) listGitHubAssignableUsersMock.mockResolvedValueOnce([]) - await runtime.listRepoWorkItems('id:repo-1', 7, 'is:open', 'cursor', true) + await runtime.listRepoWorkItems('id:repo-1', 7, 'is:open', 1, true) await runtime.countRepoWorkItems('id:repo-1', 'is:issue') await runtime.listRepoIssues('id:repo-1', 5) await runtime.getRepoIssue('id:repo-1', 12) @@ -5199,7 +5199,7 @@ describe('OrcaRuntimeService', () => { TEST_REPO_PATH, 7, 'is:open', - 'cursor', + 1, undefined, null, true, @@ -8178,6 +8178,28 @@ describe('OrcaRuntimeService', () => { ) }) + it('replaces suffix-only headless state with the recovered renderer snapshot', async () => { + const runtime = createRuntime() + syncSinglePty(runtime, 'pty-1') + runtime.seedHeadlessTerminal('pty-1', 'suffix-only redraw', { cols: 80, rows: 24 }) + + runtime.replaceHeadlessTerminalFromRendererSnapshotForRecovery('pty-1', { + data: 'restored history\r\nprompt $ ', + cols: 80, + rows: 24, + cwd: '/projects/restored' + }) + runtime.onPtyData('pty-1', 'after recovery\r\n', 100) + + const snapshot = await runtime.serializeMainTerminalBuffer('pty-1', { + scrollbackRows: 100 + }) + expect(snapshot?.data).toContain('restored history') + expect(snapshot?.data).toContain('after recovery') + expect(snapshot?.data).not.toContain('suffix-only redraw') + expect(snapshot?.cwd).toBe('/projects/restored') + }) + it('adopts OSC7 host metadata from seeded headless terminal scrollback', async () => { const runtime = createRuntime() syncSinglePty(runtime, 'pty-1') @@ -9630,6 +9652,8 @@ describe('OrcaRuntimeService', () => { it('keeps ordinary terminal send suffix failures on the existing not-writable contract', async () => { const writes: string[] = [] + const beforeWrite = vi.fn() + const afterWrite = vi.fn() const runtime = new OrcaRuntimeService(store) runtime.setPtyController({ spawn: vi.fn().mockResolvedValue({ id: 'pty-1' }), @@ -9665,9 +9689,15 @@ describe('OrcaRuntimeService', () => { const [terminal] = (await runtime.listTerminals()).terminals await expect( - runtime.sendTerminal(terminal.handle, { text: 'notes', enter: true }) + runtime.sendTerminal( + terminal.handle, + { text: 'notes', enter: true }, + { beforeWrite, afterWrite } + ) ).rejects.toThrow('terminal_not_writable') expect(writes).toEqual(['notes', '\r']) + expect(beforeWrite).toHaveBeenCalledTimes(2) + expect(afterWrite).toHaveBeenCalledOnce() }) it('creates visible terminal sessions without asking the renderer to focus a tab', async () => { @@ -18416,6 +18446,110 @@ describe('OrcaRuntimeService', () => { expect(kill).toHaveBeenCalledWith('laptop-created-pty') }) + it('waits for renderer acknowledgement before returning a whole-tab close receipt', async () => { + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal() + ) + const acknowledged = makeDeferred() + const closeTerminalTab = vi.fn(() => acknowledged.promise) + const runtime = new OrcaRuntimeService(runtimeStore as never) + runtime.setNotifier({ closeTerminal: vi.fn(), closeTerminalTab } as never) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [] + }) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'host-tab', + worktreeId: TEST_WORKTREE_ID, + title: 'Durable', + activeLeafId: HEADLESS_LEAF_ID, + layout: null + } + ], + leaves: [ + { + tabId: 'host-tab', + worktreeId: TEST_WORKTREE_ID, + leafId: HEADLESS_LEAF_ID, + paneRuntimeId: 1, + ptyId: 'persisted-pty' + } + ] + }) + const [terminal] = (await runtime.listTerminals()).terminals + const pending = runtime.closeTerminalTab(terminal.handle) + let settled = false + void pending.finally(() => { + settled = true + }) + + await vi.waitFor(() => expect(closeTerminalTab).toHaveBeenCalledWith('host-tab')) + expect(settled).toBe(false) + + acknowledged.resolve() + await expect(pending).resolves.toEqual({ + handle: terminal.handle, + tabId: 'host-tab', + closeMode: 'tab', + ptyKilled: false + }) + }) + + it('durably closes every split leaf without a renderer', async () => { + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + tabsByWorktree: { + [TEST_WORKTREE_ID]: [ + { + id: 'durable-tab', + ptyId: null, + worktreeId: TEST_WORKTREE_ID, + title: 'Durable', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + 'durable-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: undefined }) + } + }) + ) + const flushOrThrow = vi.fn() + const spawn = vi + .fn() + .mockResolvedValueOnce({ id: 'headless-left' }) + .mockResolvedValueOnce({ id: 'headless-right' }) + const kill = vi.fn(() => true) + const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow } as never) + runtime.setPtyController({ + spawn, + write: () => true, + kill, + getForegroundProcess: async () => null + }) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + const terminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'durable-tab', + leafId: HEADLESS_LEAF_ID + }) + await runtime.splitTerminal(terminal.handle, { direction: 'vertical' }) + + await runtime.closeTerminalTab(terminal.handle) + + expect(kill).toHaveBeenCalledWith('headless-left') + expect(kill).toHaveBeenCalledWith('headless-right') + expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toEqual([]) + expect(getSession().terminalLayoutsByTabId['durable-tab']).toBeUndefined() + expect(flushOrThrow).toHaveBeenCalledTimes(1) + }) + it('lists PTY-backed mobile session terminals without a renderer graph', async () => { const spawn = vi.fn().mockResolvedValue({ id: 'laptop-created-pty' }) const runtime = new OrcaRuntimeService(store) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 8e9be4cbb85..40bfc0e5691 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -153,6 +153,7 @@ import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resum import type { RuntimeClientEvent } from '../../shared/runtime-client-events' import { toRuntimeActivateWorktreeEvent } from '../../shared/runtime-client-events' import type { SshConnectionState } from '../../shared/ssh-types' +import { closeTerminalTabInWorkspaceSession } from '../../shared/workspace-session-terminal-tab-close' import type { LinearCurrentIssueContextHints, LinearAttachResult, @@ -348,7 +349,6 @@ import { buildHeadlessTabGroupSplit } from './headless-tab-group-split-layout' import { RuntimeEmulatorCommands, setEmulatorBridge } from './orca-runtime-emulator' -import { serveSimStateWatcher } from '../emulator/serve-sim-state-watcher' import type { EmulatorBridge } from '../emulator/emulator-bridge' import { RuntimeFileCommands } from './orca-runtime-files' import { RuntimeGitCommands } from './orca-runtime-git' @@ -403,7 +403,8 @@ import { addPRReviewComment, addPRReviewCommentReply, listLabels, - listAssignableUsers + listAssignableUsers, + type MainWorkItem } from '../github/client' import type { GitHubPRBranchLookupOptions } from '../github/client' import { resolveGitHubPrStartPoint } from '../github/pr-start-point' @@ -452,6 +453,7 @@ import type { GitLabMRInlineCommentInput, GitLabProjectRef, GitLabWorkItem, + ListWorkItemsResult, MRListState } from '../../shared/types' import { inspectSetupScriptImportCandidates } from '../../shared/setup-script-imports' @@ -848,6 +850,7 @@ type RuntimeStore = { getGitHubCache: Store['getGitHubCache'] getWorkspaceSession?: Store['getWorkspaceSession'] setWorkspaceSession?: Store['setWorkspaceSession'] + flushOrThrow?: Store['flushOrThrow'] persistPtyBinding?: Store['persistPtyBinding'] getUI?: Store['getUI'] updateUI?: Store['updateUI'] @@ -1234,7 +1237,7 @@ type RuntimePtyController = { serializeBuffer?( ptyId: string, opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } - ): Promise<{ data: string; cols: number; rows: number; lastTitle?: string } | null> + ): Promise<{ data: string; cols: number; rows: number; seq?: number; lastTitle?: string } | null> /** Authoritative provider-owned snapshot for restored PTYs with no mounted renderer. */ serializeProviderBuffer?( ptyId: string, @@ -1244,6 +1247,13 @@ type RuntimePtyController = { // hydration when no renderer is authoritative for this PTY. See // docs/mobile-prefer-renderer-scrollback.md. hasRendererSerializer?(ptyId: string): boolean + getRendererSerializerGeneration?(ptyId: string): number + waitForRendererSerializer?( + ptyId: string, + afterGeneration: number, + timeoutMs?: number, + signal?: AbortSignal + ): Promise getSize?(ptyId: string): { cols: number; rows: number } | null } @@ -1397,6 +1407,7 @@ type RuntimeNotifier = { content: string ): Promise closeTerminal(tabId: string, paneRuntimeId?: number): void + closeTerminalTab?(tabId: string): Promise sleepWorktree(worktreeId: string): void // Why: a phone opening a worktree wakes its slept agents by asking the host // renderer to run its own navigation-free wake (experimental agent sleep); @@ -2397,6 +2408,15 @@ export class OrcaRuntimeService { // `applyMobileDisplayMode` to pick the active phone-fit viewport. See // docs/mobile-presence-lock.md. private currentDriver = new Map() + private mobileInputFloorClaims = new Map< + string, + { + base: DriverState + generation: number + committedGeneration: number + pending: Map + } + >() private currentBrowserDriver = new Map() // Why: remote (relay/shared-control) desktop viewers of a PTY are keyed by @@ -3299,7 +3319,13 @@ export class OrcaRuntimeService { } if (existing && (existing.ptyId !== ptyId || existing.ptyGeneration !== ptyGeneration)) { - this.invalidateLeafHandle(leafKey) + // Why: mobile can subscribe while the pane is waiting for its first PTY. + // Keep that handle usable after the recovery mount binds it. + const adoptedFirstPty = + existing.ptyId === null && this.adoptFirstPtyForLeafHandle(leafKey, ptyId, ptyGeneration) + if (!adoptedFirstPty) { + this.invalidateLeafHandle(leafKey) + } } } @@ -4295,33 +4321,20 @@ export class OrcaRuntimeService { } } - private removePersistedHeadlessTerminalTab(worktreeId: string, parentTabId: string): void { + private removePersistedHeadlessTerminalTab(worktreeId: string, parentTabId: string): string[] { const session = this.store?.getWorkspaceSession?.() if (!session || !this.store?.setWorkspaceSession) { - return + throw new Error('workspace_session_unavailable') } - const tabs = session.tabsByWorktree[worktreeId] ?? [] - const nextTabs = tabs.filter((tab) => tab.id !== parentTabId) - const nextTabsByWorktree = { - ...session.tabsByWorktree, - [worktreeId]: nextTabs + const result = closeTerminalTabInWorkspaceSession(session, worktreeId, parentTabId) + if (result.pinned) { + throw new Error('terminal_tab_pinned') } - const nextLayouts = { ...session.terminalLayoutsByTabId } - delete nextLayouts[parentTabId] - const nextActiveTabId = - session.activeTabIdByWorktree?.[worktreeId] === parentTabId - ? (nextTabs[0]?.id ?? null) - : (session.activeTabIdByWorktree?.[worktreeId] ?? null) - this.store.setWorkspaceSession({ - ...session, - activeTabId: session.activeTabId === parentTabId ? nextActiveTabId : session.activeTabId, - tabsByWorktree: nextTabsByWorktree, - terminalLayoutsByTabId: nextLayouts, - activeTabIdByWorktree: { - ...session.activeTabIdByWorktree, - [worktreeId]: nextActiveTabId - } - }) + if (!result.closed) { + throw new Error('tab_not_found') + } + this.store.setWorkspaceSession(result.session) + return result.ptyIdsToKill } private persistHeadlessTerminalTabOrder(worktreeId: string, tabOrder: readonly string[]): void { @@ -4668,8 +4681,19 @@ export class OrcaRuntimeService { throw new Error('tab_not_found') } if (tab.type === 'terminal') { + const parentLeafCount = snapshot!.tabs.filter( + (candidate) => candidate.type === 'terminal' && candidate.parentTabId === tab.parentTabId + ).length + const closingWholeParent = tab.id !== tabId || parentLeafCount <= 1 + if (closingWholeParent && this.notifier?.closeTerminalTab) { + // Why: whole-tab close is a lifecycle transaction. The renderer reply + // arrives only after canonical retirement and a forced session flush. + await this.notifier.closeTerminalTab(tab.parentTabId) + return { closed: true } + } if (!this.notifier?.closeTerminal) { this.closeHeadlessMobileTerminalTab(worktreeId, snapshot!, tab) + this.store?.flushOrThrow?.() return { closed: true } } // Why: a runtime-owned headless tab whose whole parent is being closed must @@ -4678,13 +4702,10 @@ export class OrcaRuntimeService { // keeps republishing the "closed" tab with a live PTY. Best-effort notify the // renderer too so any adopted pane closes (no dead pane). A single split leaf // (exact id, multi-leaf parent) keeps the per-leaf path so siblings survive. - const parentLeafCount = snapshot!.tabs.filter( - (candidate) => candidate.type === 'terminal' && candidate.parentTabId === tab.parentTabId - ).length - const closingWholeParent = tab.id !== tabId || parentLeafCount <= 1 if (closingWholeParent && this.isRuntimeOwnedHeadlessMobileTab(worktreeId, tab)) { this.closeHeadlessMobileTerminalTab(worktreeId, snapshot!, tab) this.notifier?.closeTerminal(tab.parentTabId) + this.store?.flushOrThrow?.() return { closed: true } } if (tab.id === tabId) { @@ -4804,24 +4825,39 @@ export class OrcaRuntimeService { tab: RuntimeMobileSessionTerminalTab ): void { const closedParentTabId = tab.parentTabId + const projectedPtyIds = this.removePersistedHeadlessTerminalTab(worktreeId, closedParentTabId) + // Why: local provider ids can be reused after restart, so a dormant + // persisted id is not kill authority. SSH relay ids remain durable exact + // identities even before pane metadata reconnects. + const ptyIdsToKill = new Set(projectedPtyIds.filter((ptyId) => parseAppSshPtyId(ptyId))) + for (const candidate of snapshot.tabs) { + if (candidate.type !== 'terminal' || candidate.parentTabId !== closedParentTabId) { + continue + } + const livePty = this.findPtyForMobileTerminalTab(worktreeId, candidate) + const ptyId = livePty?.ptyId ?? candidate.ptyId + const hasOtherOwner = snapshot.tabs.some( + (other) => + other.type === 'terminal' && + other.parentTabId !== closedParentTabId && + other.ptyId === ptyId + ) + if (ptyId && !hasOtherOwner && (livePty || parseAppSshPtyId(ptyId))) { + // Why: a live serve leaf can exist before its debounced binding reaches + // persistence. Include it from the authoritative snapshot so split + // close cannot leave a provider process behind. + ptyIdsToKill.add(ptyId) + } + } + for (const ptyId of ptyIdsToKill) { + this.ptyController?.kill(ptyId) + } const nextTabs = snapshot.tabs.filter((candidate) => { if (candidate.type !== 'terminal' || candidate.parentTabId !== closedParentTabId) { return true } - const pty = this.findPtyForMobileTerminalTab(worktreeId, candidate) - if (pty?.connected) { - this.ptyController?.kill(pty.ptyId) - } else { - const persistedSshPtyId = this.getPersistedSshPtyIdForMobileTerminalTab(candidate) - if (persistedSshPtyId) { - // Why: close is an explicit deletion. Hydrated SSH PTYs can be known - // only by durable id before reconnect repopulates pane metadata. - this.ptyController?.kill(persistedSshPtyId) - } - } return false }) - this.removePersistedHeadlessTerminalTab(worktreeId, closedParentTabId) const active = nextTabs.find((candidate) => candidate.isActive) ?? nextTabs[0] ?? null const nextSnapshot: RuntimeMobileSessionTabsSnapshot = { ...snapshot, @@ -5438,6 +5474,8 @@ export class OrcaRuntimeService { listMobileFiles: RuntimeFileCommands['listMobileFiles'] = this.fileCommands.listMobileFiles.bind( this.fileCommands ) + searchMobileFilePaths: RuntimeFileCommands['searchMobileFilePaths'] = + this.fileCommands.searchMobileFilePaths.bind(this.fileCommands) openMobileFile: RuntimeFileCommands['openMobileFile'] = this.fileCommands.openMobileFile.bind( this.fileCommands ) @@ -5843,7 +5881,6 @@ export class OrcaRuntimeService { // `Network: https://local.example.com:3001/`) so the workspace ports // panel can surface them in place of the kernel bind address. advertisedUrlWatcher.ingest(ptyId, data, at) - serveSimStateWatcher.ingestPtyOutput(ptyId, data) // Why: reply ownership is captured per chunk, here at ingestion — the // same module state and tick as the hidden-gate drop sites — and rides // the writeChain link. A mark/setting/subscriber flip before the queued @@ -6958,9 +6995,9 @@ export class OrcaRuntimeService { data: string cols: number rows: number + seq?: number cwd?: string | null lastTitle?: string - seq?: number source?: 'headless' | 'renderer' oscLinks?: TerminalOscLinkRange[] alternateScreen?: boolean @@ -6970,6 +7007,10 @@ export class OrcaRuntimeService { return this.serializeTerminalBufferFromAvailableState(ptyId, opts) } + hasHeadlessTerminalState(ptyId: string): boolean { + return this.headlessTerminals.has(ptyId) + } + serializeMainTerminalBuffer( ptyId: string, opts: { scrollbackRows?: number } = {} @@ -6977,9 +7018,9 @@ export class OrcaRuntimeService { data: string cols: number rows: number + seq?: number cwd?: string | null lastTitle?: string - seq?: number source?: 'headless' | 'renderer' oscLinks?: TerminalOscLinkRange[] alternateScreen?: boolean @@ -7415,13 +7456,14 @@ export class OrcaRuntimeService { : rendererSnapshot } - private async serializeRendererTerminalBuffer( + async serializeRendererTerminalBuffer( ptyId: string, opts: { scrollbackRows?: number } = {} ): Promise<{ data: string cols: number rows: number + seq?: number cwd?: string | null lastTitle?: string source?: 'renderer' @@ -7434,6 +7476,7 @@ export class OrcaRuntimeService { data: string cols: number rows: number + seq?: number cwd?: string | null lastTitle?: string oscLinks?: TerminalOscLinkRange[] @@ -8660,7 +8703,6 @@ export class OrcaRuntimeService { onPtyExit(ptyId: string, exitCode: number): void { advertisedUrlWatcher.unbindPty(ptyId) - serveSimStateWatcher.unbindPty(ptyId) // Clean up new mobile state for this PTY this.mobileSubscribers.delete(ptyId) this.remoteTerminalViewSubscriberCounts.delete(ptyId) @@ -9111,18 +9153,108 @@ export class OrcaRuntimeService { this.setDriver(ptyId, { kind: 'mobile', clientId }) } + beginMobileInputFloor( + ptyId: string, + clientId: string + ): { commit: () => Promise; rollback: () => void } | null { + // Why: admit a client still inside its soft-leave grace (mirrors + // mobileTookFloor) so a write landing in that window reserves the floor + // instead of being dropped; post-grace/orphaned writers stay rejected. + const softLeaver = this.pendingSoftLeavers.get(ptyId) + if (!this.mobileSubscribers.get(ptyId)?.has(clientId) && softLeaver?.clientId !== clientId) { + return null + } + const state = this.mobileInputFloorClaims.get(ptyId) ?? { + base: this.getDriver(ptyId), + generation: 0, + committedGeneration: 0, + pending: new Map() + } + this.mobileInputFloorClaims.set(ptyId, state) + const token = Symbol('mobile-input-floor') + const generation = ++state.generation + state.pending.set(token, { clientId, generation }) + this.setDriver(ptyId, { kind: 'mobile', clientId }) + let settled = false + return { + commit: async () => { + if (settled) { + return + } + settled = true + state.pending.delete(token) + // Why: a newer accepted write owns the floor; an older claim that was + // delayed before commit must not replace its rollback baseline or driver. + if (generation < state.committedGeneration) { + if (state.pending.size === 0 && this.mobileInputFloorClaims.get(ptyId) === state) { + this.mobileInputFloorClaims.delete(ptyId) + } + return + } + const previousFloor = state.base + // Why: a successful write becomes the rollback baseline for any + // overlapping reservations that have not reached the PTY yet. + state.committedGeneration = generation + state.base = { kind: 'mobile', clientId } + await this.mobileTookFloor( + ptyId, + clientId, + previousFloor, + () => + this.mobileInputFloorClaims.get(ptyId) === state && + state.committedGeneration === generation + ) + if (state.pending.size === 0 && this.mobileInputFloorClaims.get(ptyId) === state) { + this.mobileInputFloorClaims.delete(ptyId) + } + }, + rollback: () => { + if (settled) { + return + } + settled = true + state.pending.delete(token) + if (this.mobileInputFloorClaims.get(ptyId) !== state) { + return + } + const current = this.getDriver(ptyId) + if (current.kind === 'mobile' && current.clientId === clientId) { + const pendingClientId = Array.from(state.pending.values()).at(-1)?.clientId + this.setDriver( + ptyId, + pendingClientId ? { kind: 'mobile', clientId: pendingClientId } : state.base + ) + } + if (state.pending.size === 0) { + this.mobileInputFloorClaims.delete(ptyId) + } + } + } + } + // Why: invoked from mobile RPC method handlers (terminal.send / setDisplayMode / // resizeForClient / fresh subscribe with auto). Records the actor as the // most recent mobile driver and re-applies phone-fit if we were previously // in `desktop` mode (mobile reclaims a take-back). Mobile-to-mobile hand-offs // are no-ops for resize. - async mobileTookFloor(ptyId: string, clientId: string): Promise { + async mobileTookFloor( + ptyId: string, + clientId: string, + previousFloor?: DriverState, + isCurrent: () => boolean = () => true + ): Promise { const inner = this.mobileSubscribers.get(ptyId) const sub = inner?.get(clientId) + const softLeaver = this.pendingSoftLeavers.get(ptyId) + // Why: native chat pauses terminal output, so its later sends have no + // subscriber lifecycle that could release a newly-created desktop lock. + if (!sub && softLeaver?.clientId !== clientId) { + return + } if (sub) { sub.lastActedAt = Date.now() } - const prev = this.getDriver(ptyId) + const prev = previousFloor ?? this.getDriver(ptyId) const currentMode = this.mobileDisplayModes.get(ptyId) // Why: a deliberate mobile action implies mobile is resuming control. // If the display mode is currently 'desktop' (set by an earlier @@ -9134,6 +9266,11 @@ export class OrcaRuntimeService { } await this.applyMobileDisplayMode(ptyId) } + // Why: display changes are async; a later PTY write must keep the floor + // when an older phone-fit operation eventually completes. + if (!isCurrent()) { + return + } this.setDriver(ptyId, { kind: 'mobile', clientId }) } @@ -10785,6 +10922,8 @@ export class OrcaRuntimeService { }, options: { beforeWrite?: (ptyId: string) => void | Promise + reserveWrite?: (ptyId: string) => void + afterWrite?: (ptyId: string) => void | Promise suffixFailureError?: string } = {} ): Promise { @@ -11197,6 +11336,8 @@ export class OrcaRuntimeService { payload: string, options: { beforeWrite?: (ptyId: string) => void | Promise + reserveWrite?: (ptyId: string) => void + afterWrite?: (ptyId: string) => void | Promise suffixFailureError?: string } = {} ): Promise { @@ -11214,6 +11355,7 @@ export class OrcaRuntimeService { } try { await options.beforeWrite?.(ptyId) + options.reserveWrite?.(ptyId) } catch (error) { if (options.suffixFailureError) { throw new Error(options.suffixFailureError) @@ -11224,6 +11366,7 @@ export class OrcaRuntimeService { if (!suffixWrote) { throw new Error(options.suffixFailureError ?? 'terminal_not_writable') } + await options.afterWrite?.(ptyId) return } if (hasText) { @@ -11231,10 +11374,12 @@ export class OrcaRuntimeService { } await options.beforeWrite?.(ptyId) + options.reserveWrite?.(ptyId) const wrote = this.ptyController?.write(ptyId, payload) ?? false if (!wrote) { throw new Error('terminal_not_writable') } + await options.afterWrite?.(ptyId) } private async writeTerminalInputChunks( @@ -11242,16 +11387,20 @@ export class OrcaRuntimeService { text: string, options: { beforeWrite?: (ptyId: string) => void | Promise + reserveWrite?: (ptyId: string) => void + afterWrite?: (ptyId: string) => void | Promise } = {} ): Promise { const chunks = iterateTerminalInputChunks(text) let chunk = chunks.next() while (!chunk.done) { await options.beforeWrite?.(ptyId) + options.reserveWrite?.(ptyId) const wrote = this.ptyController?.write(ptyId, chunk.value) ?? false if (!wrote) { throw new Error('terminal_not_writable') } + await options.afterWrite?.(ptyId) chunk = chunks.next() if (!chunk.done) { await new Promise((resolve) => setTimeout(resolve, 0)) @@ -13185,15 +13334,15 @@ export class OrcaRuntimeService { repoSelector: string, limit?: number, query?: string, - before?: string, + page?: number, noCache?: boolean - ): Promise>> { + ): Promise> { const repo = await this.resolveRepoSelector(repoSelector) return listWorkItems( repo.path, limit, query, - before, + page, repo.issueSourcePreference, repo.connectionId ?? null, noCache, @@ -17456,7 +17605,6 @@ export class OrcaRuntimeService { // purge history and process-local caches before the ID points at new state. store.removeWorktreeMeta(worktreeId) advertisedUrlWatcher.forgetWorktree(worktreeId) - serveSimStateWatcher.forgetWorktree(worktreeId) deleteWorktreeHistoryDir(worktreeId) this.closeHeadlessBrowserPagesForWorktree(worktreeId) } @@ -19217,6 +19365,85 @@ export class OrcaRuntimeService { }) } + // Why: never-mounted tabs have no attached PTY or mobile snapshot; synthetic + // handles need the ptyId so the renderer can mount the exact owning tab. + requestRendererTerminalTabMount(handle: string): boolean { + const record = this.handles.get(handle) + if (!record?.worktreeId) { + return false + } + const tabId = record.tabId.startsWith('pty:') ? undefined : record.tabId + const ptyId = record.ptyId ?? undefined + if (!tabId && !ptyId) { + return false + } + try { + this.getAuthoritativeWindow().webContents.send('terminal:requestTabMount', { + worktreeId: record.worktreeId, + ...(tabId ? { tabId } : {}), + ...(ptyId ? { ptyId } : {}) + }) + return true + } catch { + // No authoritative window (shutdown/headless): the subscribe keeps its + // existing empty-snapshot fallback. + return false + } + } + + getRendererTerminalSerializerGeneration(ptyId: string): number { + return this.ptyController?.getRendererSerializerGeneration?.(ptyId) ?? 0 + } + + getRendererTerminalSerializerGenerationForHandle(handle: string): number { + const ptyId = this.handles.get(handle)?.ptyId + return ptyId ? this.getRendererTerminalSerializerGeneration(ptyId) : 0 + } + + replaceHeadlessTerminalFromRendererSnapshotForRecovery( + ptyId: string, + snapshot: { + data: string + cols: number + rows: number + cwd?: string | null + oscLinks?: TerminalOscLinkRange[] + }, + trailingOutput: { data: string; seq: number }[] = [] + ): void { + if (!snapshot.data) { + return + } + // Why: a redraw byte can create a suffix-only model before the restored + // renderer settles. Replace it with the exact snapshot already sent mobile. + this.providerSnapshotPreferredPtys.add(ptyId) + this.disposeHeadlessTerminal(ptyId) + this.seedHeadlessTerminal( + ptyId, + snapshot.data, + { cols: snapshot.cols, rows: snapshot.rows }, + { cwd: snapshot.cwd, oscLinks: snapshot.oscLinks } + ) + for (const chunk of trailingOutput) { + this.trackHeadlessTerminalData(ptyId, chunk.data, chunk.seq) + } + // The seed's write chain already owns subsequent live bytes; suppress the + // ordinary on-data hydration path from replacing this known-good seed. + this.headlessHydrationState.set(ptyId, 'done') + } + + waitForRendererTerminalSerializer( + ptyId: string, + afterGeneration: number, + timeoutMs?: number, + signal?: AbortSignal + ): Promise { + return ( + this.ptyController?.waitForRendererSerializer?.(ptyId, afterGeneration, timeoutMs, signal) ?? + Promise.resolve(false) + ) + } + // Why: a leaf appears in the graph before its PTY spawns. If we issue a // handle while ptyId is null, the next graph sync after PTY spawn will // change ptyId and invalidate the handle. Wait for a connected PTY so @@ -19297,6 +19524,24 @@ export class OrcaRuntimeService { return { handle, tabId: leaf.tabId, ptyKilled } } + async closeTerminalTab(handle: string): Promise { + const pty = this.getLivePtyForHandle(handle) + if (pty) { + const tabId = pty.pty.tabId + if (!tabId) { + throw new Error('terminal_tab_not_found') + } + await this.closeMobileSessionTab(`id:${pty.pty.worktreeId}`, tabId) + this.claudeAgentTeams.removeTeamForLeaderHandle(handle) + return { handle, tabId, closeMode: 'tab', ptyKilled: false } + } + this.assertGraphReady() + const { leaf } = this.getLiveLeafForHandle(handle) + await this.closeMobileSessionTab(`id:${leaf.worktreeId}`, leaf.tabId) + this.claudeAgentTeams.removeTeamForLeaderHandle(handle) + return { handle, tabId: leaf.tabId, closeMode: 'tab', ptyKilled: false } + } + async splitTerminal( handle: string, opts: { @@ -20835,7 +21080,6 @@ export class OrcaRuntimeService { // Why: restored/controller-discovered PTYs learn their worktree here // without registerPty(), so URL enrichment must bind at this source. advertisedUrlWatcher.bindPty(ptyId, worktreeId) - serveSimStateWatcher.bindPty(ptyId, worktreeId) return pty } @@ -20871,7 +21115,6 @@ export class OrcaRuntimeService { // Why: recordPtyWorktree is the common lifecycle point for every path that // resolves a PTY's worktree, including renderer restore and controller list. advertisedUrlWatcher.bindPty(ptyId, worktreeId) - serveSimStateWatcher.bindPty(ptyId, worktreeId) return pty } @@ -20984,8 +21227,6 @@ export class OrcaRuntimeService { } private dropDisconnectedPtyRecord(ptyId: string): void { - // Why: pruning can remove a PTY without the normal exit callback. - serveSimStateWatcher.unbindPty(ptyId) this.ptysById.delete(ptyId) this.recentPtyOutputById.delete(ptyId) this.clearWaitBlockedCheckState(ptyId) @@ -21807,13 +22048,6 @@ export class OrcaRuntimeService { return null } - private getPersistedSshPtyIdForMobileTerminalTab( - tab: RuntimeMobileSessionTerminalTab - ): string | null { - const ptyId = tab.ptyId ?? tab.parentLayout?.ptyIdsByLeafId?.[tab.leafId] ?? null - return ptyId && parseAppSshPtyId(ptyId) ? ptyId : null - } - private getMobileTerminalPaneKey(tab: RuntimeMobileSessionTerminalTab): string { if (isTerminalLeafId(tab.leafId)) { return makePaneKey(tab.parentTabId, tab.leafId) @@ -22515,6 +22749,20 @@ export class OrcaRuntimeService { this.rejectWaitersForHandle(handle, 'terminal_handle_stale') } + private adoptFirstPtyForLeafHandle( + leafKey: string, + ptyId: string | null, + ptyGeneration: number + ): boolean { + const handle = this.handleByLeafKey.get(leafKey) + const record = handle ? this.handles.get(handle) : null + if (!handle || !record || record.ptyId !== null || ptyId === null) { + return false + } + this.handles.set(handle, { ...record, ptyId, ptyGeneration }) + return true + } + private rememberDetachedPreAllocatedLeaves(): void { for (const leaf of this.leaves.values()) { if (leaf.ptyId && this.handleByPtyId.has(leaf.ptyId)) { @@ -25470,18 +25718,6 @@ export class OrcaRuntimeService { emulatorUnregisterActive: RuntimeEmulatorCommands['emulatorUnregisterActive'] = this.emulatorCommands.emulatorUnregisterActive.bind(this.emulatorCommands) - // Why: serve-sim-state-watcher runs from main/index.ts startup; keep window IPC behind runtime (getAuthoritativeWindow is private). - notifyEmulatorAutoAttachFromWatcher( - worktreeId: string, - info: { deviceUdid: string; streamUrl: string; wsUrl: string; axUrl?: string } - ): void { - try { - this.getAuthoritativeWindow().webContents.send('ui:emulatorAutoAttach', { worktreeId, info }) - } catch { - // Window may not exist during shutdown - } - } - private getAuthoritativeWindow(): BrowserWindow { const win = this.getAvailableAuthoritativeWindow() if (!win || win.isDestroyed()) { diff --git a/src/main/runtime/rpc/errors.test.ts b/src/main/runtime/rpc/errors.test.ts index a14dc809c54..b1670590a9c 100644 --- a/src/main/runtime/rpc/errors.test.ts +++ b/src/main/runtime/rpc/errors.test.ts @@ -9,6 +9,16 @@ class LineageError extends Error { } describe('mapRuntimeError', () => { + it.each(['terminal_tab_close_timeout', 'terminal_tab_not_found', 'terminal_tab_pinned'])( + 'preserves the durable terminal tab close failure %s', + (code) => { + expect(mapRuntimeError('req_1', { runtimeId: 'runtime-1' }, new Error(code))).toMatchObject({ + ok: false, + error: { code, message: code } + }) + } + ) + it.each([ ['window_not_focused', 'keyboard input requires focus', 'restore-window'], ['permission_denied', 'missing DBUS_SESSION_BUS_ADDRESS', 'permissions'], diff --git a/src/main/runtime/rpc/errors.ts b/src/main/runtime/rpc/errors.ts index bb800b54617..15c592499f6 100644 --- a/src/main/runtime/rpc/errors.ts +++ b/src/main/runtime/rpc/errors.ts @@ -43,6 +43,9 @@ const RUNTIME_PASSTHROUGH_CODES: ReadonlySet = new Set([ 'terminal_not_writable', 'terminal_exited', 'terminal_gone', + 'terminal_tab_close_timeout', + 'terminal_tab_not_found', + 'terminal_tab_pinned', 'no_active_terminal', 'repo_not_found', 'timeout', diff --git a/src/main/runtime/rpc/methods/files-path-search.test.ts b/src/main/runtime/rpc/methods/files-path-search.test.ts new file mode 100644 index 00000000000..cd47aa8fd7c --- /dev/null +++ b/src/main/runtime/rpc/methods/files-path-search.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it, vi } from 'vitest' +import type { OrcaRuntimeService } from '../../orca-runtime' +import type { RpcRequest } from '../core' +import { RpcDispatcher } from '../dispatcher' +import { FILE_METHODS } from './files' + +describe('file path search RPC method', () => { + it('returns a bounded server-side result for mobile autocomplete', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + searchMobileFilePaths: vi.fn().mockResolvedValue({ + worktree: 'wt-1', + rootPath: '/repo', + files: [{ relativePath: 'src/app.ts', basename: 'app.ts', kind: 'text' }], + totalCount: 1, + truncated: false + }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: FILE_METHODS }) + const request: RpcRequest = { + id: 'req-1', + authToken: 'tok', + method: 'files.searchPaths', + params: { worktree: 'id:wt-1', query: 'app', limit: 8 } + } + + const response = await dispatcher.dispatch(request) + + expect(runtime.searchMobileFilePaths).toHaveBeenCalledWith('id:wt-1', 'app', 8) + expect(response).toMatchObject({ + ok: true, + result: { files: [{ relativePath: 'src/app.ts' }] } + }) + }) +}) diff --git a/src/main/runtime/rpc/methods/files.ts b/src/main/runtime/rpc/methods/files.ts index a46d24eb1b1..b9af390152b 100644 --- a/src/main/runtime/rpc/methods/files.ts +++ b/src/main/runtime/rpc/methods/files.ts @@ -19,6 +19,11 @@ const WorktreeSelector = z.object({ .pipe(z.string().min(1, 'Missing worktree selector')) }) +const FilePathSearch = WorktreeSelector.extend({ + query: z.string().max(256).default(''), + limit: z.number().int().positive().max(32).default(16) +}) + const FileOpen = WorktreeSelector.extend({ relativePath: z .unknown() @@ -176,6 +181,12 @@ export const FILE_METHODS: RpcAnyMethod[] = [ params: WorktreeSelector, handler: async (params, { runtime }) => runtime.listMobileFiles(params.worktree) }), + defineMethod({ + name: 'files.searchPaths', + params: FilePathSearch, + handler: async (params, { runtime }) => + runtime.searchMobileFilePaths(params.worktree, params.query, params.limit) + }), defineMethod({ name: 'files.open', params: FileOpen, diff --git a/src/main/runtime/rpc/methods/git-params.ts b/src/main/runtime/rpc/methods/git-params.ts index 8f9d511dfca..7e13e584c42 100644 --- a/src/main/runtime/rpc/methods/git-params.ts +++ b/src/main/runtime/rpc/methods/git-params.ts @@ -9,7 +9,8 @@ export const WorktreeSelector = z.object({ export const GitStatusParams = WorktreeSelector.extend({ includeIgnored: z.boolean().optional(), - bypassEffectiveUpstreamNegativeCache: z.boolean().optional() + bypassEffectiveUpstreamNegativeCache: z.boolean().optional(), + reuseLineStats: z.boolean().optional() }) export const GitCheckIgnored = WorktreeSelector.extend({ diff --git a/src/main/runtime/rpc/methods/git.test.ts b/src/main/runtime/rpc/methods/git.test.ts index bb69dfc5958..1655bb0b3d5 100644 --- a/src/main/runtime/rpc/methods/git.test.ts +++ b/src/main/runtime/rpc/methods/git.test.ts @@ -80,6 +80,28 @@ describe('git RPC methods', () => { }) }) + it('forwards line-stat reuse and request cancellation for status requests', async () => { + const controller = new AbortController() + const runtime = { + getRuntimeId: () => 'test-runtime', + getRuntimeGitStatus: vi.fn().mockResolvedValue({ + entries: [], + conflictOperation: 'unknown' + }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS }) + + await dispatcher.dispatch( + makeRequest('git.status', { worktree: 'id:wt-1', reuseLineStats: true }), + { signal: controller.signal } + ) + + expect(runtime.getRuntimeGitStatus).toHaveBeenCalledWith('id:wt-1', { + reuseLineStats: true, + signal: controller.signal + }) + }) + it('returns ignored paths for selected explorer rows', async () => { const runtime = { getRuntimeId: () => 'test-runtime', diff --git a/src/main/runtime/rpc/methods/git.ts b/src/main/runtime/rpc/methods/git.ts index 1e4923f672a..c4ccdf071b6 100644 --- a/src/main/runtime/rpc/methods/git.ts +++ b/src/main/runtime/rpc/methods/git.ts @@ -88,10 +88,12 @@ export const GIT_METHODS: RpcMethod[] = [ defineMethod({ name: 'git.status', params: GitStatusParams, - handler: async (params, { runtime }) => { + handler: async (params, { runtime, signal }) => { const options = params.includeIgnored === undefined && - params.bypassEffectiveUpstreamNegativeCache === undefined + params.bypassEffectiveUpstreamNegativeCache === undefined && + params.reuseLineStats === undefined && + signal === undefined ? undefined : { ...(params.includeIgnored === undefined @@ -99,7 +101,9 @@ export const GIT_METHODS: RpcMethod[] = [ : { includeIgnored: params.includeIgnored }), ...(params.bypassEffectiveUpstreamNegativeCache === true ? { bypassEffectiveUpstreamNegativeCache: true } - : {}) + : {}), + ...(params.reuseLineStats === true ? { reuseLineStats: true } : {}), + ...(signal ? { signal } : {}) } return options === undefined ? runtime.getRuntimeGitStatus(params.worktree) diff --git a/src/main/runtime/rpc/methods/github.ts b/src/main/runtime/rpc/methods/github.ts index c1419af1651..44375181da0 100644 --- a/src/main/runtime/rpc/methods/github.ts +++ b/src/main/runtime/rpc/methods/github.ts @@ -10,7 +10,7 @@ const RepoSelector = z.object({ const WorkItemsList = RepoSelector.extend({ limit: OptionalFiniteNumber, query: OptionalString, - before: OptionalString, + page: z.number().int().positive().optional(), noCache: z.boolean().optional() }) @@ -306,7 +306,7 @@ export const GITHUB_METHODS: RpcMethod[] = [ params.repo, params.limit, params.query, - params.before, + params.page, params.noCache ) }), diff --git a/src/main/runtime/rpc/methods/jira.ts b/src/main/runtime/rpc/methods/jira.ts index 35d1d067ca1..a18eb2d02d3 100644 --- a/src/main/runtime/rpc/methods/jira.ts +++ b/src/main/runtime/rpc/methods/jira.ts @@ -17,8 +17,10 @@ const SiteSelection = z const Connect = z.object({ siteUrl: requiredString('Site URL is required'), - email: requiredString('Email is required'), - apiToken: requiredString('API token is required') + // Self-hosted PAT auth needs no email; connect() enforces it for Cloud. + email: OptionalPlainString, + apiToken: requiredString('API token is required'), + authType: z.enum(['cloud', 'server']).optional() }) const SelectSite = z.object({ @@ -100,8 +102,9 @@ export const JIRA_METHODS: RpcMethod[] = [ handler: async (params, { runtime }) => runtime.jiraConnect({ siteUrl: params.siteUrl.trim(), - email: params.email.trim(), - apiToken: params.apiToken.trim() + email: params.email?.trim() ?? '', + apiToken: params.apiToken.trim(), + authType: params.authType }) }), defineMethod({ diff --git a/src/main/runtime/rpc/methods/native-chat.test.ts b/src/main/runtime/rpc/methods/native-chat.test.ts index 720ce127d2c..15bb6b3a24a 100644 --- a/src/main/runtime/rpc/methods/native-chat.test.ts +++ b/src/main/runtime/rpc/methods/native-chat.test.ts @@ -2,12 +2,35 @@ import { describe, expect, it, vi } from 'vitest' import type { NativeChatMessage } from '../../../../shared/native-chat-types' import type { RpcContext } from '../core' -// Stub the shared cache so the handler returns a deterministic transcript with +// Stub the bounded tail reader so the handler returns a deterministic transcript with // one oversized tool-result block; the test then asserts clip behavior per client. const OVERSIZED = 'x'.repeat(5000) const cachedResult = vi.hoisted(() => ({ value: { messages: [] as NativeChatMessage[] } })) -vi.mock('../../../native-chat/transcript-read-cache', () => ({ - readNativeChatTranscriptCached: () => Promise.resolve(cachedResult.value) +const watcher = vi.hoisted(() => ({ + args: null as null | { + onInitialSnapshot?: ( + messages: NativeChatMessage[], + hasMore: boolean, + beforeOffset: number, + error?: string + ) => void + onAppend: (messages: NativeChatMessage[]) => void + }, + watching: true +})) +vi.mock('../../../native-chat/transcript-watch', () => ({ + readNativeChatTranscriptTail: ({ limit }: { limit: number }) => { + const messages = cachedResult.value.messages + return Promise.resolve({ + messages: messages.slice(-limit), + hasMore: messages.length > limit, + beforeOffset: 123 + }) + }, + subscribeNativeChatTranscript: (args: NonNullable) => { + watcher.args = args + return Promise.resolve({ unsubscribe: vi.fn(), watching: watcher.watching }) + } })) import { NATIVE_CHAT_METHODS } from './native-chat' @@ -30,6 +53,34 @@ function readSessionHandler(): (params: unknown, ctx: RpcContext) => Promise Promise } +function subscribeHandler(): ( + params: unknown, + ctx: RpcContext, + emit: (value: unknown) => void +) => Promise { + const method = NATIVE_CHAT_METHODS.find((candidate) => candidate.name === 'nativeChat.subscribe') + if (!method) { + throw new Error('subscribe method not registered') + } + return method.handler as ( + params: unknown, + ctx: RpcContext, + emit: (value: unknown) => void + ) => Promise +} + +function streamingContext(clientKind: RpcContext['clientKind']): RpcContext { + return { + runtime: { + registerSubscriptionCleanup: vi.fn(), + cleanupSubscription: vi.fn(), + cleanupSubscriptionsByPrefix: vi.fn() + } as unknown as RpcContext['runtime'], + connectionId: 'connection-1', + clientKind + } +} + function ctxWith(clientKind: RpcContext['clientKind']): RpcContext { return { runtime: {} as RpcContext['runtime'], clientKind } } @@ -40,6 +91,13 @@ function firstOutput(result: unknown): string { return block.output } +function activeWatcherArgs(): NonNullable { + if (!watcher.args) { + throw new Error('native-chat transcript watcher was not subscribed') + } + return watcher.args +} + describe('nativeChat.readSession clientKind truncation gating', () => { it('clips oversized tool output for mobile clients', async () => { cachedResult.value = { messages: [makeMessage(OVERSIZED)] } @@ -52,6 +110,131 @@ describe('nativeChat.readSession clientKind truncation gating', () => { expect(output).toContain('truncated') }) + it('bounds raw tool-call inputs before sending them to mobile', async () => { + cachedResult.value = { + messages: [ + { + ...makeMessage('ignored'), + blocks: [ + { + type: 'tool-call', + name: 'Write', + input: { file_path: 'src/a.ts', content: OVERSIZED } + } + ] + } + ] + } + const result = await readSessionHandler()( + { agent: 'claude', sessionId: 's' }, + ctxWith('mobile') + ) + const input = (result as { messages: NativeChatMessage[] }).messages[0].blocks[0] + + expect(JSON.stringify(input).length).toBeLessThan(OVERSIZED.length) + expect(JSON.stringify(input)).toContain('truncated') + }) + + it('preserves AskUserQuestion option objects at the supported nesting depth', async () => { + cachedResult.value = { + messages: [ + { + ...makeMessage('ignored'), + blocks: [ + { + type: 'tool-call', + name: 'AskUserQuestion', + input: { + questions: [ + { + question: 'Pick one', + header: 'Choice', + options: [ + { label: 'Alpha', description: 'First option' }, + { label: 'Beta', description: 'Second option' } + ] + } + ] + } + } + ] + } + ] + } + + const result = await readSessionHandler()( + { agent: 'claude', sessionId: 's' }, + ctxWith('mobile') + ) + const block = (result as { messages: NativeChatMessage[] }).messages[0].blocks[0] + + expect(block).toMatchObject({ + type: 'tool-call', + input: { + questions: [ + { + options: [ + { label: 'Alpha', description: 'First option' }, + { label: 'Beta', description: 'Second option' } + ] + } + ] + } + }) + }) + + it('bounds tool-call keys and primitive node fanout', async () => { + const wide = Object.fromEntries( + Array.from({ length: 200 }, (_unused, index) => [`${'k'.repeat(200)}-${index}`, index]) + ) + cachedResult.value = { + messages: [ + { + ...makeMessage('ignored'), + blocks: [{ type: 'tool-call', name: 'Write', input: wide }] + } + ] + } + const result = await readSessionHandler()( + { agent: 'claude', sessionId: 's' }, + ctxWith('mobile') + ) + const encoded = JSON.stringify((result as { messages: NativeChatMessage[] }).messages[0]) + + expect(encoded.length).toBeLessThan(10_000) + expect(encoded).toContain('truncated') + }) + + it('keeps sibling tool-call keys that share a 128-char prefix distinct', async () => { + const prefix = 'p'.repeat(128) + cachedResult.value = { + messages: [ + { + ...makeMessage('ignored'), + blocks: [ + { + type: 'tool-call', + name: 'Write', + input: { [`${prefix}A`]: 'first', [`${prefix}B`]: 'second' } + } + ] + } + ] + } + const result = await readSessionHandler()( + { agent: 'claude', sessionId: 's' }, + ctxWith('mobile') + ) + const block = (result as { messages: NativeChatMessage[] }).messages[0].blocks[0] as { + input: Record + } + const keys = Object.keys(block.input) + + expect(keys).toHaveLength(2) + expect(new Set(keys).size).toBe(2) + expect(Object.values(block.input)).toEqual(expect.arrayContaining(['first', 'second'])) + }) + it('passes oversized tool output through intact for runtime (web/desktop) clients', async () => { cachedResult.value = { messages: [makeMessage(OVERSIZED)] } const result = await readSessionHandler()( @@ -98,5 +281,102 @@ describe('nativeChat.readSession clientKind truncation gating', () => { // Tail-only: the last id survives, the first is dropped. expect(messages.at(-1)?.id).toBe('m-59') expect(messages[0].id).toBe('m-20') + expect(result).toMatchObject({ hasMore: true, beforeOffset: 123 }) + }) +}) + +describe('nativeChat.subscribe initial snapshot', () => { + it('emits one windowed snapshot with pagination state before live appends', async () => { + watcher.watching = true + watcher.args = null + const emitted: unknown[] = [] + await subscribeHandler()( + { agent: 'claude', sessionId: 's', limit: 40 }, + streamingContext('mobile'), + (value) => emitted.push(value) + ) + const messages = Array.from({ length: 60 }, (_unused, index) => ({ + ...makeMessage('small'), + id: `m-${index}` + })) + + const callbacks = activeWatcherArgs() + callbacks.onInitialSnapshot?.(messages, true, 123) + const live = Array.from({ length: 60 }, (_unused, index) => ({ + ...makeMessage('live'), + id: `m-live-${index}` + })) + callbacks.onAppend(live) + + expect(emitted[0]).toMatchObject({ + type: 'snapshot', + hasMore: true, + beforeOffset: 123 + }) + const snapshotMessages = (emitted[0] as { messages: NativeChatMessage[] }).messages + expect(snapshotMessages).toHaveLength(40) + expect(snapshotMessages[0].id).toBe('m-20') + expect(emitted[1]).toMatchObject({ type: 'appended' }) + expect((emitted[1] as { messages: NativeChatMessage[] }).messages).toHaveLength(60) + }) + + it('emits an explicit error snapshot when no transcript can be watched', async () => { + watcher.watching = false + watcher.args = null + const emitted: unknown[] = [] + await subscribeHandler()( + { agent: 'claude', sessionId: 'missing' }, + streamingContext('mobile'), + (value) => emitted.push(value) + ) + + expect(emitted).toEqual([ + { type: 'snapshot', messages: [], hasMore: false, error: 'Transcript unavailable' } + ]) + }) + + it('forwards an initial-drain error onto the snapshot frame', async () => { + watcher.watching = true + watcher.args = null + const emitted: unknown[] = [] + await subscribeHandler()( + { agent: 'claude', sessionId: 's' }, + streamingContext('mobile'), + (value) => emitted.push(value) + ) + + activeWatcherArgs().onInitialSnapshot?.([], false, 0, 'Transcript unavailable') + + expect(emitted).toEqual([ + { + type: 'snapshot', + messages: [], + hasMore: false, + beforeOffset: 0, + error: 'Transcript unavailable' + } + ]) + }) + + it('omits error from the snapshot frame on a clean initial drain', async () => { + watcher.watching = true + watcher.args = null + const emitted: unknown[] = [] + await subscribeHandler()( + { agent: 'claude', sessionId: 's' }, + streamingContext('mobile'), + (value) => emitted.push(value) + ) + + activeWatcherArgs().onInitialSnapshot?.([makeMessage('hi')], false, 7) + + expect(emitted).toEqual([ + { + type: 'snapshot', + messages: [expect.objectContaining({ id: 'a-1' })], + hasMore: false, + beforeOffset: 7 + } + ]) }) }) diff --git a/src/main/runtime/rpc/methods/native-chat.ts b/src/main/runtime/rpc/methods/native-chat.ts index 05558c8b76c..513a2134011 100644 --- a/src/main/runtime/rpc/methods/native-chat.ts +++ b/src/main/runtime/rpc/methods/native-chat.ts @@ -1,8 +1,10 @@ import { z } from 'zod' import type { NativeChatBlock, NativeChatMessage } from '../../../../shared/native-chat-types' import type { AgentType } from '../../../../shared/native-chat-types' -import { readNativeChatTranscriptCached } from '../../../native-chat/transcript-read-cache' -import { subscribeNativeChatTranscript } from '../../../native-chat/transcript-watch' +import { + readNativeChatTranscriptTail, + subscribeNativeChatTranscript +} from '../../../native-chat/transcript-watch' import { defineMethod, defineStreamingMethod, type RpcAnyMethod, type RpcContext } from '../core' // Why: native chat renders an agent's own transcript (Claude/Codex JSONL). The @@ -39,7 +41,8 @@ const NativeChatSession = z.object({ // Authoritative transcript path from the agent hook (providerSession), used to // locate the file directly when the session id no longer names it (recent // Claude Code). Optional for back-compat with older clients. - transcriptPath: z.string().min(1).optional() + transcriptPath: z.string().min(1).optional(), + beforeOffset: z.number().int().nonnegative().optional() }) const NativeChatUnsubscribe = z.object({ @@ -59,6 +62,8 @@ const MOBILE_NATIVE_CHAT_MAX_WINDOW = 2000 // The mobile view only previews block bodies, so truncate them on the wire to // keep the payload small; the marker tells the user content was clipped. const MOBILE_BLOCK_CHAR_CAP = 4000 +const MOBILE_TOOL_INPUT_ITEMS_CAP = 20 +const MOBILE_TOOL_INPUT_NODE_CAP = 100 const TRUNCATION_MARKER = '\n… (truncated)' function clip(text: string): string { @@ -76,13 +81,77 @@ function clipBlock(block: NativeChatBlock): NativeChatBlock { ? { ...block, output: clip(block.output) } : block } + if (block.type === 'tool-call') { + const budget = { remaining: MOBILE_BLOCK_CHAR_CAP, nodes: MOBILE_TOOL_INPUT_NODE_CAP } + return { ...block, input: sanitizeToolInput(block.input, budget, 0) } + } return block } +function sanitizeToolInput( + value: unknown, + budget: { remaining: number; nodes: number }, + depth: number +): unknown { + budget.nodes-- + if (budget.nodes < 0 || budget.remaining <= 0) { + return '… (truncated)' + } + if (typeof value === 'string') { + const length = Math.min(value.length, budget.remaining) + budget.remaining -= length + return length < value.length ? `${value.slice(0, length)}… (truncated)` : value + } + if (!value || typeof value !== 'object' || depth >= 5) { + return value && typeof value === 'object' ? '… (truncated)' : value + } + if (Array.isArray(value)) { + const result = value + .slice(0, MOBILE_TOOL_INPUT_ITEMS_CAP) + .map((item) => sanitizeToolInput(item, budget, depth + 1)) + if (value.length > MOBILE_TOOL_INPUT_ITEMS_CAP) { + result.push('… (truncated)') + } + return result + } + const result: Record = {} + let count = 0 + for (const key in value) { + if (!Object.prototype.hasOwnProperty.call(value, key)) { + continue + } + if (count >= MOBILE_TOOL_INPUT_ITEMS_CAP || budget.remaining <= 0) { + result['…'] = 'truncated' + break + } + let boundedKey = key.slice(0, Math.min(key.length, budget.remaining, 128)) + // Why: sibling keys sharing a >=128-char (or budget-truncated) prefix collapse + // to the same bounded key; suffix collisions so neither field is silently lost. + if (Object.prototype.hasOwnProperty.call(result, boundedKey)) { + boundedKey = `${boundedKey}~${count}` + } + budget.remaining -= boundedKey.length + result[boundedKey] = sanitizeToolInput( + (value as Record)[key], + budget, + depth + 1 + ) + count++ + } + return result +} + function sanitizeMessage(message: NativeChatMessage): NativeChatMessage { return { ...message, blocks: message.blocks.map(clipBlock) } } +function sanitizeAppendForClient( + messages: readonly NativeChatMessage[], + clientKind: RpcContext['clientKind'] +): NativeChatMessage[] { + return clientKind === 'mobile' ? messages.map(sanitizeMessage) : messages.slice() +} + /** Window a transcript to its most recent `limit` messages so a long session * can't freeze the client. Windowing by count applies to ALL RPC clients — * shipping thousands of turns over the paired link is bad for web and mobile @@ -113,14 +182,20 @@ export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [ name: 'nativeChat.readSession', params: NativeChatSession, handler: async (params, { clientKind }) => { - const result = await readNativeChatTranscriptCached( - params.agent, - params.sessionId, - params.transcriptPath - ) - // Window to the conversation tail (all clients); clip blocks for mobile only. + const limit = params.limit ?? MOBILE_NATIVE_CHAT_DEFAULT_WINDOW + const result = await readNativeChatTranscriptTail({ + agent: params.agent, + sessionId: params.sessionId, + transcriptPath: params.transcriptPath, + limit, + beforeOffset: params.beforeOffset + }) return 'messages' in result - ? { messages: windowForClient(result.messages, clientKind, params.limit) } + ? { + messages: windowForClient(result.messages, clientKind, limit), + hasMore: result.hasMore, + beforeOffset: result.beforeOffset + } : result } }), @@ -130,10 +205,8 @@ export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [ handler: async (params, { runtime, connectionId, clientKind }, emit) => { let closed = false let unsubscribe = (): void => {} - // Why: the subscriber seeds its read offset at 0, so the first drain emits - // the whole transcript and later drains emit only appended turns. The first - // batch is windowed to the tail (a full transcript would freeze mobile); - // later incremental batches are smaller than the window so they pass through. + // Why: the first drain is a bounded tail snapshot; later drains emit only + // appended turns. This avoids parsing or shipping full long transcripts. // Clients merge by message id, so the initial windowed batch doubles as the // snapshot. Keyed by the client-supplied subscriptionId when present so // registration and unsubscribe derive from the same token; otherwise by @@ -141,6 +214,7 @@ export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [ // unsubscribe (no wire break). const cleanupToken = params.subscriptionId ?? `${params.agent}:${params.sessionId}` const subscriptionId = `nativeChat:${connectionId ?? 'local'}:${cleanupToken}` + const limit = params.limit ?? MOBILE_NATIVE_CHAT_DEFAULT_WINDOW runtime.registerSubscriptionCleanup( subscriptionId, () => { @@ -157,11 +231,37 @@ export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [ agent: params.agent, sessionId: params.sessionId, transcriptPath: params.transcriptPath, + initialLimit: limit, + onInitialSnapshot: (messages, hasMore, beforeOffset, error) => { + if (closed) { + return + } + // Forward an initial-drain error so a watching client's first frame carries it + // instead of stranding the view at 'loading' when the read keeps throwing. + emit({ + type: 'snapshot', + messages: windowForClient(messages, clientKind, limit), + hasMore, + beforeOffset, + ...(error ? { error } : {}) + }) + }, + onReplace: (messages, hasMore, beforeOffset) => { + if (closed) { + return + } + emit({ + type: 'replacement', + messages: windowForClient(messages, clientKind, limit), + hasMore, + beforeOffset + }) + }, onAppend: (messages) => { if (closed) { return } - emit({ type: 'appended', messages: windowForClient(messages, clientKind) }) + emit({ type: 'appended', messages: sanitizeAppendForClient(messages, clientKind) }) } }) // The connection may have closed while the file was being resolved. @@ -169,6 +269,9 @@ export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [ subscription.unsubscribe() return } + if (!subscription.watching) { + emit({ type: 'snapshot', messages: [], hasMore: false, error: 'Transcript unavailable' }) + } unsubscribe = subscription.unsubscribe } }), diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 7a9b9e448a6..6d5879d9cf8 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -50,6 +50,9 @@ const TERMINAL_MULTIPLEX_ACK_TOTAL_HIGH_WATER_BYTES = 2 * 1024 * 1024 // payload bytes rather than UTF-16 code units. const TERMINAL_MULTIPLEX_PENDING_MAX_BYTES = 256 * 1024 const TERMINAL_QUERY_REPLAY_MAX_CHARS = 16 * 1024 +// Why: keep initial subscribe latency bounded; readiness remains observed after +// this deadline and triggers an in-stream recovery snapshot when it arrives. +const MOBILE_RENDERER_MOUNT_READY_TIMEOUT_MS = 3_000 let nextTerminalStreamId = 1 type SnapshotFrameOptions = { @@ -332,6 +335,61 @@ function resolveMobileFloorClientId( return null } +async function sendTerminalStreamInput( + runtime: OrcaRuntimeService, + args: { + terminal: string + text: string + client: TerminalViewportClient | undefined + isMobile: boolean + } +): Promise { + const action = { text: args.text, enter: false, interrupt: false } + const clientId = args.isMobile ? args.client?.id : undefined + const floorClaim: MobileInputFloorClaimHolder = { current: null } + try { + if (!clientId) { + await runtime.sendTerminal(args.terminal, action) + return + } + const result = await runtime.sendTerminal(args.terminal, action, { + reserveWrite: (writePtyId) => { + const claim = runtime.beginMobileInputFloor(writePtyId, clientId) + if (!claim) { + throw new Error('mobile_input_floor_unavailable') + } + floorClaim.current = claim + }, + afterWrite: () => commitMobileInputFloorClaim(floorClaim) + }) + if (!result.accepted) { + floorClaim.current?.rollback() + } + } catch { + floorClaim.current?.rollback() + } +} + +type MobileInputFloorClaimHolder = { + current: ReturnType +} + +async function commitMobileInputFloorClaim(claim: MobileInputFloorClaimHolder): Promise { + const current = claim.current + if (!current) { + return + } + try { + await current.commit() + } finally { + // Why: the runtime may yield before the next chunk/suffix, so that write + // needs a fresh reservation if desktop reclaimed the floor meanwhile. + if (claim.current === current) { + claim.current = null + } + } +} + function getTerminalSendGuardRefusedReason(error: unknown): 'no-agent' | 'permission' | undefined { const message = error instanceof Error ? error.message : String(error) if (message.includes('terminal_guard_permission')) { @@ -650,6 +708,44 @@ async function serializeBudgetedMobileSnapshot( return null } +async function serializeStableMobileRendererSnapshot( + runtime: OrcaRuntimeService, + ptyId: string +): Promise { + const candidates = [MOBILE_SUBSCRIBE_SCROLLBACK_ROWS, 500, 250, 100, 25, 0] + let candidateIndex = 0 + for (let attempt = 0; attempt < candidates.length; attempt += 1) { + // Why: stability retries share the six-call snapshot budget. Advance + // toward zero scrollback so the final attempt always has a bounded payload. + candidateIndex = Math.max(candidateIndex, attempt) + const rows = candidates[candidateIndex] + const outputSequenceBefore = runtime.getPtyOutputSequence(ptyId) + const serialized = await runtime.serializeRendererTerminalBuffer(ptyId, { + scrollbackRows: rows + }) + const outputSequenceAfter = runtime.getPtyOutputSequence(ptyId) + if (outputSequenceBefore !== outputSequenceAfter) { + continue + } + if (!serialized) { + return null + } + const overByteBudget = terminalStreamByteLengthExceeds( + serialized.data, + MOBILE_SNAPSHOT_BYTE_BUDGET + ) + if (!overByteBudget || rows === 0) { + return { + ...serialized, + scrollbackRows: rows, + truncatedByByteBudget: rows < MOBILE_SUBSCRIBE_SCROLLBACK_ROWS || overByteBudget + } + } + candidateIndex += 1 + } + return null +} + // Why: mobile xterm can only re-wrap SOFT-wrapped lines on a client-side // term.resize(); the restored scrollback snapshot contains HARD newlines from // the host serialization, so a width change leaves prior output wrapped at the @@ -905,7 +1001,8 @@ const TerminalSubscribe = TerminalHandle.extend({ capabilities: z .object({ terminalBinaryStream: z.literal(1).optional(), - desktopViewportClaims: z.literal(1).optional() + desktopViewportClaims: z.literal(1).optional(), + mobileInputLeaseOnly: z.literal(1).optional() }) .optional() }) @@ -1224,6 +1321,19 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ } } } + const mobileFloorClientId = resolveMobileFloorClientId(driver, params.client) + const mobileFloorClaim: MobileInputFloorClaimHolder = { current: null } + const beforeWrite = assertSendPreconditions + const reserveWrite = + params.inputKind !== 'query-reply' && leaf?.ptyId && mobileFloorClientId + ? (ptyId: string): void => { + const claim = runtime.beginMobileInputFloor(ptyId, mobileFloorClientId) + if (!claim) { + throw new Error('mobile_input_floor_unavailable') + } + mobileFloorClaim.current = claim + } + : undefined let result try { result = await runtime.sendTerminal( @@ -1233,9 +1343,16 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ enter: params.enter === true, interrupt: params.interrupt === true }, - { beforeWrite: assertSendPreconditions } + { + beforeWrite, + ...(reserveWrite ? { reserveWrite } : {}), + ...(params.inputKind !== 'query-reply' && mobileFloorClientId + ? { afterWrite: () => commitMobileInputFloorClaim(mobileFloorClaim) } + : {}) + } ) } catch (error) { + mobileFloorClaim.current?.rollback() const refusedReason = getTerminalSendGuardRefusedReason(error) if (refusedReason) { return { @@ -1258,15 +1375,14 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ } throw error } + if (result.accepted !== true) { + mobileFloorClaim.current?.rollback() + } // Why: deliberate mobile input is a take-floor action. Drives the // `* → mobile{clientId}` driver transition so the desktop banner // remounts (if previously reclaimed) and active phone-fit dims follow // the most recent actor. Clientless sends are old mobile builds, so use // the current mobile driver as their compatibility identity. - const mobileFloorClientId = resolveMobileFloorClientId(driver, params.client) - if (params.inputKind !== 'query-reply' && leaf?.ptyId && mobileFloorClientId) { - await runtime.mobileTookFloor(leaf.ptyId, mobileFloorClientId) - } return { send: result } } }), @@ -1368,6 +1484,13 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ close: await runtime.closeTerminal(params.terminal) }) }), + defineMethod({ + name: 'terminal.closeTab', + params: TerminalHandle, + handler: async (params, { runtime }) => ({ + close: await runtime.closeTerminalTab(params.terminal) + }) + }), defineMethod({ name: 'agentTeams.tmuxCompat', params: AgentTeamsTmuxCompat, @@ -1751,22 +1874,17 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ // Mobile already has the higher-priority floor; a rejected desktop // viewport claim must never suppress later phone input. const inputClaimTail = stream.isMobile ? Promise.resolve(true) : stream.desktopClaimTail - void inputClaimTail - .then((claimed) => - !claimed || isTerminalInputLockedForClient(runtime, stream.ptyId, stream.client) - ? null - : runtime.sendTerminal(stream.terminal, { - text, - enter: false, - interrupt: false - }) - ) - .then(async () => { - if (stream.isMobile && stream.client?.id) { - await runtime.mobileTookFloor(stream.ptyId, stream.client.id) - } + void inputClaimTail.then((claimed) => { + if (!claimed || isTerminalInputLockedForClient(runtime, stream.ptyId, stream.client)) { + return + } + return sendTerminalStreamInput(runtime, { + terminal: stream.terminal, + text, + client: stream.client, + isMobile: stream.isMobile }) - .catch(() => {}) + }) return } if (frame.opcode === TerminalStreamOpcode.Resize && stream.client) { @@ -2355,13 +2473,25 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ ) => { let leaf = runtime.resolveLeafForHandle(params.terminal) const isMobile = params.client?.type === 'mobile' + const serializerGenerationBeforeAnyMount = isMobile + ? (runtime.getRendererTerminalSerializerGenerationForHandle?.(params.terminal) ?? 0) + : 0 + let rendererMountRequestedBeforePty = false const useBinaryStream = params.capabilities?.terminalBinaryStream === 1 && Boolean(sendBinary) + // Why: a closed stream must not allocate listeners, mobile-fit state, or + // a hidden renderer surface that no client remains to consume. + if (signal?.aborted) { + return + } // Why: the left pane's PTY spawns asynchronously after the tab is created. // Mobile clients that subscribe before the PTY is ready would get a bare // scrollback+end with no live stream or phone-fit. Wait for the PTY so // the subscribe can proceed normally. if (!leaf?.ptyId && isMobile) { + // Why: a never-mounted tab has no graph leaf to await; mounting the + // exact tab lets its PTY attach without activating the worktree. + rendererMountRequestedBeforePty = runtime.requestRendererTerminalTabMount(params.terminal) try { const ptyId = await runtime.waitForLeafPtyId(params.terminal, 10_000, signal) leaf = { ptyId } @@ -2391,7 +2521,61 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ const ptyId = leaf.ptyId const clientId = params.client?.id + const mobileInputLeaseOnly = + isMobile && params.capabilities?.mobileInputLeaseOnly === 1 && Boolean(clientId) + // Why: the initial mount/PTY wait and phone-fit can both emit a redraw + // that creates suffix-only state, so preserve the pre-mount absence signal. + const missingHeadlessStateBeforeMobileFit = + isMobile && + (rendererMountRequestedBeforePty || runtime.hasHeadlessTerminalState?.(ptyId) === false) + const serializerGenerationBeforeMobileFit = missingHeadlessStateBeforeMobileFit + ? rendererMountRequestedBeforePty + ? serializerGenerationBeforeAnyMount + : runtime.getRendererTerminalSerializerGeneration(ptyId) + : 0 const supportsDesktopViewportClaims = params.capabilities?.desktopViewportClaims === 1 + if (mobileInputLeaseOnly && clientId) { + let closed = false + let resolveStream = (): void => {} + const streamClosed = new Promise((resolve) => { + resolveStream = resolve + }) + const subscriptionId = `${params.terminal}:${clientId}` + // Why: chat needs the input-floor acknowledgement without registering + // a view subscriber or transporting duplicate PTY output. + runtime.registerSubscriptionCleanup( + subscriptionId, + () => { + closed = true + runtime.handleMobileUnsubscribe(ptyId, clientId) + emit({ type: 'end' }) + resolveStream() + }, + connectionId + ) + void runtime + .waitForTerminal(params.terminal, { condition: 'exit', signal }) + .then(() => runtime.cleanupSubscription(subscriptionId)) + .catch(() => runtime.cleanupSubscription(subscriptionId)) + try { + await runtime.handleMobileSubscribe(ptyId, clientId, params.viewport) + if (closed || signal?.aborted) { + // Why: a disconnect can win the awaited subscribe and otherwise + // resurrect mobile presence after cleanup already released it. + runtime.handleMobileUnsubscribe(ptyId, clientId) + if (!closed) { + runtime.cleanupSubscription(subscriptionId) + } + return + } + emit({ type: 'subscribed', streamId: null, lines: [], truncated: false }) + await streamClosed + } catch (error) { + runtime.cleanupSubscription(subscriptionId) + throw error + } + return + } // Why: only unregister the width floor this subscription took (see the // multiplex stream's registeredRemoteDesktopDriver note). let registeredRemoteDesktopDriver = false @@ -2532,6 +2716,8 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ let unsubscribeResize = (): void => {} let unsubscribeFit = (): void => {} let unregisterBinaryHandler = (): void => {} + let abortRendererMountWait = (): void => {} + let lateRendererReadyPromise: Promise | null = null let outputBatcher: ReturnType | null = null let resolveStream = (): void => {} const streamClosed = new Promise((resolve) => { @@ -2551,6 +2737,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ unsubscribeResize() unsubscribeFit() unregisterBinaryHandler() + abortRendererMountWait() if (isMobile && clientId) { runtime.handleMobileUnsubscribe(ptyId, clientId) } else if (registeredRemoteDesktopDriver && clientId) { @@ -2602,22 +2789,17 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ if (isTerminalInputLockedForClient(runtime, ptyId, params.client)) { return } - void desktopClaimTail - .then((claimed) => - !claimed || isTerminalInputLockedForClient(runtime, ptyId, params.client) - ? null - : runtime.sendTerminal(params.terminal, { - text, - enter: false, - interrupt: false - }) - ) - .then(async () => { - if (isMobile && clientId) { - await runtime.mobileTookFloor(ptyId, clientId) - } + void desktopClaimTail.then(async (claimed) => { + if (!claimed || isTerminalInputLockedForClient(runtime, ptyId, params.client)) { + return + } + await sendTerminalStreamInput(runtime, { + terminal: params.terminal, + text, + client: params.client, + isMobile }) - .catch(() => {}) + }) return } if (frame.opcode === TerminalStreamOpcode.Resize && params.client) { @@ -2772,6 +2954,83 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ if (closed) { return } + // Why: missing model state—not snapshot text—is the signal that this + // PTY may never have attached; avoid remounting legitimate blank panes. + // A renderer-sourced snapshot also proves the exact pane is already + // attached, so waiting for a fresh mount generation would only stall. + const mountRequested = + missingHeadlessStateBeforeMobileFit && + serialized?.source !== 'renderer' && + (rendererMountRequestedBeforePty || + runtime.requestRendererTerminalTabMount(params.terminal)) + if (missingHeadlessStateBeforeMobileFit && mountRequested) { + // Why: an idle legacy PTY emits no later byte; a fresh settle proves + // this exact remount completed before we replay its restored screen. + const mountWaitController = new AbortController() + const abortMountWait = (): void => mountWaitController.abort() + abortRendererMountWait = abortMountWait + if (signal?.aborted) { + abortMountWait() + } else { + signal?.addEventListener('abort', abortMountWait, { once: true }) + } + const rendererReadyPromise = runtime + .waitForRendererTerminalSerializer( + ptyId, + serializerGenerationBeforeMobileFit, + undefined, + mountWaitController.signal + ) + .catch(() => false) + const finishMountWait = (): void => { + signal?.removeEventListener('abort', abortMountWait) + if (abortRendererMountWait === abortMountWait) { + abortRendererMountWait = () => {} + } + } + void rendererReadyPromise.then(finishMountWait, finishMountWait) + let deadlineTimer: ReturnType | null = null + const initialDeadline = new Promise((resolve) => { + deadlineTimer = setTimeout(() => resolve(false), MOBILE_RENDERER_MOUNT_READY_TIMEOUT_MS) + if (typeof deadlineTimer.unref === 'function') { + deadlineTimer.unref() + } + }) + const rendererReady = await Promise.race([rendererReadyPromise, initialDeadline]) + if (deadlineTimer) { + clearTimeout(deadlineTimer) + } + if (closed || signal?.aborted) { + return + } + if (rendererReady) { + read = await runtime.readTerminal(params.terminal) + const stableRendererSnapshot = await serializeStableMobileRendererSnapshot( + runtime, + ptyId + ) + if (closed) { + return + } + if (stableRendererSnapshot?.data.length) { + serialized = stableRendererSnapshot + const trailingOutput = pendingOutput.flatMap((item) => { + const data = getOutputAfterSnapshotSeq(item, stableRendererSnapshot.seq) + const seq = item.meta?.seq + return data && typeof seq === 'number' ? [{ data, seq }] : [] + }) + runtime.replaceHeadlessTerminalFromRendererSnapshotForRecovery( + ptyId, + stableRendererSnapshot, + trailingOutput + ) + } + } else { + // Why: a renderer can settle after the bounded initial response. + // Keep observing it so an idle PTY still self-heals without bytes. + lateRendererReadyPromise = rendererReadyPromise + } + } let initialOutputOverflowed = false if (pendingOutputOverflowed) { pendingOutput.splice(0) @@ -2932,6 +3191,54 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ } pendingOutputBytes = 0 outputBatcher.flush() + const lateRendererReady = lateRendererReadyPromise + lateRendererReadyPromise = null + if (lateRendererReady) { + void lateRendererReady + .then(async (rendererReady) => { + if (!rendererReady || closed) { + return + } + outputBatcher?.flush() + const recovery = await serializeStableMobileRendererSnapshot(runtime, ptyId) + if (closed) { + return + } + if (!recovery?.data.length) { + return + } + // Why: late recovery has no buffered-output gate. Only an exact + // renderer high-water may reset mobile without erasing live bytes. + if (recovery.seq !== runtime.getPtyOutputSequence(ptyId)) { + return + } + runtime.replaceHeadlessTerminalFromRendererSnapshotForRecovery(ptyId, recovery) + // Why: shipped mobile clients apply resized snapshots in place, + // allowing a blank initialized xterm to recover without resubscribe. + const recoveryStats = sendSnapshotFrames(sendFrame, { + kind: 'resized', + cols: recovery.cols, + rows: recovery.rows, + displayMode, + reason: 'renderer-mount-ready', + source: recovery.source, + truncated: false, + truncatedByByteBudget: recovery.truncatedByByteBudget, + data: recovery.data + }) + lastResizeCols = recovery.cols + console.log('[mobile-terminal-stream] recovery snapshot', { + terminal: params.terminal, + streamId, + reason: 'renderer-mount-ready', + bytes: recoveryStats.bytes, + chunks: recoveryStats.chunks, + scrollbackRows: recovery.scrollbackRows, + truncatedByByteBudget: recovery.truncatedByByteBudget === true + }) + }) + .catch(() => {}) + } const sendResizedFrame = (event: { cols: number rows: number diff --git a/src/main/runtime/rpc/terminal-multiplex.test.ts b/src/main/runtime/rpc/terminal-multiplex.test.ts index 20b608c94c2..99a77fb9afd 100644 --- a/src/main/runtime/rpc/terminal-multiplex.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex.test.ts @@ -692,6 +692,9 @@ describe('terminal multiplex RPC', () => { }) => void) | undefined const restreamResolves: ((value: { data: string; cols: number; rows: number }) => void)[] = [] + const write = vi.fn() + const commit = vi.fn().mockResolvedValue(undefined) + const beginMobileInputFloor = vi.fn(() => ({ commit, rollback: vi.fn() })) const runtime = stubRuntime({ resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), @@ -723,7 +726,13 @@ describe('terminal multiplex RPC', () => { cleanups.set(id, cleanup) }), waitForTerminal: vi.fn(() => new Promise(() => {})), - sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), + sendTerminal: vi.fn().mockImplementation(async (_handle, _action, options) => { + options.reserveWrite('pty-1') + write() + await options.afterWrite('pty-1') + return { accepted: true } + }), + beginMobileInputFloor, updateMobileViewport: vi.fn().mockResolvedValue({ updated: false, applied: false }) }) const dispatcher = new RpcDispatcher({ @@ -787,12 +796,16 @@ describe('terminal multiplex RPC', () => { )! ) await vi.waitFor(() => - expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', { - text: 'x', - enter: false, - interrupt: false - }) + expect(runtime.sendTerminal).toHaveBeenCalledWith( + 'terminal-1', + { text: 'x', enter: false, interrupt: false }, + { reserveWrite: expect.any(Function), afterWrite: expect.any(Function) } + ) ) + expect(beginMobileInputFloor.mock.invocationCallOrder[0]).toBeLessThan( + write.mock.invocationCallOrder[0]! + ) + expect(write.mock.invocationCallOrder[0]).toBeLessThan(commit.mock.invocationCallOrder[0]!) binaryFrames.splice(0) resizeListener?.({ diff --git a/src/main/runtime/rpc/terminal-output-batching.test.ts b/src/main/runtime/rpc/terminal-output-batching.test.ts index d10456c8fa6..c1ee755ee83 100644 --- a/src/main/runtime/rpc/terminal-output-batching.test.ts +++ b/src/main/runtime/rpc/terminal-output-batching.test.ts @@ -277,12 +277,18 @@ describe('terminal output batching', () => { } }) - it('routes binary terminal input frames back to the subscribed PTY', async () => { + it('claims the mobile floor before binary terminal input and rolls back rejection', async () => { const handlers = new Map< number, (frame: NonNullable>) => void >() const cleanups = new Map void>() + const write = vi.fn() + const commit = vi.fn().mockResolvedValue(undefined) + const rollback = vi.fn() + const beginMobileInputFloor = vi.fn( + (): ReturnType => ({ commit, rollback }) + ) const runtime = stubRuntime({ resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), @@ -293,7 +299,11 @@ describe('terminal output batching', () => { subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + subscribeToDriverChanges: vi.fn().mockReturnValue(vi.fn()), + getTerminalFitOverride: vi.fn().mockReturnValue(null), getDriver: vi.fn().mockReturnValue({ kind: 'idle' }), + handleMobileSubscribe: vi.fn().mockResolvedValue(undefined), + handleMobileUnsubscribe: vi.fn(), registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { cleanups.set(id, cleanup) }), @@ -302,7 +312,13 @@ describe('terminal output batching', () => { cleanups.delete(id) }), waitForTerminal: vi.fn(() => new Promise(() => {})), - sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), + sendTerminal: vi.fn().mockImplementation(async (_handle, _action, options) => { + options.reserveWrite('pty-1') + write() + await options.afterWrite('pty-1') + return { accepted: true } + }), + beginMobileInputFloor, updateMobileViewport: vi.fn().mockResolvedValue(false) }) const dispatcher = new RpcDispatcher({ @@ -314,7 +330,7 @@ describe('terminal output batching', () => { const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.subscribe', { terminal: 'terminal-1', - client: { id: 'desktop-1', type: 'desktop' }, + client: { id: 'mobile-1', type: 'mobile' }, capabilities: { terminalBinaryStream: 1 } }), (msg) => messages.push(msg), @@ -345,14 +361,54 @@ describe('terminal output batching', () => { ) await vi.waitFor(() => - expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', { - text: 'ls\r', - enter: false, - interrupt: false - }) + expect(runtime.sendTerminal).toHaveBeenCalledWith( + 'terminal-1', + { text: 'ls\r', enter: false, interrupt: false }, + { reserveWrite: expect.any(Function), afterWrite: expect.any(Function) } + ) ) + expect(beginMobileInputFloor).toHaveBeenCalledWith('pty-1', 'mobile-1') + expect(beginMobileInputFloor.mock.invocationCallOrder[0]).toBeLessThan( + write.mock.invocationCallOrder[0]! + ) + expect(write.mock.invocationCallOrder[0]).toBeLessThan(commit.mock.invocationCallOrder[0]!) + + vi.mocked(runtime.sendTerminal).mockImplementationOnce(async (_handle, _action, options) => { + options?.reserveWrite?.('pty-1') + return { handle: 'terminal-1', accepted: false, bytesWritten: 0 } + }) + handlers.get(streamId)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Input, + streamId, + seq: 2, + payload: encodeTerminalStreamText('rejected') + }) + )! + ) + await vi.waitFor(() => expect(rollback).toHaveBeenCalledOnce()) + expect(commit).toHaveBeenCalledOnce() + + // Post-unsubscribe: capture the still-registered handler, tear down the + // subscription, then replay a frame. The closed guard must drop it, so no + // further sendTerminal dispatch and no write reach the PTY. + const staleHandler = handlers.get(streamId)! + runtime.cleanupSubscription('terminal-1:mobile-1') + staleHandler( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Input, + streamId, + seq: 3, + payload: encodeTerminalStreamText('after-unsubscribe') + }) + )! + ) + await Promise.resolve() + expect(runtime.sendTerminal).toHaveBeenCalledTimes(2) + expect(write).toHaveBeenCalledOnce() - runtime.cleanupSubscription('terminal-1:desktop-1') await dispatchPromise }) }) diff --git a/src/main/runtime/rpc/terminal-send.test.ts b/src/main/runtime/rpc/terminal-send.test.ts index 58b37bcd7fc..e9fb481aae0 100644 --- a/src/main/runtime/rpc/terminal-send.test.ts +++ b/src/main/runtime/rpc/terminal-send.test.ts @@ -16,6 +16,12 @@ import { function stubRuntime(overrides: Partial = {}): OrcaRuntimeService { return { getRuntimeId: () => 'test-runtime', + beginMobileInputFloor: vi.fn((ptyId: string, clientId: string) => ({ + commit: async () => { + await overrides.mobileTookFloor?.(ptyId, clientId) + }, + rollback: vi.fn() + })), ...overrides } as OrcaRuntimeService } @@ -144,10 +150,9 @@ describe('terminal send RPC', () => { releaseClaim = () => resolve(true) }) ) - const sendTerminal = vi.fn().mockResolvedValue({ - handle: 'terminal-1', - accepted: true, - bytesWritten: 1 + const sendTerminal = vi.fn().mockImplementation(async (_handle, _action, options) => { + await options.beforeWrite?.('pty-1') + return { handle: 'terminal-1', accepted: true, bytesWritten: 1 } }) const runtime = stubRuntime({ resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), @@ -177,15 +182,33 @@ describe('terminal send RPC', () => { }) it('accepts legacy clientless mobile input when the current driver is mobile', async () => { - const runtime = stubRuntime({ - resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), - getDriver: vi.fn().mockReturnValue({ kind: 'mobile', clientId: 'mobile-1' }), - sendTerminal: vi.fn().mockResolvedValue({ + const write = vi.fn() + const sendTerminal = vi.fn().mockImplementation(async (_handle, _action, options) => { + await options.beforeWrite?.('pty-1') + options.reserveWrite?.('pty-1') + write() + await options.afterWrite?.('pty-1') + await options.beforeWrite?.('pty-1') + options.reserveWrite?.('pty-1') + write() + await options.afterWrite?.('pty-1') + return { handle: 'terminal-1', accepted: true, bytesWritten: 1 - }), - mobileTookFloor: vi.fn().mockResolvedValue(undefined) + } + }) + const mobileTookFloor = vi.fn().mockResolvedValue(undefined) + const beginMobileInputFloor = vi.fn(() => ({ + commit: async () => mobileTookFloor('pty-1', 'mobile-1'), + rollback: vi.fn() + })) + const runtime = stubRuntime({ + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + getDriver: vi.fn().mockReturnValue({ kind: 'mobile', clientId: 'mobile-1' }), + sendTerminal, + beginMobileInputFloor, + mobileTookFloor }) const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) @@ -208,9 +231,115 @@ describe('terminal send RPC', () => { enter: false, interrupt: false }, - { beforeWrite: undefined } + { + beforeWrite: undefined, + reserveWrite: expect.any(Function), + afterWrite: expect.any(Function) + } ) expect(runtime.mobileTookFloor).toHaveBeenCalledWith('pty-1', 'mobile-1') + expect(beginMobileInputFloor).toHaveBeenCalledTimes(2) + expect(runtime.mobileTookFloor).toHaveBeenCalledTimes(2) + expect(beginMobileInputFloor.mock.invocationCallOrder[0]).toBeLessThan( + write.mock.invocationCallOrder[0]! + ) + expect(write.mock.invocationCallOrder[0]).toBeLessThan( + mobileTookFloor.mock.invocationCallOrder[0]! + ) + }) + + it.each([ + ['rejects', async () => ({ handle: 'terminal-1', accepted: false, bytesWritten: 0 })], + [ + 'throws', + async () => { + throw new Error('write failed') + } + ] + ])('rolls back a mobile floor claim when the terminal write %s', async (_case, finishWrite) => { + const commit = vi.fn() + const rollback = vi.fn() + const beginMobileInputFloor = vi.fn(() => ({ commit, rollback })) + const sendTerminal = vi.fn().mockImplementation(async (_handle, _action, options) => { + await options.beforeWrite?.('pty-1') + options.reserveWrite?.('pty-1') + return finishWrite() + }) + const runtime = stubRuntime({ + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }), + sendTerminal, + beginMobileInputFloor + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + await dispatcher.dispatch( + makeRequest('terminal.send', { + terminal: 'terminal-1', + text: 'x', + client: { id: 'mobile-1', type: 'mobile' } + }) + ) + + expect(beginMobileInputFloor).toHaveBeenCalledWith('pty-1', 'mobile-1') + expect(beginMobileInputFloor).toHaveBeenCalledOnce() + expect(rollback).toHaveBeenCalledOnce() + expect(commit).not.toHaveBeenCalled() + }) + + it('writes zero bytes when the mobile subscriber disappeared before reservation', async () => { + const write = vi.fn() + const runtime = stubRuntime({ + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + getDriver: vi.fn().mockReturnValue({ kind: 'mobile', clientId: 'mobile-1' }), + beginMobileInputFloor: vi.fn().mockReturnValue(null), + sendTerminal: vi.fn().mockImplementation(async (_handle, _action, options) => { + options.reserveWrite('pty-1') + write() + return { handle: 'terminal-1', accepted: true, bytesWritten: 1 } + }) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('terminal.send', { terminal: 'terminal-1', text: 'stale' }) + ) + + expect(response.ok).toBe(false) + expect(write).not.toHaveBeenCalled() + }) + + it('reclaims the mobile floor for a delayed suffix and preserves the earlier commit', async () => { + const commit = vi.fn() + const rollback = vi.fn() + const beginMobileInputFloor = vi.fn(() => ({ commit: async () => commit(), rollback })) + const runtime = stubRuntime({ + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }), + beginMobileInputFloor, + sendTerminal: vi.fn().mockImplementation(async (_handle, _action, options) => { + await options.beforeWrite?.('pty-1') + options.reserveWrite('pty-1') + await options.afterWrite('pty-1') + await options.beforeWrite?.('pty-1') + options.reserveWrite('pty-1') + throw new Error('suffix failed') + }) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + await dispatcher.dispatch( + makeRequest('terminal.send', { + terminal: 'terminal-1', + text: 'partial', + enter: true, + client: { id: 'mobile-1', type: 'mobile' } + }) + ) + + expect(beginMobileInputFloor).toHaveBeenCalledTimes(2) + expect(commit).toHaveBeenCalledOnce() + expect(rollback).toHaveBeenCalledOnce() }) it('writes a validated terminal query reply without taking the mobile floor', async () => { @@ -446,6 +575,7 @@ describe('terminal send RPC', () => { }) it('refuses guarded terminal sends when the agent needs permission', async () => { + const beginMobileInputFloor = vi.fn() const runtime = stubRuntime({ resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }), @@ -454,7 +584,8 @@ describe('terminal send RPC', () => { isRunningAgent: true, status: 'permission' }), - sendTerminal: vi.fn() + sendTerminal: vi.fn(), + beginMobileInputFloor }) const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) @@ -463,7 +594,7 @@ describe('terminal send RPC', () => { terminal: 'terminal-1', enter: true, requireAgentStatus: 'sendable', - client: { id: 'desktop-1', type: 'desktop' } + client: { id: 'mobile-1', type: 'mobile' } }) ) @@ -481,6 +612,7 @@ describe('terminal send RPC', () => { }) expect(runtime.getTerminalAgentStatus).toHaveBeenCalledWith('terminal-1') expect(runtime.sendTerminal).not.toHaveBeenCalled() + expect(beginMobileInputFloor).not.toHaveBeenCalled() }) it('allows guarded terminal sends when the agent is sendable', async () => { diff --git a/src/main/runtime/rpc/terminal-subscribe-blank-mount.test.ts b/src/main/runtime/rpc/terminal-subscribe-blank-mount.test.ts new file mode 100644 index 00000000000..89a46d36073 --- /dev/null +++ b/src/main/runtime/rpc/terminal-subscribe-blank-mount.test.ts @@ -0,0 +1,235 @@ +/** STA-1840 regression: missing mobile terminal models request an exact renderer tab mount. */ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from './dispatcher' +import type { RpcRequest } from './core' +import type { OrcaRuntimeService } from '../orca-runtime' +import { TERMINAL_METHODS } from './methods/terminal' +import type { RuntimeTerminalWait } from '../../../shared/runtime-types' + +function stubRuntime(overrides: Partial = {}): OrcaRuntimeService { + return { + getRuntimeId: () => 'test-runtime', + registerRemoteTerminalViewSubscriber: () => () => {}, + requestRendererTerminalTabMount: () => false, + getRendererTerminalSerializerGenerationForHandle: () => 0, + getRendererTerminalSerializerGeneration: () => 0, + waitForRendererTerminalSerializer: async () => false, + getPtyOutputSequence: () => 0, + replaceHeadlessTerminalFromRendererSnapshotForRecovery: () => {}, + serializeRendererTerminalBuffer: async () => null, + hasHeadlessTerminalState: () => true, + ...overrides + } as OrcaRuntimeService +} + +const makeRequest = (method: string, params?: unknown): RpcRequest => ({ + id: 'req-1', + authToken: 'tok', + method, + params +}) + +describe('terminal.subscribe blank-tab background mount', () => { + it('does not mount a hidden tab for an already-aborted mobile subscribe', async () => { + const controller = new AbortController() + controller.abort() + const requestRendererTerminalTabMount = vi.fn(() => true) + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue(null), + requestRendererTerminalTabMount, + waitForLeafPtyId: vi.fn(), + readTerminal: vi.fn() + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + await dispatcher.dispatchStreaming( + makeRequest('terminal.subscribe', { + terminal: 'terminal-1', + client: { id: 'phone-1', type: 'mobile' }, + capabilities: { terminalBinaryStream: 1 } + }), + vi.fn(), + { + signal: controller.signal, + connectionId: 'conn-phone', + sendBinary: vi.fn(), + registerBinaryStreamHandler: vi.fn(() => vi.fn()) + } + ) + + expect(requestRendererTerminalTabMount).not.toHaveBeenCalled() + expect(runtime.waitForLeafPtyId).not.toHaveBeenCalled() + expect(runtime.readTerminal).not.toHaveBeenCalled() + }) + + it('requests a renderer tab mount when a mobile subscribe has no headless model', async () => { + // Why: stale preview text must not hide the missing live model/attachment. + const cleanups = new Map void>() + const callOrder: string[] = [] + const unsubscribeData = vi.fn() + const requestRendererTerminalTabMount = vi.fn(() => { + callOrder.push('request-mount') + return true + }) + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + requestRendererTerminalTabMount, + hasHeadlessTerminalState: vi.fn().mockReturnValue(false), + handleMobileSubscribe: vi.fn().mockResolvedValue(true), + handleMobileUnsubscribe: vi.fn(), + subscribeToTerminalData: vi.fn(() => { + callOrder.push('subscribe-data') + return unsubscribeData + }), + readTerminal: vi.fn().mockResolvedValue({ tail: ['stale preview'], truncated: false }), + serializeTerminalBuffer: vi + .fn() + .mockResolvedValue({ data: 'stale snapshot', cols: 80, rows: 24, seq: 4 }), + getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + isTerminalAlternateScreen: vi.fn().mockReturnValue(false), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + cleanups.get(id)?.() + cleanups.delete(id) + }), + waitForTerminal: vi.fn(() => new Promise(() => {})) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.subscribe', { + terminal: 'terminal-1', + client: { id: 'phone-1', type: 'mobile' }, + capabilities: { terminalBinaryStream: 1 } + }), + vi.fn(), + { + connectionId: 'conn-phone', + sendBinary: vi.fn(), + registerBinaryStreamHandler: vi.fn(() => vi.fn()) + } + ) + + await vi.waitFor(() => + expect(requestRendererTerminalTabMount).toHaveBeenCalledWith('terminal-1') + ) + expect(callOrder).toEqual(['subscribe-data', 'request-mount']) + + runtime.cleanupSubscription('terminal-1:phone-1') + await dispatchPromise + expect(unsubscribeData).toHaveBeenCalledTimes(1) + }) + + it('does not request a renderer tab mount when an attached terminal is legitimately blank', async () => { + const cleanups = new Map void>() + const requestRendererTerminalTabMount = vi.fn(() => true) + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + requestRendererTerminalTabMount, + handleMobileSubscribe: vi.fn().mockResolvedValue(true), + handleMobileUnsubscribe: vi.fn(), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn().mockResolvedValue(null), + getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + isTerminalAlternateScreen: vi.fn().mockReturnValue(false), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + cleanups.get(id)?.() + cleanups.delete(id) + }), + waitForTerminal: vi.fn(() => new Promise(() => {})) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.subscribe', { + terminal: 'terminal-1', + client: { id: 'phone-1', type: 'mobile' }, + capabilities: { terminalBinaryStream: 1 } + }), + vi.fn(), + { + connectionId: 'conn-phone', + sendBinary: vi.fn(), + registerBinaryStreamHandler: vi.fn(() => vi.fn()) + } + ) + + await vi.waitFor(() => expect(runtime.handleMobileSubscribe).toHaveBeenCalled()) + expect(requestRendererTerminalTabMount).not.toHaveBeenCalled() + + runtime.cleanupSubscription('terminal-1:phone-1') + await dispatchPromise + }) + + it('does not wait for a remount when the current snapshot came from the renderer', async () => { + const cleanups = new Map void>() + const requestRendererTerminalTabMount = vi.fn(() => true) + const waitForRendererTerminalSerializer = vi.fn() + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + requestRendererTerminalTabMount, + waitForRendererTerminalSerializer, + hasHeadlessTerminalState: vi.fn().mockReturnValue(false), + handleMobileSubscribe: vi.fn().mockResolvedValue(true), + handleMobileUnsubscribe: vi.fn(), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn().mockResolvedValue({ + data: 'current renderer prompt $ ', + cols: 80, + rows: 24, + source: 'renderer' + }), + getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + isTerminalAlternateScreen: vi.fn().mockReturnValue(false), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + cleanups.get(id)?.() + cleanups.delete(id) + }), + waitForTerminal: vi.fn(() => new Promise(() => {})) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.subscribe', { + terminal: 'terminal-1', + client: { id: 'phone-1', type: 'mobile' }, + capabilities: { terminalBinaryStream: 1 } + }), + vi.fn(), + { + connectionId: 'conn-phone', + sendBinary: vi.fn(), + registerBinaryStreamHandler: vi.fn(() => vi.fn()) + } + ) + + await vi.waitFor(() => expect(runtime.handleMobileSubscribe).toHaveBeenCalled()) + expect(requestRendererTerminalTabMount).not.toHaveBeenCalled() + expect(waitForRendererTerminalSerializer).not.toHaveBeenCalled() + + runtime.cleanupSubscription('terminal-1:phone-1') + await dispatchPromise + }) +}) diff --git a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts index 4a674f9df91..5c02a25326f 100644 --- a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts @@ -17,6 +17,7 @@ function stubRuntime(overrides: Partial = {}): OrcaRuntimeSe // Why: subscribe streams register as remote view subscribers for Phase-5 // query-authority suppression (terminal-query-authority.md). registerRemoteTerminalViewSubscriber: () => () => {}, + requestRendererTerminalTabMount: () => false, ...overrides } as OrcaRuntimeService } diff --git a/src/main/runtime/rpc/terminal-subscribe-lease-only.test.ts b/src/main/runtime/rpc/terminal-subscribe-lease-only.test.ts new file mode 100644 index 00000000000..fab3d5a09cd --- /dev/null +++ b/src/main/runtime/rpc/terminal-subscribe-lease-only.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RuntimeTerminalWait } from '../../../shared/runtime-types' +import type { OrcaRuntimeService } from '../orca-runtime' +import type { RpcRequest } from './core' +import { RpcDispatcher } from './dispatcher' +import { TERMINAL_METHODS } from './methods/terminal' + +const request: RpcRequest = { + id: 'req-1', + authToken: 'tok', + method: 'terminal.subscribe', + params: { + terminal: 'terminal-1', + client: { id: 'phone-1', type: 'mobile' }, + capabilities: { terminalBinaryStream: 1, mobileInputLeaseOnly: 1 } + } +} + +describe('terminal lease-only subscription', () => { + it('keeps mobile input ownership without registering output delivery', async () => { + const messages: string[] = [] + const cleanups = new Map void>() + const runtime = { + getRuntimeId: () => 'test-runtime', + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + handleMobileSubscribe: vi.fn().mockResolvedValue(true), + handleMobileUnsubscribe: vi.fn(), + subscribeToTerminalData: vi.fn(), + registerRemoteTerminalViewSubscriber: vi.fn(), + readTerminal: vi.fn(), + serializeTerminalBuffer: vi.fn(), + subscribeToTerminalResize: vi.fn(), + subscribeToFitOverrideChanges: vi.fn(), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + cleanups.get(id)?.() + cleanups.delete(id) + }), + waitForTerminal: vi.fn(() => new Promise(() => {})) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + request, + (message) => messages.push(message), + { + connectionId: 'conn-phone', + sendBinary: vi.fn(), + registerBinaryStreamHandler: vi.fn(() => vi.fn()) + } + ) + + await vi.waitFor(() => + expect(messages.some((message) => JSON.parse(message).result?.type === 'subscribed')).toBe( + true + ) + ) + expect(runtime.handleMobileSubscribe).toHaveBeenCalledWith('pty-1', 'phone-1', undefined) + expect(runtime.subscribeToTerminalData).not.toHaveBeenCalled() + expect(runtime.registerRemoteTerminalViewSubscriber).not.toHaveBeenCalled() + expect(runtime.readTerminal).not.toHaveBeenCalled() + expect(runtime.serializeTerminalBuffer).not.toHaveBeenCalled() + expect(runtime.subscribeToTerminalResize).not.toHaveBeenCalled() + expect(runtime.subscribeToFitOverrideChanges).not.toHaveBeenCalled() + + runtime.cleanupSubscription('terminal-1:phone-1') + await dispatchPromise + expect(runtime.handleMobileUnsubscribe).toHaveBeenCalledWith('pty-1', 'phone-1') + }) +}) diff --git a/src/main/runtime/rpc/terminal-subscribe-mount-replay.test.ts b/src/main/runtime/rpc/terminal-subscribe-mount-replay.test.ts new file mode 100644 index 00000000000..3a7614587f1 --- /dev/null +++ b/src/main/runtime/rpc/terminal-subscribe-mount-replay.test.ts @@ -0,0 +1,437 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RuntimeTerminalWait } from '../../../shared/runtime-types' +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame, + decodeTerminalStreamText +} from '../../../shared/terminal-stream-protocol' +import type { OrcaRuntimeService } from '../orca-runtime' +import type { RpcRequest } from './core' +import { RpcDispatcher } from './dispatcher' +import { TERMINAL_METHODS } from './methods/terminal' + +const request: RpcRequest = { + id: 'req-1', + authToken: 'tok', + method: 'terminal.subscribe', + params: { + terminal: 'terminal-1', + client: { id: 'phone-1', type: 'mobile' }, + capabilities: { terminalBinaryStream: 1 } + } +} + +describe('terminal subscribe mount replay', () => { + it('includes the restored idle screen when a missing model is background-mounted', async () => { + const binaryFrames: Uint8Array[] = [] + const cleanups = new Map void>() + let mounted = false + const runtime = { + getRuntimeId: () => 'test-runtime', + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + hasHeadlessTerminalState: vi.fn(() => mounted), + requestRendererTerminalTabMount: vi.fn(() => true), + getRendererTerminalSerializerGenerationForHandle: vi.fn(() => 3), + getRendererTerminalSerializerGeneration: vi.fn(() => 3), + getPtyOutputSequence: vi.fn(() => (mounted ? 4 : 0)), + replaceHeadlessTerminalFromRendererSnapshotForRecovery: vi.fn(), + waitForRendererTerminalSerializer: vi.fn(async (_ptyId, afterGeneration) => { + expect(afterGeneration).toBe(3) + mounted = true + return true + }), + handleMobileSubscribe: vi.fn().mockResolvedValue(true), + handleMobileUnsubscribe: vi.fn(), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + registerRemoteTerminalViewSubscriber: vi.fn(() => vi.fn()), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn(async () => + mounted + ? { data: 'idle prompt $ ', cols: 80, rows: 24, seq: 4 } + : { data: '', cols: 80, rows: 24, seq: 1 } + ), + serializeRendererTerminalBuffer: vi.fn(async () => + mounted ? { data: 'idle prompt $ ', cols: 80, rows: 24, seq: 4 } : null + ), + getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + isTerminalAlternateScreen: vi.fn().mockReturnValue(false), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + cleanups.get(id)?.() + cleanups.delete(id) + }), + waitForTerminal: vi.fn(() => new Promise(() => {})) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming(request, vi.fn(), { + connectionId: 'conn-phone', + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, + registerBinaryStreamHandler: vi.fn(() => vi.fn()) + }) + + await vi.waitFor(() => expect(runtime.requestRendererTerminalTabMount).toHaveBeenCalled()) + await vi.waitFor(() => + expect( + binaryFrames + .map((bytes) => decodeTerminalStreamFrame(bytes)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk) + .map((frame) => decodeTerminalStreamText(frame!.payload)) + .join('') + ).toContain('idle prompt $ ') + ) + + runtime.cleanupSubscription('terminal-1:phone-1') + await dispatchPromise + }) + + it('prefers restored history when phone-fit creates suffix-only headless state', async () => { + const binaryFrames: Uint8Array[] = [] + const cleanups = new Map void>() + let generation = 1 + let headlessPresent = false + let serializeCalls = 0 + const runtime = { + getRuntimeId: () => 'test-runtime', + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + hasHeadlessTerminalState: vi.fn(() => headlessPresent), + requestRendererTerminalTabMount: vi.fn(() => true), + getRendererTerminalSerializerGenerationForHandle: vi.fn(() => generation), + getRendererTerminalSerializerGeneration: vi.fn(() => generation), + getPtyOutputSequence: vi.fn(() => 0), + replaceHeadlessTerminalFromRendererSnapshotForRecovery: vi.fn(), + waitForRendererTerminalSerializer: vi.fn(async (_ptyId, afterGeneration) => { + return generation > afterGeneration + }), + handleMobileSubscribe: vi.fn(async () => { + headlessPresent = true + generation = 2 + return true + }), + handleMobileUnsubscribe: vi.fn(), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + registerRemoteTerminalViewSubscriber: vi.fn(() => vi.fn()), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn(async () => { + serializeCalls += 1 + if (serializeCalls === 1) { + return { data: 'suffix-only redraw', cols: 80, rows: 24, seq: 2 } + } + return { data: 'raced idle prompt $ ', cols: 80, rows: 24, seq: 5 } + }), + serializeRendererTerminalBuffer: vi.fn(async () => ({ + data: 'raced idle prompt $ ', + cols: 80, + rows: 24, + seq: 5 + })), + getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + isTerminalAlternateScreen: vi.fn().mockReturnValue(false), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + cleanups.get(id)?.() + cleanups.delete(id) + }), + waitForTerminal: vi.fn(() => new Promise(() => {})) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming(request, vi.fn(), { + connectionId: 'conn-phone', + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, + registerBinaryStreamHandler: vi.fn(() => vi.fn()) + }) + + await vi.waitFor(() => + expect( + binaryFrames + .map((bytes) => decodeTerminalStreamFrame(bytes)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk) + .map((frame) => decodeTerminalStreamText(frame!.payload)) + .join('') + ).toContain('raced idle prompt $ ') + ) + expect( + binaryFrames + .map((bytes) => decodeTerminalStreamFrame(bytes)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk) + .map((frame) => decodeTerminalStreamText(frame!.payload)) + .join('') + ).not.toContain('suffix-only redraw') + expect(runtime.waitForRendererTerminalSerializer).toHaveBeenCalledWith( + 'pty-1', + 1, + undefined, + expect.any(AbortSignal) + ) + + runtime.cleanupSubscription('terminal-1:phone-1') + await dispatchPromise + }) + + it('replays a late recovery when readiness lands after the bounded initial response', async () => { + vi.useFakeTimers() + const binaryFrames: Uint8Array[] = [] + const cleanups = new Map void>() + let mounted = false + let signalWaitStarted!: () => void + const waitStarted = new Promise((resolve) => { + signalWaitStarted = resolve + }) + const runtime = { + getRuntimeId: () => 'test-runtime', + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-delayed' }), + hasHeadlessTerminalState: vi.fn(() => false), + requestRendererTerminalTabMount: vi.fn(() => true), + getRendererTerminalSerializerGenerationForHandle: vi.fn(() => 1), + getRendererTerminalSerializerGeneration: vi.fn(() => 1), + getPtyOutputSequence: vi.fn(() => (mounted ? 4 : 0)), + replaceHeadlessTerminalFromRendererSnapshotForRecovery: vi.fn(), + waitForRendererTerminalSerializer: vi.fn( + (_ptyId: string, _afterGeneration: number, timeoutMs: number | undefined) => { + expect(timeoutMs).toBeUndefined() + signalWaitStarted() + return new Promise((resolve) => { + setTimeout(() => { + mounted = true + resolve(true) + }, 3_001) + }) + } + ), + handleMobileSubscribe: vi.fn().mockResolvedValue(true), + handleMobileUnsubscribe: vi.fn(), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + registerRemoteTerminalViewSubscriber: vi.fn(() => vi.fn()), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn(async () => ({ + data: mounted ? 'delayed idle prompt $ ' : 'late suffix-only redraw', + cols: 80, + rows: 24, + seq: mounted ? 4 : 1 + })), + serializeRendererTerminalBuffer: vi.fn(async () => + mounted ? { data: 'delayed idle prompt $ ', cols: 80, rows: 24, seq: 4 } : null + ), + getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + isTerminalAlternateScreen: vi.fn().mockReturnValue(false), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + cleanups.get(id)?.() + cleanups.delete(id) + }), + waitForTerminal: vi.fn(() => new Promise(() => {})) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming(request, vi.fn(), { + connectionId: 'conn-phone', + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, + registerBinaryStreamHandler: vi.fn(() => vi.fn()) + }) + + await waitStarted + await vi.advanceTimersByTimeAsync(3_000) + await Promise.resolve() + expect(binaryFrames.length).toBeGreaterThan(0) + expect( + binaryFrames + .map((bytes) => decodeTerminalStreamFrame(bytes)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk) + .map((frame) => decodeTerminalStreamText(frame!.payload)) + .join('') + ).toContain('late suffix-only redraw') + expect( + binaryFrames + .map((bytes) => decodeTerminalStreamFrame(bytes)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk) + .map((frame) => decodeTerminalStreamText(frame!.payload)) + .join('') + ).not.toContain('delayed idle prompt $ ') + await vi.advanceTimersByTimeAsync(1) + await Promise.resolve() + await Promise.resolve() + + expect( + binaryFrames + .map((bytes) => decodeTerminalStreamFrame(bytes)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk) + .map((frame) => decodeTerminalStreamText(frame!.payload)) + .join('') + ).toContain('delayed idle prompt $ ') + + runtime.cleanupSubscription('terminal-1:phone-1') + await dispatchPromise + vi.useRealTimers() + }) + + it('recovers from the pre-mount generation when suffix state appears during the PTY wait', async () => { + const binaryFrames: Uint8Array[] = [] + const cleanups = new Map void>() + let generation = 0 + let headlessPresent = false + const requestRendererTerminalTabMount = vi.fn(() => { + generation = 1 + return true + }) + const runtime = { + getRuntimeId: () => 'test-runtime', + resolveLeafForHandle: vi.fn().mockReturnValue(null), + waitForLeafPtyId: vi.fn(async () => { + // A redraw may reach main before the PTY wait completes, but it does + // not contain the legacy terminal's restored history. + headlessPresent = true + return 'pty-late-leaf' + }), + hasHeadlessTerminalState: vi.fn(() => headlessPresent), + requestRendererTerminalTabMount, + getRendererTerminalSerializerGenerationForHandle: vi.fn(() => 0), + getRendererTerminalSerializerGeneration: vi.fn(() => generation), + getPtyOutputSequence: vi.fn(() => 0), + replaceHeadlessTerminalFromRendererSnapshotForRecovery: vi.fn(), + waitForRendererTerminalSerializer: vi.fn(async (_ptyId, afterGeneration) => { + return generation > afterGeneration + }), + handleMobileSubscribe: vi.fn().mockResolvedValue(true), + handleMobileUnsubscribe: vi.fn(), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + registerRemoteTerminalViewSubscriber: vi.fn(() => vi.fn()), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn().mockResolvedValue({ + data: 'suffix-only redraw', + cols: 80, + rows: 24, + seq: 1 + }), + serializeRendererTerminalBuffer: vi.fn().mockResolvedValue({ + data: 'late leaf prompt $ ', + cols: 80, + rows: 24 + }), + getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + isTerminalAlternateScreen: vi.fn().mockReturnValue(false), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + cleanups.get(id)?.() + cleanups.delete(id) + }), + waitForTerminal: vi.fn(() => new Promise(() => {})) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming(request, vi.fn(), { + connectionId: 'conn-phone', + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, + registerBinaryStreamHandler: vi.fn(() => vi.fn()) + }) + + await vi.waitFor(() => + expect( + binaryFrames + .map((bytes) => decodeTerminalStreamFrame(bytes)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk) + .map((frame) => decodeTerminalStreamText(frame!.payload)) + .join('') + ).toContain('late leaf prompt $ ') + ) + expect(requestRendererTerminalTabMount).toHaveBeenCalledTimes(1) + expect(runtime.waitForRendererTerminalSerializer).toHaveBeenCalledWith( + 'pty-late-leaf', + 0, + undefined, + expect.any(AbortSignal) + ) + + runtime.cleanupSubscription('terminal-1:phone-1') + await dispatchPromise + }) + + it('cancels the mount-ready wait when the mobile subscription closes', async () => { + const cleanups = new Map void>() + let waitSignal: AbortSignal | undefined + const runtime = { + getRuntimeId: () => 'test-runtime', + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + hasHeadlessTerminalState: vi.fn(() => false), + requestRendererTerminalTabMount: vi.fn(() => true), + getRendererTerminalSerializerGenerationForHandle: vi.fn(() => 1), + getRendererTerminalSerializerGeneration: vi.fn(() => 1), + getPtyOutputSequence: vi.fn(() => 0), + replaceHeadlessTerminalFromRendererSnapshotForRecovery: vi.fn(), + waitForRendererTerminalSerializer: vi.fn( + (_ptyId: string, _afterGeneration: number, _timeoutMs: number, signal?: AbortSignal) => { + waitSignal = signal + return new Promise((resolve) => { + signal?.addEventListener('abort', () => resolve(false), { once: true }) + }) + } + ), + handleMobileSubscribe: vi.fn().mockResolvedValue(true), + handleMobileUnsubscribe: vi.fn(), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + registerRemoteTerminalViewSubscriber: vi.fn(() => vi.fn()), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn().mockResolvedValue(null), + serializeRendererTerminalBuffer: vi.fn().mockResolvedValue(null), + getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + isTerminalAlternateScreen: vi.fn().mockReturnValue(false), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + cleanups.get(id)?.() + cleanups.delete(id) + }), + waitForTerminal: vi.fn(() => new Promise(() => {})) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming(request, vi.fn(), { + connectionId: 'conn-phone', + sendBinary: vi.fn(), + registerBinaryStreamHandler: vi.fn(() => vi.fn()) + }) + + await vi.waitFor(() => expect(runtime.waitForRendererTerminalSerializer).toHaveBeenCalled()) + runtime.cleanupSubscription('terminal-1:phone-1') + + expect(waitSignal?.aborted).toBe(true) + await dispatchPromise + }) +}) diff --git a/src/main/runtime/rpc/terminal-subscribe-renderer-recovery-output.test.ts b/src/main/runtime/rpc/terminal-subscribe-renderer-recovery-output.test.ts new file mode 100644 index 00000000000..91cd571e0f4 --- /dev/null +++ b/src/main/runtime/rpc/terminal-subscribe-renderer-recovery-output.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RuntimeTerminalWait } from '../../../shared/runtime-types' +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame, + decodeTerminalStreamText +} from '../../../shared/terminal-stream-protocol' +import type { OrcaRuntimeService } from '../orca-runtime' +import type { RpcRequest } from './core' +import { RpcDispatcher } from './dispatcher' +import { TERMINAL_METHODS } from './methods/terminal' + +const request: RpcRequest = { + id: 'req-output-race', + authToken: 'tok', + method: 'terminal.subscribe', + params: { + terminal: 'terminal-output-race', + client: { id: 'phone-output-race', type: 'mobile' }, + capabilities: { terminalBinaryStream: 1 } + } +} + +describe('terminal subscribe renderer recovery output ordering', () => { + it('replays bytes absent from the renderer snapshot using its exact sequence boundary', async () => { + const binaryFrames: Uint8Array[] = [] + const cleanups = new Map void>() + let outputSequence = 0 + let rendererSerializeCalls = 0 + let onData: + | ((data: string, meta?: { seq?: number; rawLength?: number; cwd?: string }) => void) + | undefined + const runtime = { + getRuntimeId: () => 'test-runtime', + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-output-race' }), + hasHeadlessTerminalState: vi.fn(() => false), + requestRendererTerminalTabMount: vi.fn(() => true), + getRendererTerminalSerializerGenerationForHandle: vi.fn(() => 1), + getRendererTerminalSerializerGeneration: vi.fn(() => 1), + waitForRendererTerminalSerializer: vi.fn().mockResolvedValue(true), + getPtyOutputSequence: vi.fn(() => outputSequence), + handleMobileSubscribe: vi.fn().mockResolvedValue(true), + handleMobileUnsubscribe: vi.fn(), + subscribeToTerminalData: vi.fn((_ptyId, listener) => { + onData = listener + return vi.fn() + }), + registerRemoteTerminalViewSubscriber: vi.fn(() => vi.fn()), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn().mockResolvedValue(null), + serializeRendererTerminalBuffer: vi.fn(async () => { + rendererSerializeCalls += 1 + if (rendererSerializeCalls === 1) { + outputSequence = 6 + onData?.('during', { seq: 6, rawLength: 6 }) + return { data: 'stale renderer', cols: 80, rows: 24 } + } + return { data: 'restored history', cols: 80, rows: 24, seq: 0 } + }), + replaceHeadlessTerminalFromRendererSnapshotForRecovery: vi.fn(() => { + outputSequence = 11 + onData?.('after', { seq: 11, rawLength: 5 }) + }), + getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + isTerminalAlternateScreen: vi.fn().mockReturnValue(false), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + cleanups.get(id)?.() + cleanups.delete(id) + }), + waitForTerminal: vi.fn(() => new Promise(() => {})) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming(request, vi.fn(), { + connectionId: 'conn-phone', + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, + registerBinaryStreamHandler: vi.fn(() => vi.fn()) + }) + + await vi.waitFor(() => { + const output = binaryFrames + .map((bytes) => decodeTerminalStreamFrame(bytes)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + .map((frame) => decodeTerminalStreamText(frame!.payload)) + .join('') + expect(output).toBe('duringafter') + }) + + const snapshot = binaryFrames + .map((bytes) => decodeTerminalStreamFrame(bytes)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk) + .map((frame) => decodeTerminalStreamText(frame!.payload)) + .join('') + expect(snapshot).toBe('restored history') + expect(rendererSerializeCalls).toBe(2) + + runtime.cleanupSubscription('terminal-output-race:phone-output-race') + await dispatchPromise + }) +}) diff --git a/src/main/runtime/runtime-mobile-file-path-search.test.ts b/src/main/runtime/runtime-mobile-file-path-search.test.ts new file mode 100644 index 00000000000..495a7933842 --- /dev/null +++ b/src/main/runtime/runtime-mobile-file-path-search.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from 'vitest' +import { + rankRuntimeMobileFilePaths, + type RuntimeMobileFilePathInventory, + RuntimeMobileFilePathSearchCache +} from './runtime-mobile-file-path-search' + +describe('rankRuntimeMobileFilePaths', () => { + it('ranks path and basename prefixes before substrings and caps output', () => { + expect( + rankRuntimeMobileFilePaths( + ['docs/apple.md', 'src/apple.ts', 'src/deep/pineapple.ts', 'z/apple.test.ts'], + 'apple', + 3 + ) + ).toEqual({ + paths: ['docs/apple.md', 'src/apple.ts', 'z/apple.test.ts'], + totalCount: 4 + }) + }) +}) + +describe('RuntimeMobileFilePathSearchCache', () => { + it('reuses a live entry, reloads after expiry, and evicts the oldest key', async () => { + const cache = new RuntimeMobileFilePathSearchCache(2, 100) + const load = vi.fn(async (path: string) => ({ + paths: [path], + totalCount: 1, + truncated: false + })) + + await cache.get('a', () => load('a'), 0) + await cache.get('a', () => load('a-again'), 50) + await cache.get('b', () => load('b'), 50) + await cache.get('c', () => load('c'), 50) + await cache.get('a', () => load('a-reloaded'), 50) + + expect(load.mock.calls.map((call) => call[0])).toEqual(['a', 'b', 'c', 'a-reloaded']) + }) + + it('reloads an unevicted key once its TTL has elapsed', async () => { + // TTL expiry without capacity pressure — exercises the now >= expiresAt path + // that LRU eviction otherwise masks. + const cache = new RuntimeMobileFilePathSearchCache(2, 100) + const load = vi.fn(async (path: string) => ({ paths: [path], totalCount: 1, truncated: false })) + + await cache.get('a', () => load('a'), 0) + await cache.get('a', () => load('a-live'), 50) + await cache.get('a', () => load('a-expired'), 150) + + expect(load.mock.calls.map((call) => call[0])).toEqual(['a', 'a-expired']) + }) + + it('coalesces concurrent cold loads for the same worktree', async () => { + const cache = new RuntimeMobileFilePathSearchCache(2, 100) + let resolveLoad: (value: RuntimeMobileFilePathInventory) => void = () => {} + const load = vi.fn( + () => + new Promise((resolve) => { + resolveLoad = resolve + }) + ) + + const first = cache.get('a', load, 0) + const second = cache.get('a', load, 0) + expect(load).toHaveBeenCalledOnce() + + resolveLoad({ paths: ['a'], totalCount: 1, truncated: false }) + await expect(Promise.all([first, second])).resolves.toEqual([ + { paths: ['a'], totalCount: 1, truncated: false }, + { paths: ['a'], totalCount: 1, truncated: false } + ]) + }) + + it('starts the TTL when a slow inventory becomes usable', async () => { + vi.useFakeTimers() + try { + vi.setSystemTime(0) + const cache = new RuntimeMobileFilePathSearchCache(2, 100) + let resolveLoad: (value: RuntimeMobileFilePathInventory) => void = () => {} + const load = vi.fn( + () => + new Promise((resolve) => { + resolveLoad = resolve + }) + ) + const first = cache.get('slow-ssh', load) + vi.setSystemTime(200) + resolveLoad({ paths: ['a'], totalCount: 1, truncated: false }) + await first + + await cache.get('slow-ssh', load) + expect(load).toHaveBeenCalledOnce() + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/main/runtime/runtime-mobile-file-path-search.ts b/src/main/runtime/runtime-mobile-file-path-search.ts new file mode 100644 index 00000000000..13213d6dfcf --- /dev/null +++ b/src/main/runtime/runtime-mobile-file-path-search.ts @@ -0,0 +1,93 @@ +export type RuntimeMobileFilePathInventory = { + paths: string[] + totalCount: number + truncated: boolean +} + +type CacheEntry = RuntimeMobileFilePathInventory & { expiresAt: number } + +/** Lazy TTL/LRU cache for autocomplete inventories. It avoids launching rg for + * every mobile keystroke while bounding retained worktrees and paths. */ +export class RuntimeMobileFilePathSearchCache { + private readonly entries = new Map() + private readonly inFlight = new Map>() + + constructor( + private readonly maxEntries: number, + private readonly ttlMs: number + ) {} + + async get( + key: string, + load: () => Promise, + now?: number + ): Promise { + const requestedAt = now ?? Date.now() + const cached = this.entries.get(key) + if (cached && cached.expiresAt > requestedAt) { + this.entries.delete(key) + this.entries.set(key, cached) + return cached + } + this.entries.delete(key) + const pending = this.inFlight.get(key) + if (pending) { + return pending + } + const next = load() + .then((loaded) => { + // Why: a slow SSH scan should receive a full TTL after it becomes usable, + // not arrive already expired because the clock started before its I/O. + this.entries.set(key, { ...loaded, expiresAt: (now ?? Date.now()) + this.ttlMs }) + while (this.entries.size > this.maxEntries) { + const oldest = this.entries.keys().next().value as string | undefined + if (!oldest) { + break + } + this.entries.delete(oldest) + } + return loaded + }) + .finally(() => { + if (this.inFlight.get(key) === next) { + this.inFlight.delete(key) + } + }) + // Why: debounced clients can overlap on a cold key; sharing this promise + // prevents duplicate local rg or SSH inventory scans. + this.inFlight.set(key, next) + return next + } +} + +/** Preserves composer ranking: full-path/basename prefixes first, then substring + * matches, while returning only the requested bounded candidate slice. */ +export function rankRuntimeMobileFilePaths( + paths: readonly string[], + query: string, + limit: number +): { paths: string[]; totalCount: number } { + const normalizedQuery = query.trim().toLowerCase() + if (!normalizedQuery) { + return { paths: paths.slice(0, limit), totalCount: paths.length } + } + const prefix: string[] = [] + const substring: string[] = [] + let totalCount = 0 + for (const path of paths) { + const lower = path.toLowerCase() + const basename = lower.split('/').pop() ?? lower + if (lower.startsWith(normalizedQuery) || basename.startsWith(normalizedQuery)) { + totalCount++ + if (prefix.length < limit) { + prefix.push(path) + } + } else if (lower.includes(normalizedQuery)) { + totalCount++ + if (substring.length < limit) { + substring.push(path) + } + } + } + return { paths: [...prefix, ...substring].slice(0, limit), totalCount } +} diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 62e1af606df..d7623065634 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -199,6 +199,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'files.readTerminalArtifact', 'files.readTerminalArtifactPreview', 'files.resolveTerminalPath', + 'files.searchPaths', 'files.writeTerminalArtifact', 'folderWorkspace.list', 'git.abortMerge', @@ -376,6 +377,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'agentTeams.tmuxCompat', 'terminal.clearBuffer', 'terminal.close', + 'terminal.closeTab', 'terminal.create', 'terminal.focus', 'terminal.agentStatus', diff --git a/src/main/runtime/terminal-mobile-subscribe-tab-mount.test.ts b/src/main/runtime/terminal-mobile-subscribe-tab-mount.test.ts new file mode 100644 index 00000000000..d84283e9181 --- /dev/null +++ b/src/main/runtime/terminal-mobile-subscribe-tab-mount.test.ts @@ -0,0 +1,162 @@ +/** STA-1840 regression: known blank-terminal handles request a bounded renderer mount. */ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' + +type HandleSeed = { + handle: string + worktreeId: string + tabId: string + ptyId: string | null +} + +type RuntimeInternals = { + handles: Map< + string, + HandleSeed & { + runtimeId: string + rendererGraphEpoch: number + leafId: string + ptyGeneration: number + } + > + getAuthoritativeWindow: () => { + webContents: { send: (channel: string, payload: unknown) => void } + } + leaves: Map + issueHandle: (leaf: unknown) => string +} + +function seedRuntime(seeds: HandleSeed[]): { + runtime: OrcaRuntimeService + send: ReturnType +} { + const runtime = new OrcaRuntimeService() + const internals = runtime as unknown as RuntimeInternals + for (const seed of seeds) { + internals.handles.set(seed.handle, { + ...seed, + runtimeId: 'rt-test', + rendererGraphEpoch: 1, + leafId: 'leaf-1', + ptyGeneration: 1 + }) + } + const send = vi.fn() + internals.getAuthoritativeWindow = () => ({ webContents: { send } }) + return { runtime, send } +} + +describe('mobile terminal subscribe tab mount', () => { + it('keeps a waiting real-tab handle usable when the mount binds its first PTY', async () => { + const runtime = new OrcaRuntimeService() + const syncGraph = (ptyId: string | null): void => { + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: 'wt-1', + title: 'Terminal', + activeLeafId: 'leaf-1', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: 'wt-1', + leafId: 'leaf-1', + paneRuntimeId: 1, + ptyId + } + ] + }) + } + + runtime.attachWindow(1) + syncGraph(null) + const internals = runtime as unknown as RuntimeInternals + const leaf = internals.leaves.values().next().value + if (!leaf) { + throw new Error('expected terminal leaf') + } + const handle = internals.issueHandle(leaf) + const ptyWait = runtime.waitForLeafPtyId(handle) + + syncGraph('pty-1') + + await expect(ptyWait).resolves.toBe('pty-1') + expect(runtime.resolveLiveLeafForHandle(handle)).toEqual({ ptyId: 'pty-1' }) + await expect(runtime.readTerminal(handle)).resolves.toMatchObject({ handle }) + }) + + it('requests a tab mount by tabId for a real-tab handle awaiting its PTY (null-leaf blank path)', () => { + const { runtime, send } = seedRuntime([ + { handle: 'h1', worktreeId: 'wt-1', tabId: 'tab-1', ptyId: null } + ]) + + runtime.requestRendererTerminalTabMount('h1') + + expect(send).toHaveBeenCalledWith('terminal:requestTabMount', { + worktreeId: 'wt-1', + tabId: 'tab-1' + }) + }) + + it('requests a tab mount by tabId even when a real-tab handle carries a ptyId', () => { + const { runtime, send } = seedRuntime([ + { handle: 'h1', worktreeId: 'wt-1', tabId: 'tab-1', ptyId: 'wt-1@@abc' } + ]) + + runtime.requestRendererTerminalTabMount('h1') + + expect(send).toHaveBeenCalledWith('terminal:requestTabMount', { + worktreeId: 'wt-1', + tabId: 'tab-1', + ptyId: 'wt-1@@abc' + }) + }) + + it('requests a mount by ptyId for a synthetic pty-form handle (never-mounted workspace)', () => { + // Why: never-mounted workspaces expose only synthetic pty handles to mobile. + const { runtime, send } = seedRuntime([ + { handle: 'h1', worktreeId: 'wt-1', tabId: 'pty:wt-1@@abc', ptyId: 'wt-1@@abc' } + ]) + + runtime.requestRendererTerminalTabMount('h1') + + expect(send).toHaveBeenCalledWith('terminal:requestTabMount', { + worktreeId: 'wt-1', + ptyId: 'wt-1@@abc' + }) + }) + + it('does not request a mount when a pty-form handle has no ptyId', () => { + const { runtime, send } = seedRuntime([ + { handle: 'h1', worktreeId: 'wt-1', tabId: 'pty:abc', ptyId: null } + ]) + + runtime.requestRendererTerminalTabMount('h1') + + expect(send).not.toHaveBeenCalled() + }) + + it('does not request a mount for an unknown handle', () => { + const { runtime, send } = seedRuntime([]) + + runtime.requestRendererTerminalTabMount('missing') + + expect(send).not.toHaveBeenCalled() + }) + + it('swallows window lookup failures so subscribe keeps its fallback', () => { + const { runtime } = seedRuntime([ + { handle: 'h1', worktreeId: 'wt-1', tabId: 'tab-1', ptyId: null } + ]) + const internals = runtime as unknown as RuntimeInternals + internals.getAuthoritativeWindow = () => { + throw new Error('no window') + } + + expect(() => runtime.requestRendererTerminalTabMount('h1')).not.toThrow() + }) +}) diff --git a/src/main/skills/discovery.test.ts b/src/main/skills/discovery.test.ts index 68ff5e9d017..d65f30dc391 100644 --- a/src/main/skills/discovery.test.ts +++ b/src/main/skills/discovery.test.ts @@ -83,6 +83,41 @@ describe('skill discovery', () => { } }) + it('does not add runtime-owned repository paths to local scan roots', () => { + const runtimeRepo = makeRepo('/runtime/repo') + runtimeRepo.executionHostId = 'runtime:environment-1' + + const roots = buildSkillDiscoverySources({ + homeDir: '/home/test', + cwd: '/workspace/current', + repos: [runtimeRepo] + }) + + expect(roots.map((root) => root.path.replace(/\\/g, '/'))).not.toContain( + '/runtime/repo/.agents/skills' + ) + }) + + it('can exclude the implicit cwd without excluding explicit local repositories', () => { + const defaultRoots = buildSkillDiscoverySources({ + homeDir: '/home/test', + cwd: '/workspace/current', + repos: [makeRepo('/workspace/known')] + }) + const explicitRoots = buildSkillDiscoverySources({ + homeDir: '/home/test', + cwd: '/workspace/current', + repos: [makeRepo('/workspace/known')], + includeCwd: false + }) + + const normalizedDefaultPaths = defaultRoots.map((root) => root.path.replace(/\\/g, '/')) + const normalizedExplicitPaths = explicitRoots.map((root) => root.path.replace(/\\/g, '/')) + expect(normalizedDefaultPaths).toContain('/workspace/current/.agents/skills') + expect(normalizedExplicitPaths).not.toContain('/workspace/current/.agents/skills') + expect(normalizedExplicitPaths).toContain('/workspace/known/.agents/skills') + }) + it('discovers skill packages through symlinked skill directories', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-skills-')) const home = join(root, 'home') diff --git a/src/main/skills/discovery.ts b/src/main/skills/discovery.ts index d84f82af426..ed74ef3ffa6 100644 --- a/src/main/skills/discovery.ts +++ b/src/main/skills/discovery.ts @@ -234,6 +234,7 @@ export async function discoverSkills(args: { repos?: Repo[] homeDir?: string cwd?: string + includeCwd?: boolean }): Promise { const roots = buildSkillDiscoverySources(args) const sources: SkillDiscoverySource[] = [] diff --git a/src/main/skills/skill-bundle-artifacts.test.ts b/src/main/skills/skill-bundle-artifacts.test.ts new file mode 100644 index 00000000000..f86d5c382d7 --- /dev/null +++ b/src/main/skills/skill-bundle-artifacts.test.ts @@ -0,0 +1,37 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { loadSkillBundleArtifacts } from './skill-bundle-artifacts' + +const temporaryDirectories: string[] = [] + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((root) => rm(root, { recursive: true }))) +}) + +describe('skill bundle artifacts', () => { + it('rejects malformed nested release entries before building provenance', async () => { + const resourceRoot = await mkdtemp(join(tmpdir(), 'orca-skill-artifacts-')) + temporaryDirectories.push(resourceRoot) + const target = join(resourceRoot, 'skills') + const source = resolve('resources', 'skills') + await mkdir(target, { recursive: true }) + const [manifest, registry, releaseMapping] = await Promise.all( + ['current-manifest.json', 'snapshot-registry.json', 'release-mapping.json'].map((name) => + readFile(join(source, name), 'utf8') + ) + ) + const malformedMapping = JSON.parse(releaseMapping) + malformedMapping.releases[0] = { appVersion: 'invalid' } + await Promise.all([ + writeFile(join(target, 'current-manifest.json'), manifest), + writeFile(join(target, 'snapshot-registry.json'), registry), + writeFile(join(target, 'release-mapping.json'), JSON.stringify(malformedMapping)) + ]) + + await expect(loadSkillBundleArtifacts(resourceRoot)).rejects.toThrow( + 'Invalid skill release mapping' + ) + }) +}) diff --git a/src/main/skills/skill-bundle-artifacts.ts b/src/main/skills/skill-bundle-artifacts.ts new file mode 100644 index 00000000000..e7d403442b5 --- /dev/null +++ b/src/main/skills/skill-bundle-artifacts.ts @@ -0,0 +1,164 @@ +import { app } from 'electron' +import { readFile } from 'node:fs/promises' +import { join, resolve } from 'node:path' +import { z, type ZodType } from 'zod' +import type { + SkillBundleManifest, + SkillKnownSnapshot, + SkillReleaseMapping, + SkillSnapshotRegistry +} from '../../shared/skill-freshness' + +export type SkillBundleArtifacts = { + manifest: SkillBundleManifest + registry: SkillSnapshotRegistry + releaseMapping: SkillReleaseMapping + knownSnapshots: Record + releasedAppVersions: Record> +} + +const sha256Schema = z.string().regex(/^[a-f0-9]{64}$/) +const snapshotShape = { + releaseRevision: z.number().int().positive(), + packageDigest: sha256Schema, + gitTreeSha: z.string().regex(/^[a-f0-9]{40}$/), + files: z + .array( + z + .object({ + path: z.string().min(1), + size: z.number().int().nonnegative(), + executable: z.boolean(), + classification: z.enum(['text', 'binary']), + exactSha256: sha256Schema, + textNormalizedSha256: sha256Schema.nullable(), + identitySha256: sha256Schema + }) + .strict() + ) + .min(1) +} +const knownSnapshotSchema = z.object(snapshotShape).strict() +const manifestSchema = z + .object({ + schemaVersion: z.literal(1), + appVersion: z.string().min(1), + skills: z.array( + z + .object({ + name: z.string().regex(/^[a-z0-9][a-z0-9._-]*$/), + sourcePath: z.string().min(1), + appVersion: z.string().min(1), + ...snapshotShape + }) + .strict() + ) + }) + .strict() +const registrySchema = z + .object({ + schemaVersion: z.literal(1), + skills: z.record(z.string().min(1), z.array(knownSnapshotSchema).min(1)) + }) + .strict() +const releaseMappingSchema = z + .object({ + schemaVersion: z.literal(1), + releases: z.array( + z + .object({ + appVersion: z.string().min(1), + skills: z.record(z.string().min(1), z.number().int().positive()) + }) + .strict() + ) + }) + .strict() + +function parseArtifact(schema: ZodType, value: unknown, label: string): T { + const result = schema.safeParse(value) + if (!result.success) { + throw new Error(`Invalid ${label}: ${result.error.issues[0]?.message ?? 'schema mismatch'}`) + } + return result.data +} + +const artifactsByResourceRoot = new Map>() + +// Why: the artifacts ship with the binary and never change within a run, while +// focus-triggered rescans would otherwise re-read and re-parse them every time. +export function loadSkillBundleArtifacts( + resourceRoot = app.isPackaged ? process.resourcesPath : resolve(process.cwd(), 'resources') +): Promise { + const cached = artifactsByResourceRoot.get(resourceRoot) + if (cached) { + return cached + } + const loading = readSkillBundleArtifacts(resourceRoot) + artifactsByResourceRoot.set(resourceRoot, loading) + loading.catch(() => { + artifactsByResourceRoot.delete(resourceRoot) + }) + return loading +} + +async function readSkillBundleArtifacts(resourceRoot: string): Promise { + const bundleRoot = join(resourceRoot, 'skills') + const [manifestValue, registryValue, releaseMappingValue] = await Promise.all([ + readFile(join(bundleRoot, 'current-manifest.json'), 'utf8').then(JSON.parse), + readFile(join(bundleRoot, 'snapshot-registry.json'), 'utf8').then(JSON.parse), + readFile(join(bundleRoot, 'release-mapping.json'), 'utf8').then(JSON.parse) + ]) + const manifest: SkillBundleManifest = parseArtifact( + manifestSchema, + manifestValue, + 'skill bundle manifest' + ) + const registry: SkillSnapshotRegistry = parseArtifact( + registrySchema, + registryValue, + 'skill snapshot registry' + ) + const releaseMapping: SkillReleaseMapping = parseArtifact( + releaseMappingSchema, + releaseMappingValue, + 'skill release mapping' + ) + for (const current of manifest.skills) { + if ( + current.appVersion !== manifest.appVersion || + !registry.skills[current.name]?.some( + (snapshot) => + snapshot.releaseRevision === current.releaseRevision && + snapshot.packageDigest === current.packageDigest + ) + ) { + throw new Error(`Inconsistent current skill snapshot: ${current.name}`) + } + } + + const releasedAppVersions: Record> = {} + for (const release of releaseMapping.releases) { + for (const [name, revision] of Object.entries(release.skills)) { + if (!registry.skills[name]?.some((snapshot) => snapshot.releaseRevision === revision)) { + throw new Error(`Unknown released skill revision: ${name}@${revision}`) + } + releasedAppVersions[name] ??= {} + releasedAppVersions[name][revision] ??= release.appVersion + } + } + for (const current of manifest.skills) { + releasedAppVersions[current.name] ??= {} + releasedAppVersions[current.name][current.releaseRevision] = current.appVersion + } + + return { + manifest, + registry, + releaseMapping, + // Why: newer-known classification needs every identity packaged with this + // build, while release mapping remains the provenance record for shipped revisions. + knownSnapshots: registry.skills, + releasedAppVersions + } +} diff --git a/src/main/skills/skill-candidate-concurrency.ts b/src/main/skills/skill-candidate-concurrency.ts new file mode 100644 index 00000000000..c81f1942ce9 --- /dev/null +++ b/src/main/skills/skill-candidate-concurrency.ts @@ -0,0 +1,24 @@ +const MAX_CONCURRENT_SKILL_CANDIDATES = 4 + +export async function runSkillCandidateTasks( + tasks: readonly (() => Promise)[] +): Promise { + const results = Array.from({ length: tasks.length }) + let nextIndex = 0 + + async function worker(): Promise { + for (;;) { + const index = nextIndex + nextIndex += 1 + if (index >= tasks.length) { + return + } + results[index] = await tasks[index]() + } + } + + await Promise.all( + Array.from({ length: Math.min(MAX_CONCURRENT_SKILL_CANDIDATES, tasks.length) }, () => worker()) + ) + return results +} diff --git a/src/main/skills/skill-discovery-sources.ts b/src/main/skills/skill-discovery-sources.ts index bec34117c4c..ba3726f43e0 100644 --- a/src/main/skills/skill-discovery-sources.ts +++ b/src/main/skills/skill-discovery-sources.ts @@ -3,6 +3,7 @@ import { homedir } from 'node:os' import { basename, join } from 'node:path' import type { SkillDiscoverySource, SkillProvider, SkillSourceKind } from '../../shared/skills' import type { Repo } from '../../shared/types' +import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' export type SkillScanRoot = Omit @@ -25,6 +26,7 @@ export function buildSkillDiscoverySources( homeDir?: string cwd?: string repos?: Repo[] + includeCwd?: boolean } = {} ): SkillScanRoot[] { const home = args.homeDir ?? homedir() @@ -62,12 +64,16 @@ export function buildSkillDiscoverySources( const projectPaths = new Set() for (const repo of args.repos ?? []) { - if (repo.connectionId) { + // Why: runtime-owned repos can have no legacy connectionId while their + // paths are meaningful only on a remote host. + if (getRepoExecutionHostId(repo) !== LOCAL_EXECUTION_HOST_ID) { continue } projectPaths.add(repo.path) } - projectPaths.add(cwd) + if (args.includeCwd !== false) { + projectPaths.add(cwd) + } for (const repoPath of projectPaths) { const label = `Repo ${basename(repoPath)}` diff --git a/src/main/skills/skill-freshness-eligibility.test.ts b/src/main/skills/skill-freshness-eligibility.test.ts new file mode 100644 index 00000000000..e3754197559 --- /dev/null +++ b/src/main/skills/skill-freshness-eligibility.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest' +import { + buildTargetedSkillUpdateCommand, + type SkillFreshnessInstallation +} from '../../shared/skill-freshness' +import { eligibleSkillUpdateNames } from './skill-freshness-eligibility' + +function placement( + name: string, + overrides: Partial = {} +): SkillFreshnessInstallation { + return { + id: `${name}-${overrides.rootId ?? 'home-agents'}`, + name, + rootId: 'home-agents', + providers: ['agent-skills'], + sourceKind: 'home', + sourceLabel: 'Agent skills home', + unresolvedPath: `/home/.agents/skills/${name}`, + resolvedPath: `/home/.agents/skills/${name}`, + physicalIdentity: `physical-${name}`, + topology: 'canonical-copy', + status: 'outdated', + installedReleaseRevision: 1, + installedAppVersion: '1.0.0', + currentReleaseRevision: 2, + currentPackageDigest: 'current', + currentAppVersion: '2.0.0', + observedPackageDigest: 'old', + errorCategory: null, + ...overrides + } +} + +describe('skill freshness name-scoped update eligibility', () => { + it('offers a name when at least one supported placement is outdated and all are official', () => { + expect( + eligibleSkillUpdateNames([ + placement('orca-cli'), + placement('orca-cli', { + id: 'orca-cli-claude', + rootId: 'home-claude', + topology: 'provider-alias', + status: 'current' + }) + ]) + ).toEqual(['orca-cli']) + }) + + it.each([ + ['newer-known', 'independent-copy'], + ['unrecognized', 'independent-copy'], + ['inaccessible', 'broken-link'], + ['current', 'external-link'], + ['current', 'read-only'], + ['current', 'repo-scope'], + ['current', 'plugin-cache'] + ] as const)('poisons a name for a %s placement in %s topology', (status, topology) => { + expect( + eligibleSkillUpdateNames([ + placement('orca-cli'), + placement('orca-cli', { id: `poison-${status}-${topology}`, status, topology }) + ]) + ).toEqual([]) + }) + + it('still updates the canonical copy when a clean standalone duplicate exists', () => { + // Why: a duplicate no longer omits the whole name — the canonical copy converges + // and the duplicate row is flagged as maybe-not-reached rather than blocking. + expect( + eligibleSkillUpdateNames([ + placement('orca-cli'), + placement('orca-cli', { + id: 'orca-cli-gemini', + rootId: 'home-gemini', + unresolvedPath: '/home/.gemini/skills/orca-cli', + resolvedPath: '/home/.gemini/skills/orca-cli', + topology: 'independent-copy', + status: 'current' + }) + ]) + ).toEqual(['orca-cli']) + }) + + it('does not offer a skill that exists only as a standalone copy', () => { + // Why: with no canonical or alias to anchor `--global`, the command has no + // reliable target, so a duplicate-only skill stays unoffered. + expect( + eligibleSkillUpdateNames([ + placement('orca-cli', { + rootId: 'home-gemini', + unresolvedPath: '/home/.gemini/skills/orca-cli', + resolvedPath: '/home/.gemini/skills/orca-cli', + topology: 'independent-copy', + status: 'outdated' + }) + ]) + ).toEqual([]) + }) + + it('does not offer an all-current name or let another safe name hide a poisoned one', () => { + expect( + eligibleSkillUpdateNames([ + placement('computer-use', { status: 'current' }), + placement('orchestration'), + placement('orchestration', { + id: 'orchestration-project', + status: 'unrecognized', + topology: 'repo-scope' + }) + ]) + ).toEqual([]) + }) + + it('builds only an explicit, deterministic global command', () => { + expect(buildTargetedSkillUpdateCommand(['orchestration', 'orca-cli', 'orca-cli'])).toBe( + 'npx skills update orca-cli orchestration --global' + ) + expect(buildTargetedSkillUpdateCommand([])).toBeNull() + expect(buildTargetedSkillUpdateCommand(['orca-cli;echo unsafe'])).toBeNull() + }) +}) diff --git a/src/main/skills/skill-freshness-eligibility.ts b/src/main/skills/skill-freshness-eligibility.ts new file mode 100644 index 00000000000..f4eb27a9f5a --- /dev/null +++ b/src/main/skills/skill-freshness-eligibility.ts @@ -0,0 +1,41 @@ +import { + SUPPORTED_GLOBAL_SKILL_TOPOLOGIES, + type SkillFreshnessInstallation +} from '../../shared/skill-freshness' + +export function eligibleSkillUpdateNames( + installations: readonly SkillFreshnessInstallation[] +): string[] { + const byName = new Map() + for (const installation of installations) { + const entries = byName.get(installation.name) ?? [] + entries.push(installation) + byName.set(installation.name, entries) + } + + const eligible: string[] = [] + for (const [name, entries] of byName) { + const hasOutdated = entries.some((entry) => entry.status === 'outdated') + const everyPlacementIsOfficialAndUpdatable = entries.every( + (entry) => + (entry.status === 'current' || entry.status === 'outdated') && + // Why: the rail reliably converges the canonical copy and its symlink aliases. + // A standalone duplicate no longer blocks the whole name — the canonical copy + // still updates and the duplicate row is flagged as maybe-not-reached — while + // data-loss topologies (unrecognized/read-only/etc.) still poison via these checks. + (SUPPORTED_GLOBAL_SKILL_TOPOLOGIES.has(entry.topology) || + entry.topology === 'independent-copy') && + Boolean(entry.resolvedPath && entry.physicalIdentity) + ) + // Why: only offer the global command when a reliably-convergent placement anchors it, + // so a skill that exists solely as a standalone copy never draws a command that could + // no-op or error against a canonical install that isn't there. + const hasReliableTarget = entries.some((entry) => + SUPPORTED_GLOBAL_SKILL_TOPOLOGIES.has(entry.topology) + ) + if (hasOutdated && everyPlacementIsOfficialAndUpdatable && hasReliableTarget) { + eligible.push(name) + } + } + return eligible.sort((left, right) => left.localeCompare(right, 'en')) +} diff --git a/src/main/skills/skill-freshness-inventory-limits.test.ts b/src/main/skills/skill-freshness-inventory-limits.test.ts new file mode 100644 index 00000000000..c43d137fcd0 --- /dev/null +++ b/src/main/skills/skill-freshness-inventory-limits.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { + boundRepositorySkillRoots, + MAXIMUM_REPOSITORY_SKILL_ROOTS +} from './skill-freshness-inventory' +import type { SkillScanRoot } from './skill-discovery-sources' + +describe('skill freshness inventory limits', () => { + it('caps repository roots before creating candidate probes', () => { + const roots = Array.from( + { length: MAXIMUM_REPOSITORY_SKILL_ROOTS + 3 }, + (_, index): SkillScanRoot => ({ + id: `repo-${index}`, + label: `Repo ${index}`, + path: `/repo-${index}/.agents/skills`, + sourceKind: 'repo', + providers: ['agent-skills'] + }) + ) + + const bounded = boundRepositorySkillRoots(roots) + + expect(bounded.scanned).toHaveLength(MAXIMUM_REPOSITORY_SKILL_ROOTS) + expect(bounded.omitted).toHaveLength(3) + }) +}) diff --git a/src/main/skills/skill-freshness-inventory.test.ts b/src/main/skills/skill-freshness-inventory.test.ts new file mode 100644 index 00000000000..990fbdba1a3 --- /dev/null +++ b/src/main/skills/skill-freshness-inventory.test.ts @@ -0,0 +1,363 @@ +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import type { Repo } from '../../shared/types' +import type { + SkillBundleFileIdentity, + SkillCurrentBundleEntry, + SkillKnownSnapshot +} from '../../shared/skill-freshness' +import { + inventorySkillFreshness, + MAXIMUM_REPOSITORY_SKILL_ROOTS +} from './skill-freshness-inventory' +import { describeObservedSkillFile, skillPackageDigest } from './skill-package-identity' + +const temporaryDirectories: string[] = [] + +function snapshot(releaseRevision: number, markdown: string): SkillKnownSnapshot { + const observed = describeObservedSkillFile('SKILL.md', Buffer.from(markdown), false) + const file: SkillBundleFileIdentity = { + path: observed.path, + size: observed.size, + executable: observed.executable, + classification: observed.classification, + exactSha256: observed.exactSha256, + textNormalizedSha256: observed.textNormalizedSha256, + identitySha256: observed.identitySha256 + } + return { + releaseRevision, + packageDigest: skillPackageDigest([file]), + gitTreeSha: releaseRevision.toString(16).padStart(40, '0'), + files: [file] + } +} + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), 'orca-skill-inventory-')) + temporaryDirectories.push(root) + const homeDir = join(root, 'home') + const resourceRoot = join(root, 'resources') + const skillResourceRoot = join(resourceRoot, 'skills') + await mkdir(skillResourceRoot, { recursive: true }) + + const oldMarkdown = '---\nname: orca-cli\ndescription: Old official guide.\n---\n\n# Old\n' + const currentMarkdown = + '---\nname: orca-cli\ndescription: Current official guide.\n---\n\n# Current\n' + const newerMarkdown = '---\nname: orca-cli\ndescription: Newer official guide.\n---\n\n# Newer\n' + const snapshots = [ + snapshot(1, oldMarkdown), + snapshot(2, currentMarkdown), + snapshot(3, newerMarkdown) + ] + const current: SkillCurrentBundleEntry = { + name: 'orca-cli', + sourcePath: 'skills/orca-cli', + appVersion: '2.0.0', + ...snapshots[1] + } + await Promise.all([ + writeFile( + join(skillResourceRoot, 'current-manifest.json'), + `${JSON.stringify({ schemaVersion: 1, appVersion: '2.0.0', skills: [current] }, null, 2)}\n` + ), + writeFile( + join(skillResourceRoot, 'snapshot-registry.json'), + `${JSON.stringify({ schemaVersion: 1, skills: { 'orca-cli': snapshots } }, null, 2)}\n` + ), + writeFile( + join(skillResourceRoot, 'release-mapping.json'), + `${JSON.stringify( + { + schemaVersion: 1, + releases: [ + { appVersion: '1.0.0', skills: { 'orca-cli': 1 } }, + { appVersion: '2.0.0', skills: { 'orca-cli': 2 } }, + { appVersion: '3.0.0', skills: { 'orca-cli': 3 } } + ] + }, + null, + 2 + )}\n` + ) + ]) + + const writeSkill = async (rootPath: string, markdown: string): Promise => { + const directory = join(rootPath, 'orca-cli') + await mkdir(directory, { recursive: true }) + await writeFile(join(directory, 'SKILL.md'), markdown) + return directory + } + return { + root, + homeDir, + resourceRoot, + oldMarkdown, + currentMarkdown, + newerMarkdown, + writeSkill + } +} + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((root) => rm(root, { recursive: true }))) +}) + +describe('read-only skill freshness inventory', () => { + it('offers an exact older official name only when all global placements are safe', async () => { + const test = await fixture() + await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.oldMarkdown) + + const inventory = await inventorySkillFreshness({ + homeDir: test.homeDir, + repos: [], + resourceRoot: test.resourceRoot + }) + + expect(inventory.installations.map((entry) => entry.status)).toEqual(['outdated']) + expect(inventory.installations[0]?.installedAppVersion).toBe('1.0.0') + expect(inventory.eligibleUpdateNames).toEqual(['orca-cli']) + }) + + it('labels newer known and unrecognized bytes honestly without calling them modified', async () => { + const test = await fixture() + await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.newerMarkdown) + await test.writeSkill( + join(test.homeDir, '.claude', 'skills'), + '---\nname: orca-cli\ndescription: User copy.\n---\n' + ) + + const inventory = await inventorySkillFreshness({ + homeDir: test.homeDir, + repos: [], + resourceRoot: test.resourceRoot + }) + + expect(inventory.installations.map((entry) => entry.status)).toEqual([ + 'newer-known', + 'unrecognized' + ]) + expect(inventory.eligibleUpdateNames).toEqual([]) + }) + + it('retains full-file identity without projecting unused metadata', async () => { + const test = await fixture() + const lateDescription = 'Description beyond the metadata parsing budget.' + await test.writeSkill( + join(test.homeDir, '.agents', 'skills'), + `${' '.repeat(256 * 1024)}\n${lateDescription}` + ) + + const inventory = await inventorySkillFreshness({ + homeDir: test.homeDir, + repos: [], + resourceRoot: test.resourceRoot + }) + + expect(inventory.installations[0]).toMatchObject({ + status: 'unrecognized' + }) + expect(inventory.installations[0]).not.toHaveProperty('description') + expect(inventory.installations[0]?.observedPackageDigest).toMatch(/^[a-f0-9]{64}$/) + }) + + it.runIf(process.platform !== 'win32')( + 'deduplicates a provider alias to the canonical copy', + async () => { + const test = await fixture() + const canonical = await test.writeSkill( + join(test.homeDir, '.agents', 'skills'), + test.oldMarkdown + ) + const claudeRoot = join(test.homeDir, '.claude', 'skills') + await mkdir(claudeRoot, { recursive: true }) + await symlink(canonical, join(claudeRoot, 'orca-cli')) + + const inventory = await inventorySkillFreshness({ + homeDir: test.homeDir, + repos: [], + resourceRoot: test.resourceRoot + }) + + expect(inventory.installations).toHaveLength(1) + expect(inventory.installations[0]?.providers).toEqual(['agent-skills', 'claude']) + expect(inventory.installations[0]?.topology).toBe('canonical-copy') + expect(inventory.eligibleUpdateNames).toEqual(['orca-cli']) + } + ) + + it.runIf(process.platform !== 'win32')( + 'deduplicates aliases within an unsupported topology without hiding its poison', + async () => { + const test = await fixture() + await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.oldMarkdown) + const shared = await test.writeSkill(join(test.root, 'shared'), test.currentMarkdown) + const repos = await Promise.all( + ['one', 'two'].map(async (id) => { + const repoPath = join(test.root, `repo-${id}`) + const root = join(repoPath, '.agents', 'skills') + await mkdir(root, { recursive: true }) + await symlink(shared, join(root, 'orca-cli')) + return { id, path: repoPath } as unknown as Repo + }) + ) + + const inventory = await inventorySkillFreshness({ + homeDir: test.homeDir, + repos, + resourceRoot: test.resourceRoot + }) + + expect( + inventory.installations.filter((entry) => entry.topology === 'repo-scope') + ).toHaveLength(1) + expect(inventory.eligibleUpdateNames).toEqual([]) + } + ) + + it('keeps inaccessible placements visible and lets them poison the name', async () => { + const test = await fixture() + await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.oldMarkdown) + const inaccessiblePath = join(test.homeDir, '.codex', 'skills', 'orca-cli') + + const inventory = await inventorySkillFreshness({ + homeDir: test.homeDir, + repos: [], + resourceRoot: test.resourceRoot, + candidateLstat: async (path) => { + if (path === inaccessiblePath) { + throw Object.assign(new Error('permission denied'), { code: 'EACCES' }) + } + return import('node:fs/promises').then(({ lstat }) => lstat(path)) + } + }) + + expect(inventory.installations.map((entry) => entry.status)).toEqual([ + 'outdated', + 'inaccessible' + ]) + expect(inventory.eligibleUpdateNames).toEqual([]) + }) + + it('does not lose an inaccessible known repository placement', async () => { + const test = await fixture() + await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.oldMarkdown) + const repoPath = join(test.root, 'repo') + const inaccessiblePath = join(repoPath, '.agents', 'skills', 'orca-cli') + + const inventory = await inventorySkillFreshness({ + homeDir: test.homeDir, + repos: [{ id: 'repo', path: repoPath }] as unknown as Repo[], + resourceRoot: test.resourceRoot, + candidateLstat: async (path) => { + if (path === inaccessiblePath) { + throw Object.assign(new Error('permission denied'), { code: 'EACCES' }) + } + return import('node:fs/promises').then(({ lstat }) => lstat(path)) + } + }) + + expect(inventory.installations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + unresolvedPath: inaccessiblePath, + topology: 'repo-scope', + status: 'inaccessible' + }) + ]) + ) + expect(inventory.eligibleUpdateNames).toEqual([]) + }) + + it.each([ + ['repo', 'repo-scope'], + ['plugin', 'plugin-cache'] + ] as const)( + 'keeps an official %s placement informational and name-poisoning', + async (kind, topology) => { + const test = await fixture() + await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.oldMarkdown) + let repos: Repo[] = [] + if (kind === 'repo') { + const repoPath = join(test.root, 'repo') + await test.writeSkill(join(repoPath, '.agents', 'skills'), test.currentMarkdown) + repos = [{ id: 'repo', path: repoPath }] as unknown as Repo[] + } else { + await test.writeSkill( + join(test.homeDir, '.codex', 'plugins', 'cache', 'vendor', 'skills'), + test.currentMarkdown + ) + } + + const inventory = await inventorySkillFreshness({ + homeDir: test.homeDir, + repos, + resourceRoot: test.resourceRoot + }) + + expect(inventory.installations.some((entry) => entry.topology === topology)).toBe(true) + expect(inventory.eligibleUpdateNames).toEqual([]) + } + ) + + it('accepts CRLF as the same official text identity', async () => { + const test = await fixture() + await test.writeSkill( + join(test.homeDir, '.agents', 'skills'), + test.oldMarkdown.replaceAll('\n', '\r\n') + ) + + const inventory = await inventorySkillFreshness({ + homeDir: test.homeDir, + repos: [], + resourceRoot: test.resourceRoot + }) + expect(inventory.installations[0]?.status).toBe('outdated') + }) + + it('classifies exact current bytes as current when a later snapshot reuses them', async () => { + const test = await fixture() + const resourceRoot = join(test.resourceRoot, 'skills') + const registryPath = join(resourceRoot, 'snapshot-registry.json') + const registry = JSON.parse(await readFile(registryPath, 'utf8')) + registry.skills['orca-cli'].push(snapshot(4, test.currentMarkdown)) + await writeFile(registryPath, `${JSON.stringify(registry, null, 2)}\n`) + await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.currentMarkdown) + + const inventory = await inventorySkillFreshness({ + homeDir: test.homeDir, + repos: [], + resourceRoot: test.resourceRoot + }) + + expect(inventory.installations[0]).toMatchObject({ + status: 'current', + installedReleaseRevision: 2, + installedAppVersion: '2.0.0' + }) + }) + + it('withholds updates when stored repositories exceed the probe budget', async () => { + const test = await fixture() + await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.oldMarkdown) + const repos = Array.from( + { length: MAXIMUM_REPOSITORY_SKILL_ROOTS / 2 + 1 }, + (_, index) => ({ id: `repo-${index}`, path: join(test.root, `repo-${index}`) }) as Repo + ) + + const inventory = await inventorySkillFreshness({ + homeDir: test.homeDir, + repos, + resourceRoot: test.resourceRoot + }) + + expect(inventory.installations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ errorCategory: 'repository-scan-limit', status: 'inaccessible' }) + ]) + ) + expect(inventory.eligibleUpdateNames).toEqual([]) + }) +}) diff --git a/src/main/skills/skill-freshness-inventory.ts b/src/main/skills/skill-freshness-inventory.ts new file mode 100644 index 00000000000..e5bbae31da9 --- /dev/null +++ b/src/main/skills/skill-freshness-inventory.ts @@ -0,0 +1,179 @@ +import { lstat } from 'node:fs/promises' +import { join } from 'node:path' +import type { Repo } from '../../shared/types' +import type { + SkillFreshnessInstallation, + SkillFreshnessInventory +} from '../../shared/skill-freshness' +import { buildSkillDiscoverySources, type SkillScanRoot } from './skill-discovery-sources' +import { loadSkillBundleArtifacts } from './skill-bundle-artifacts' +import { eligibleSkillUpdateNames } from './skill-freshness-eligibility' +import { runSkillCandidateTasks } from './skill-candidate-concurrency' +import { + classifyHomeSkillCandidate, + classifyUnsupportedSkillCandidate, + dedupeSkillFreshnessPlacements, + observeSkillFreshnessInstallation, + type CandidateLstat +} from './skill-freshness-placement-observation' +import { scanKnownPluginSkillCandidates } from './skill-plugin-cache-scan' + +export const MAXIMUM_REPOSITORY_SKILL_ROOTS = 128 + +export function boundRepositorySkillRoots(roots: readonly SkillScanRoot[]): { + scanned: SkillScanRoot[] + omitted: SkillScanRoot[] +} { + return { + scanned: roots.slice(0, MAXIMUM_REPOSITORY_SKILL_ROOTS), + omitted: roots.slice(MAXIMUM_REPOSITORY_SKILL_ROOTS) + } +} + +export async function inventorySkillFreshness( + args: { + homeDir?: string + cwd?: string + repos?: Repo[] + resourceRoot?: string + candidateLstat?: CandidateLstat + } = {} +): Promise { + const artifacts = await loadSkillBundleArtifacts(args.resourceRoot) + const currentByName = new Map(artifacts.manifest.skills.map((skill) => [skill.name, skill])) + const discoveryArgs = { + homeDir: args.homeDir, + cwd: args.cwd, + repos: args.repos, + // Why: freshness scans known repositories explicitly; treating the app's + // launch cwd as another repo would create phantom poison placements. + includeCwd: false + } + const roots = buildSkillDiscoverySources(discoveryArgs) + const homeRoots = roots.filter((root) => root.sourceKind === 'home') + const allRepoRoots = roots.filter((root) => root.sourceKind === 'repo') + const { scanned: repoRoots, omitted: omittedRepoRoots } = boundRepositorySkillRoots(allRepoRoots) + const pluginRoots = roots.filter((root) => root.sourceKind === 'plugin') + const canonicalRootPath = homeRoots.find((root) => root.id === 'home-agents')?.path + if (!canonicalRootPath) { + throw new Error('Missing canonical agent skills root') + } + + const homeTasks = artifacts.manifest.skills.flatMap((current) => + homeRoots.map( + (root) => () => + classifyHomeSkillCandidate({ + root, + current, + artifacts, + canonicalRootPath, + candidateLstat: args.candidateLstat ?? ((path) => lstat(path)) + }) + ) + ) + // Why: each observation may retain the package byte ceiling while hashing; + // launch/focus scans must not fan out across every known placement. + const homeInstallations = (await runSkillCandidateTasks(homeTasks)).filter( + (installation): installation is SkillFreshnessInstallation => installation !== null + ) + + const candidateLstat = args.candidateLstat ?? ((path) => lstat(path)) + const repoTasks = artifacts.manifest.skills.flatMap((current) => + repoRoots.map( + (root) => () => + classifyUnsupportedSkillCandidate({ + root, + current, + artifacts, + unresolvedPath: join(root.path, current.name), + candidateLstat + }) + ) + ) + // Why: stored repositories can grow without bound. If the probe budget is + // exhausted, one sentinel per name preserves safety without hashing more packages. + const omittedRepoTasks = + omittedRepoRoots.length === 0 + ? [] + : artifacts.manifest.skills.map( + (current) => () => + observeSkillFreshnessInstallation({ + current, + artifacts, + rootId: 'repo-scan-limit', + providers: [...new Set(omittedRepoRoots.flatMap((root) => root.providers))], + sourceKind: 'repo', + sourceLabel: 'Additional repositories', + unresolvedPath: omittedRepoRoots[0]?.path ?? 'repo-scan-limit', + topology: { + topology: 'repo-scope', + resolvedPath: null, + identity: null, + errorCategory: 'repository-scan-limit' + } + }) + ) + const pluginScans = await Promise.all( + pluginRoots.map(async (root) => ({ + root, + scan: await scanKnownPluginSkillCandidates(root.path, new Set(currentByName.keys())) + })) + ) + const pluginTasks = pluginScans.flatMap(({ root, scan }) => [ + ...scan.candidates.flatMap((candidate) => { + const current = currentByName.get(candidate.name) + return current + ? [ + () => + classifyUnsupportedSkillCandidate({ + root, + current, + artifacts, + unresolvedPath: candidate.path, + candidateLstat + }) + ] + : [] + }), + // Why: unreadable plugin subtrees could hide any official name. An + // incomplete scan must conservatively poison every name rather than imply absence. + ...scan.incompletePaths.flatMap((incompletePath) => + artifacts.manifest.skills.map( + (current) => () => + observeSkillFreshnessInstallation({ + current, + artifacts, + rootId: root.id, + providers: root.providers, + sourceKind: 'plugin', + sourceLabel: root.label, + unresolvedPath: join(incompletePath, current.name), + topology: { + topology: 'plugin-cache', + resolvedPath: null, + identity: null, + errorCategory: 'plugin-cache-scan-incomplete' + } + }) + ) + ) + ]) + const unsupportedInstallations = ( + await runSkillCandidateTasks([...repoTasks, ...omittedRepoTasks, ...pluginTasks]) + ).filter((installation): installation is SkillFreshnessInstallation => installation !== null) + const installations = dedupeSkillFreshnessPlacements([ + ...homeInstallations, + ...unsupportedInstallations + ]).sort( + (left, right) => + left.name.localeCompare(right.name, 'en') || + left.unresolvedPath.localeCompare(right.unresolvedPath, 'en') + ) + + return { + schemaVersion: 1, + installations, + eligibleUpdateNames: eligibleSkillUpdateNames(installations), + scannedAt: Date.now() + } +} diff --git a/src/main/skills/skill-freshness-placement-observation.ts b/src/main/skills/skill-freshness-placement-observation.ts new file mode 100644 index 00000000000..2f8d32ca663 --- /dev/null +++ b/src/main/skills/skill-freshness-placement-observation.ts @@ -0,0 +1,251 @@ +import type { Stats } from 'node:fs' +import { join } from 'node:path' +import type { + SkillCurrentBundleEntry, + SkillFreshnessInstallation, + SkillFreshnessStatus, + SkillKnownSnapshot +} from '../../shared/skill-freshness' +import type { SkillScanRoot } from './skill-discovery-sources' +import type { SkillBundleArtifacts } from './skill-bundle-artifacts' +import { matchingKnownSnapshot, observeSkillPackage } from './skill-package-identity' +import { + classifyHomeSkillTopology, + classifyUnsupportedSkillTopology, + skillPlacementId, + skillTopologyPriority, + type ClassifiedSkillTopology +} from './skill-installation-topology' + +export type CandidateLstat = (path: string) => Promise + +function freshnessStatus( + snapshot: SkillKnownSnapshot | null, + current: SkillCurrentBundleEntry +): SkillFreshnessStatus { + if (!snapshot) { + return 'unrecognized' + } + if (snapshot.releaseRevision > current.releaseRevision) { + return 'newer-known' + } + return snapshot.packageDigest === current.packageDigest ? 'current' : 'outdated' +} + +function errorCategory(error: unknown, fallback: string): string { + return error instanceof Error && error.message ? error.message : fallback +} + +function isInaccessibleError(error: unknown): boolean { + return Boolean( + error && + typeof error === 'object' && + 'code' in error && + (error.code === 'EACCES' || error.code === 'EPERM') + ) +} + +function knownSnapshots( + artifacts: SkillBundleArtifacts, + current: SkillCurrentBundleEntry +): SkillKnownSnapshot[] { + const snapshots = artifacts.knownSnapshots[current.name] ?? [] + return snapshots.some((snapshot) => snapshot.packageDigest === current.packageDigest) + ? snapshots + : [...snapshots, current] +} + +export async function observeSkillFreshnessInstallation(args: { + current: SkillCurrentBundleEntry + artifacts: SkillBundleArtifacts + rootId: string + providers: SkillFreshnessInstallation['providers'] + sourceKind: SkillFreshnessInstallation['sourceKind'] + sourceLabel: string + unresolvedPath: string + topology: ClassifiedSkillTopology +}): Promise { + const base = { + id: skillPlacementId(args.unresolvedPath, args.current.name), + name: args.current.name, + rootId: args.rootId, + providers: args.providers, + sourceKind: args.sourceKind, + sourceLabel: args.sourceLabel, + unresolvedPath: args.unresolvedPath, + resolvedPath: args.topology.resolvedPath, + physicalIdentity: args.topology.identity, + topology: args.topology.topology, + currentReleaseRevision: args.current.releaseRevision, + currentPackageDigest: args.current.packageDigest, + currentAppVersion: args.current.appVersion, + errorCategory: args.topology.errorCategory + } + if (!args.topology.resolvedPath || !args.topology.identity) { + return { + ...base, + status: 'inaccessible', + installedReleaseRevision: null, + installedAppVersion: null, + observedPackageDigest: null + } + } + + try { + const observed = await observeSkillPackage(args.topology.resolvedPath) + const matchedSnapshot = matchingKnownSnapshot( + observed, + knownSnapshots(args.artifacts, args.current) + ) + // Why: a later release can reintroduce identical bytes. Exact current + // identity is still current, and cannot honestly be attributed to the later tag. + const snapshot = + observed.observedDigest === args.current.packageDigest ? args.current : matchedSnapshot + return { + ...base, + status: freshnessStatus(snapshot, args.current), + installedReleaseRevision: snapshot?.releaseRevision ?? null, + installedAppVersion: snapshot + ? (args.artifacts.releasedAppVersions[args.current.name]?.[snapshot.releaseRevision] ?? + null) + : null, + observedPackageDigest: observed.observedDigest + } + } catch (error) { + return { + ...base, + status: isInaccessibleError(error) ? 'inaccessible' : 'unrecognized', + installedReleaseRevision: null, + installedAppVersion: null, + observedPackageDigest: null, + errorCategory: errorCategory(error, 'skill-package-read-failed') + } + } +} + +export async function classifyHomeSkillCandidate(args: { + root: SkillScanRoot + current: SkillCurrentBundleEntry + artifacts: SkillBundleArtifacts + canonicalRootPath: string + candidateLstat: CandidateLstat +}): Promise { + const unresolvedPath = join(args.root.path, args.current.name) + try { + await args.candidateLstat(unresolvedPath) + } catch (error) { + if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { + return null + } + return observeSkillFreshnessInstallation({ + current: args.current, + artifacts: args.artifacts, + rootId: args.root.id, + providers: args.root.providers, + sourceKind: args.root.sourceKind, + sourceLabel: args.root.label, + unresolvedPath, + topology: { + topology: 'broken-link', + resolvedPath: null, + identity: null, + errorCategory: errorCategory(error, 'skill-candidate-inaccessible') + } + }) + } + + let topology: ClassifiedSkillTopology + try { + topology = await classifyHomeSkillTopology(args.root, unresolvedPath, args.canonicalRootPath) + } catch (error) { + topology = { + topology: 'broken-link', + resolvedPath: null, + identity: null, + errorCategory: errorCategory(error, 'skill-candidate-topology-failed') + } + } + return observeSkillFreshnessInstallation({ + current: args.current, + artifacts: args.artifacts, + rootId: args.root.id, + providers: args.root.providers, + sourceKind: args.root.sourceKind, + sourceLabel: args.root.label, + unresolvedPath, + topology + }) +} + +export async function classifyUnsupportedSkillCandidate(args: { + root: SkillScanRoot + current: SkillCurrentBundleEntry + artifacts: SkillBundleArtifacts + unresolvedPath: string + candidateLstat: CandidateLstat +}): Promise { + try { + await args.candidateLstat(args.unresolvedPath) + } catch (error) { + if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { + return null + } + return observeSkillFreshnessInstallation({ + current: args.current, + artifacts: args.artifacts, + rootId: args.root.id, + providers: args.root.providers, + sourceKind: args.root.sourceKind, + sourceLabel: args.root.label, + unresolvedPath: args.unresolvedPath, + topology: { + topology: args.root.sourceKind === 'repo' ? 'repo-scope' : 'plugin-cache', + resolvedPath: null, + identity: null, + errorCategory: errorCategory(error, 'unsupported-candidate-inaccessible') + } + }) + } + return observeSkillFreshnessInstallation({ + current: args.current, + artifacts: args.artifacts, + rootId: args.root.id, + providers: args.root.providers, + sourceKind: args.root.sourceKind, + sourceLabel: args.root.label, + unresolvedPath: args.unresolvedPath, + topology: await classifyUnsupportedSkillTopology( + args.unresolvedPath, + args.root.sourceKind === 'repo' ? 'repo' : 'plugin' + ) + }) +} + +function topologyDedupeBucket(installation: SkillFreshnessInstallation): string { + return installation.topology === 'canonical-copy' || installation.topology === 'provider-alias' + ? 'managed-global' + : installation.topology +} + +export function dedupeSkillFreshnessPlacements( + installations: readonly SkillFreshnessInstallation[] +): SkillFreshnessInstallation[] { + const deduped = new Map() + for (const installation of installations) { + const key = installation.physicalIdentity + ? `${installation.name}\0${installation.physicalIdentity}\0${topologyDedupeBucket(installation)}` + : `logical\0${installation.id}` + const existing = deduped.get(key) + if (!existing) { + deduped.set(key, installation) + continue + } + const providers = [...new Set([...existing.providers, ...installation.providers])] + if (skillTopologyPriority(installation.topology) > skillTopologyPriority(existing.topology)) { + deduped.set(key, { ...installation, providers }) + } else { + existing.providers = providers + } + } + return [...deduped.values()] +} diff --git a/src/main/skills/skill-installation-topology.ts b/src/main/skills/skill-installation-topology.ts new file mode 100644 index 00000000000..673fcb3d1e1 --- /dev/null +++ b/src/main/skills/skill-installation-topology.ts @@ -0,0 +1,172 @@ +import { createHash } from 'node:crypto' +import { constants } from 'node:fs' +import { access, lstat, realpath, stat } from 'node:fs/promises' +import { dirname, normalize, resolve } from 'node:path' +import type { SkillInstallationTopology } from '../../shared/skill-freshness' +import type { SkillScanRoot } from './skill-discovery-sources' + +export type ClassifiedSkillTopology = { + topology: SkillInstallationTopology + resolvedPath: string | null + identity: string | null + errorCategory: string | null +} + +export function skillPlacementId(unresolvedPath: string, name: string): string { + return createHash('sha256') + .update(normalizedSkillIdentityPath(unresolvedPath)) + .update('\0') + .update(name) + .digest('hex') + .slice(0, 24) +} + +export function normalizedSkillIdentityPath(value: string): string { + const normalized = normalize(value) + return process.platform === 'win32' ? normalized.toLocaleLowerCase('en-US') : normalized +} + +export function skillPhysicalIdentity( + resolvedPath: string, + fileStat: Awaited> +): string { + const inodeIdentity = fileStat.dev || fileStat.ino ? `${fileStat.dev}:${fileStat.ino}` : null + return inodeIdentity ?? normalizedSkillIdentityPath(resolvedPath) +} + +export function skillTopologyPriority(topology: SkillInstallationTopology): number { + switch (topology) { + case 'canonical-copy': + return 3 + case 'independent-copy': + return 2 + case 'provider-alias': + return 1 + case 'external-link': + case 'broken-link': + case 'read-only': + case 'repo-scope': + case 'plugin-cache': + return 0 + } +} + +async function writableDestination(path: string): Promise { + try { + await Promise.all([ + access(path, constants.R_OK | constants.W_OK), + access(dirname(path), constants.W_OK) + ]) + return true + } catch { + return false + } +} + +async function hasSymlinkedAncestor(path: string, boundary: string): Promise { + let current = resolve(path) + const stop = resolve(boundary) + for (;;) { + const entry = await lstat(current).catch(() => null) + if (!entry || entry.isSymbolicLink()) { + return true + } + const parent = dirname(current) + if (current === stop) { + return false + } + if (parent === current) { + return true + } + current = parent + } +} + +export async function classifyHomeSkillTopology( + root: SkillScanRoot, + unresolvedPath: string, + canonicalRootPath: string +): Promise { + let logicalStat: Awaited> + try { + logicalStat = await lstat(unresolvedPath) + } catch (error) { + if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { + return { + topology: 'broken-link', + resolvedPath: null, + identity: null, + errorCategory: 'missing' + } + } + throw error + } + const linked = logicalStat.isSymbolicLink() + let resolvedPath: string + let resolvedStat: Awaited> + try { + resolvedPath = await realpath(unresolvedPath) + resolvedStat = await stat(resolvedPath) + } catch { + return { + topology: 'broken-link', + resolvedPath: null, + identity: null, + errorCategory: 'dangling-link' + } + } + if (!resolvedStat.isDirectory()) { + return { + topology: 'broken-link', + resolvedPath, + identity: null, + errorCategory: 'not-directory' + } + } + + const identity = skillPhysicalIdentity(resolvedPath, resolvedStat) + const canonicalRoot = await realpath(canonicalRootPath).catch(() => resolve(canonicalRootPath)) + const homeBoundary = dirname(dirname(canonicalRootPath)) + const rootOrProviderParentLinked = await hasSymlinkedAncestor(root.path, homeBoundary) + const isCanonicalTarget = + normalizedSkillIdentityPath(dirname(resolvedPath)) === + normalizedSkillIdentityPath(canonicalRoot) + let topology: SkillInstallationTopology + if (linked) { + topology = isCanonicalTarget ? 'provider-alias' : 'external-link' + } else if (rootOrProviderParentLinked) { + topology = 'external-link' + } else { + topology = root.id === 'home-agents' ? 'canonical-copy' : 'independent-copy' + } + if (topology !== 'external-link' && !(await writableDestination(resolvedPath))) { + topology = 'read-only' + } + return { topology, resolvedPath, identity, errorCategory: null } +} + +export async function classifyUnsupportedSkillTopology( + directoryPath: string, + sourceKind: 'repo' | 'plugin' +): Promise { + try { + const resolvedPath = await realpath(directoryPath) + const resolvedStat = await stat(resolvedPath) + if (!resolvedStat.isDirectory()) { + throw new Error('not-directory') + } + return { + topology: sourceKind === 'repo' ? 'repo-scope' : 'plugin-cache', + resolvedPath, + identity: skillPhysicalIdentity(resolvedPath, resolvedStat), + errorCategory: null + } + } catch (error) { + return { + topology: sourceKind === 'repo' ? 'repo-scope' : 'plugin-cache', + resolvedPath: null, + identity: null, + errorCategory: error instanceof Error ? error.message : 'read-failed' + } + } +} diff --git a/src/main/skills/skill-package-identity.test.ts b/src/main/skills/skill-package-identity.test.ts new file mode 100644 index 00000000000..648b628e82c --- /dev/null +++ b/src/main/skills/skill-package-identity.test.ts @@ -0,0 +1,92 @@ +import { chmod, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + describeObservedSkillFile, + matchingKnownSnapshot, + observeSkillPackage, + skillPackageDigest +} from './skill-package-identity' + +const temporaryDirectories: string[] = [] + +async function temporarySkill(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-skill-freshness-')) + temporaryDirectories.push(root) + return root +} + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((root) => rm(root, { recursive: true }))) +}) + +describe('skill package identity', () => { + it('matches CRLF installed text to an LF official snapshot', async () => { + const root = await temporarySkill() + await writeFile(join(root, 'SKILL.md'), 'first\r\nsecond\r\n') + const observed = await observeSkillPackage(root) + const expected = describeObservedSkillFile('SKILL.md', Buffer.from('first\nsecond\n'), false) + + // Why: scans can observe several package byte budgets concurrently; only + // hashes, not raw file buffers, should survive each file's identity pass. + expect(observed.files[0]).not.toHaveProperty('bytes') + expect( + matchingKnownSnapshot(observed, [ + { + releaseRevision: 1, + packageDigest: skillPackageDigest([expected]), + gitTreeSha: 'tree', + files: [expected] + } + ])?.releaseRevision + ).toBe(1) + }) + + it('uses exact bytes for executable and binary files', async () => { + const executable = describeObservedSkillFile('run.sh', Buffer.from('#!/bin/sh\r\n'), true) + const binary = describeObservedSkillFile('asset.bin', Buffer.from([0, 13, 10]), false) + expect(executable.identitySha256).toBe(executable.exactSha256) + expect(binary.identitySha256).toBe(binary.exactSha256) + expect(binary.classification).toBe('binary') + }) + + it('orders package files by locale-independent code units', async () => { + const root = await temporarySkill() + await writeFile(join(root, 'apple.md'), 'apple') + await writeFile(join(root, 'Zebra.md'), 'zebra') + + const observed = await observeSkillPackage(root) + + expect(observed.files.map((file) => file.path)).toEqual(['Zebra.md', 'apple.md']) + }) + + it('rejects links and bounded-observation overflows', async () => { + const root = await temporarySkill() + await writeFile(join(root, 'SKILL.md'), 'skill') + if (process.platform !== 'win32') { + await symlink(join(root, 'SKILL.md'), join(root, 'linked.md')) + await expect(observeSkillPackage(root)).rejects.toThrow('skill-package-link') + await rm(join(root, 'linked.md')) + } + await expect( + observeSkillPackage(root, { + maximumDepth: 1, + maximumEntries: 0, + maximumFiles: 1, + maximumSingleFileBytes: 10, + maximumTotalBytes: 10 + }) + ).rejects.toThrow('skill-package-entry-limit') + }) + + it.runIf(process.platform !== 'win32')('tracks executable mode in package identity', async () => { + const root = await temporarySkill() + await mkdir(join(root, 'scripts')) + const script = join(root, 'scripts', 'run.sh') + await writeFile(script, '#!/bin/sh\n') + await chmod(script, 0o755) + const observed = await observeSkillPackage(root) + expect(observed.files[0]?.executable).toBe(true) + }) +}) diff --git a/src/main/skills/skill-package-identity.ts b/src/main/skills/skill-package-identity.ts new file mode 100644 index 00000000000..8189ee0b28b --- /dev/null +++ b/src/main/skills/skill-package-identity.ts @@ -0,0 +1,229 @@ +import { createHash } from 'node:crypto' +import type { Dirent } from 'node:fs' +import { lstat, open, opendir } from 'node:fs/promises' +import { isAbsolute, join, relative, sep } from 'node:path' +import type { SkillBundleFileIdentity, SkillKnownSnapshot } from '../../shared/skill-freshness' + +type ObservedSkillFile = SkillBundleFileIdentity + +export type ObservedSkillPackage = { + files: ObservedSkillFile[] + observedDigest: string +} + +export const SKILL_PACKAGE_OBSERVATION_LIMITS = { + maximumDepth: 16, + maximumEntries: 2_048, + maximumFiles: 512, + maximumSingleFileBytes: 4 * 1024 * 1024, + maximumTotalBytes: 32 * 1024 * 1024 +} as const + +type SkillPackageObservationLimits = { + maximumDepth: number + maximumEntries: number + maximumFiles: number + maximumSingleFileBytes: number + maximumTotalBytes: number +} + +async function readBoundedSkillFile( + path: string, + remainingTotalBytes: number, + maximumSingleFileBytes: number +): Promise { + const handle = await open(path, 'r') + try { + const before = await handle.stat() + if (before.size > maximumSingleFileBytes) { + throw new Error('skill-package-file-size-limit') + } + if (before.size > remainingTotalBytes) { + throw new Error('skill-package-total-size-limit') + } + const bytes = Buffer.alloc(before.size) + let offset = 0 + while (offset < bytes.length) { + const result = await handle.read(bytes, offset, bytes.length - offset, offset) + if (result.bytesRead === 0) { + throw new Error('skill-package-changed-during-read') + } + offset += result.bytesRead + } + if ((await handle.stat()).size !== before.size) { + throw new Error('skill-package-changed-during-read') + } + return bytes + } finally { + await handle.close() + } +} + +function sha256(bytes: Buffer): string { + return createHash('sha256').update(bytes).digest('hex') +} + +function compareCodeUnits(left: string, right: string): number { + return left === right ? 0 : left < right ? -1 : 1 +} + +function normalizedText(bytes: Buffer): Buffer { + const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes) + return Buffer.from(text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'), 'utf8') +} + +export function describeObservedSkillFile( + path: string, + bytes: Buffer, + executable: boolean +): ObservedSkillFile { + let normalized: Buffer | null = null + if (!bytes.includes(0)) { + try { + normalized = normalizedText(bytes) + } catch { + normalized = null + } + } + const classification = normalized ? 'text' : 'binary' + const exactSha256 = sha256(bytes) + const textNormalizedSha256 = normalized ? sha256(normalized) : null + return { + path, + size: bytes.length, + executable, + classification, + exactSha256, + textNormalizedSha256, + identitySha256: + textNormalizedSha256 !== null && !executable ? textNormalizedSha256 : exactSha256 + } +} + +export function skillPackageDigest(files: readonly SkillBundleFileIdentity[]): string { + return sha256( + Buffer.from( + JSON.stringify( + files.map((file) => ({ + path: file.path, + executable: file.executable, + classification: file.classification, + identitySha256: file.identitySha256 + })) + ) + ) + ) +} + +function matchesFileIdentity( + actual: ObservedSkillFile, + expected: SkillBundleFileIdentity +): boolean { + if ( + actual.path !== expected.path || + actual.executable !== expected.executable || + actual.classification !== expected.classification + ) { + return false + } + return expected.classification === 'text' && !expected.executable + ? actual.textNormalizedSha256 === expected.textNormalizedSha256 + : actual.exactSha256 === expected.exactSha256 +} + +export async function observeSkillPackage( + packageRoot: string, + limits: SkillPackageObservationLimits = SKILL_PACKAGE_OBSERVATION_LIMITS +): Promise { + const files: ObservedSkillFile[] = [] + const caseFoldedPaths = new Map() + let entryCount = 0 + let totalBytes = 0 + + async function visit(directory: string, depth: number): Promise { + const directoryHandle = await opendir(directory) + const entries: Dirent[] = [] + try { + for (;;) { + const entry = await directoryHandle.read() + if (!entry) { + break + } + entryCount += 1 + if (entryCount > limits.maximumEntries) { + throw new Error('skill-package-entry-limit') + } + entries.push(entry) + } + } finally { + await directoryHandle.close().catch(() => undefined) + } + // Why: runtime Electron and the build's Node may carry different ICU data; + // identity order must match the generator without locale-sensitive collation. + entries.sort((left, right) => compareCodeUnits(left.name, right.name)) + for (const entry of entries) { + const absolutePath = join(directory, entry.name) + const relativePath = relative(packageRoot, absolutePath) + if ( + isAbsolute(relativePath) || + relativePath === '..' || + relativePath.startsWith(`..${sep}`) + ) { + throw new Error('skill-path-escape') + } + const manifestPath = relativePath.split(sep).join('/') + const folded = manifestPath.toLocaleLowerCase('en-US') + const collision = caseFoldedPaths.get(folded) + if (collision && collision !== manifestPath) { + throw new Error('skill-case-collision') + } + caseFoldedPaths.set(folded, manifestPath) + const fileStat = await lstat(absolutePath) + if (fileStat.isSymbolicLink()) { + throw new Error('skill-package-link') + } + if (fileStat.isDirectory()) { + if (depth >= limits.maximumDepth) { + throw new Error('skill-package-depth-limit') + } + await visit(absolutePath, depth + 1) + } else if (fileStat.isFile()) { + if (files.length >= limits.maximumFiles) { + throw new Error('skill-package-file-count-limit') + } + const bytes = await readBoundedSkillFile( + absolutePath, + limits.maximumTotalBytes - totalBytes, + limits.maximumSingleFileBytes + ) + totalBytes += bytes.length + files.push(describeObservedSkillFile(manifestPath, bytes, (fileStat.mode & 0o111) !== 0)) + } else { + throw new Error('skill-package-special-file') + } + } + } + + await visit(packageRoot, 0) + return { files, observedDigest: skillPackageDigest(files) } +} + +export function matchingKnownSnapshot( + observed: ObservedSkillPackage, + snapshots: readonly SkillKnownSnapshot[] +): SkillKnownSnapshot | null { + for (const snapshot of snapshots.toReversed()) { + if (snapshot.files.length !== observed.files.length) { + continue + } + if ( + snapshot.files.every((expected, index) => { + const actual = observed.files[index] + return Boolean(actual && matchesFileIdentity(actual, expected)) + }) + ) { + return snapshot + } + } + return null +} diff --git a/src/main/skills/skill-plugin-cache-scan.test.ts b/src/main/skills/skill-plugin-cache-scan.test.ts new file mode 100644 index 00000000000..46216ce6c04 --- /dev/null +++ b/src/main/skills/skill-plugin-cache-scan.test.ts @@ -0,0 +1,40 @@ +import { mkdir, mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { scanKnownPluginSkillCandidates } from './skill-plugin-cache-scan' + +const temporaryDirectories: string[] = [] + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((root) => rm(root, { recursive: true }))) +}) + +describe('plugin skill candidate scan', () => { + it('stops at the package candidate budget and marks the scan incomplete', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-plugin-skill-scan-')) + temporaryDirectories.push(root) + await Promise.all( + ['one', 'two'].map((vendor) => mkdir(join(root, vendor, 'orca-cli'), { recursive: true })) + ) + + const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli']), 1) + + expect(result.candidates).toHaveLength(1) + expect(result.incompletePaths).toEqual([root]) + }) + + it('marks depth-truncated subtrees incomplete so hidden skills poison eligibility', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-plugin-skill-depth-')) + temporaryDirectories.push(root) + const segments = Array.from({ length: 11 }, (_, index) => `level-${index}`) + const hiddenSkill = join(root, ...segments, 'orca-cli') + await mkdir(hiddenSkill, { recursive: true }) + + const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli'])) + + expect(result.candidates).toEqual([]) + expect(result.incompletePaths).toHaveLength(1) + expect(hiddenSkill.startsWith(result.incompletePaths[0] ?? '')).toBe(true) + }) +}) diff --git a/src/main/skills/skill-plugin-cache-scan.ts b/src/main/skills/skill-plugin-cache-scan.ts new file mode 100644 index 00000000000..7162cedbc47 --- /dev/null +++ b/src/main/skills/skill-plugin-cache-scan.ts @@ -0,0 +1,142 @@ +import type { Dirent } from 'node:fs' +import { opendir, realpath, stat } from 'node:fs/promises' +import { join } from 'node:path' + +const MAXIMUM_PLUGIN_SCAN_DEPTH = 9 +const MAXIMUM_PLUGIN_SCAN_ENTRIES = 4_096 +export const MAXIMUM_PLUGIN_SKILL_CANDIDATES = 64 +const MAXIMUM_PLUGIN_INCOMPLETE_PATHS = 16 + +export type KnownPluginSkillCandidate = { + name: string + path: string +} + +export type KnownPluginSkillScan = { + candidates: KnownPluginSkillCandidate[] + incompletePaths: string[] +} + +function errorCode(error: unknown): string | null { + return error && typeof error === 'object' && 'code' in error && typeof error.code === 'string' + ? error.code + : null +} + +export async function scanKnownPluginSkillCandidates( + rootPath: string, + knownNames: ReadonlySet, + maximumCandidates = MAXIMUM_PLUGIN_SKILL_CANDIDATES +): Promise { + const candidates: KnownPluginSkillCandidate[] = [] + const incompletePaths = new Set() + const visited = new Set() + let entryCount = 0 + let limitReached = false + + function recordIncomplete(path: string): void { + if (incompletePaths.has(path)) { + return + } + if (incompletePaths.size >= MAXIMUM_PLUGIN_INCOMPLETE_PATHS) { + // Why: each incomplete path expands to one conservative row per official + // skill. Collapse a hostile cache into one poison sentinel before IPC/render fanout. + incompletePaths.clear() + incompletePaths.add(rootPath) + limitReached = true + return + } + incompletePaths.add(path) + } + + async function visit(directory: string, depth: number): Promise { + if (limitReached) { + return + } + if (depth > MAXIMUM_PLUGIN_SCAN_DEPTH) { + recordIncomplete(directory) + return + } + let resolved: string + try { + resolved = await realpath(directory) + } catch (error) { + if (errorCode(error) !== 'ENOENT') { + recordIncomplete(directory) + } + return + } + if (visited.has(resolved)) { + return + } + visited.add(resolved) + + let handle: Awaited> + try { + handle = await opendir(directory) + } catch { + recordIncomplete(directory) + return + } + const entries: Dirent[] = [] + try { + for (;;) { + const entry = await handle.read() + if (!entry) { + break + } + entryCount += 1 + if (entryCount > MAXIMUM_PLUGIN_SCAN_ENTRIES) { + limitReached = true + recordIncomplete(rootPath) + break + } + entries.push(entry) + } + } catch { + recordIncomplete(directory) + } finally { + await handle.close().catch(() => undefined) + } + + entries.sort((left, right) => (left.name === right.name ? 0 : left.name < right.name ? -1 : 1)) + for (const entry of entries) { + if (limitReached) { + return + } + const entryPath = join(directory, entry.name) + let directoryEntry = entry.isDirectory() + if (entry.isSymbolicLink()) { + try { + directoryEntry = (await stat(entryPath)).isDirectory() + } catch { + if (knownNames.has(entry.name)) { + if (candidates.length >= maximumCandidates) { + limitReached = true + recordIncomplete(rootPath) + return + } + candidates.push({ name: entry.name, path: entryPath }) + } + continue + } + } + if (!directoryEntry) { + continue + } + if (knownNames.has(entry.name)) { + if (candidates.length >= maximumCandidates) { + limitReached = true + recordIncomplete(rootPath) + return + } + candidates.push({ name: entry.name, path: entryPath }) + continue + } + await visit(entryPath, depth + 1) + } + } + + await visit(rootPath, 0) + return { candidates, incompletePaths: [...incompletePaths] } +} diff --git a/src/main/ssh/ssh-remote-cli-launcher.test.ts b/src/main/ssh/ssh-remote-cli-launcher.test.ts index c9f9c0865de..62aa7bec660 100644 --- a/src/main/ssh/ssh-remote-cli-launcher.test.ts +++ b/src/main/ssh/ssh-remote-cli-launcher.test.ts @@ -1,6 +1,6 @@ import { Buffer } from 'node:buffer' import { spawnSync } from 'node:child_process' -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { describe, expect, it } from 'vitest' @@ -23,15 +23,18 @@ function decodePowerShellCommand(command: string): string { } describe('SSH remote Orca CLI launcher', () => { - it('compiles a native Windows launcher without a cmd.exe argument bridge', () => { - const hostPlatform = getRemoteHostPlatform('win32-x64') - const plan = createRemoteCliInstallPlan({ + function windowsInstallPlan(): ReturnType { + return createRemoteCliInstallPlan({ binDir: 'C:/Users/me user/.orca-relay/bin', relayDir: 'C:/Users/me user/.orca-remote/relay-v1', nodePath: 'C:/Program Files/nodejs/node.exe', sockPath: '\\\\.\\pipe\\orca-relay-123', - hostPlatform + hostPlatform: getRemoteHostPlatform('win32-x64') }) + } + + it('compiles a native Windows launcher without a cmd.exe argument bridge', () => { + const plan = windowsInstallPlan() expect(plan.launcherPath).toBe('C:/Users/me user/.orca-relay/bin/orca.exe') expect(plan.files).toHaveLength(1) @@ -54,7 +57,24 @@ describe('SSH remote Orca CLI launcher', () => { expect(compileScript).toContain('/out:orca.exe') expect(compileScript).toContain('C:/Users/me user/.orca-relay/bin/orca-launcher.cs') expect(compileScript).toContain('C:/Users/me user/.orca-relay/bin/orca.cmd') - expect(compileScript.indexOf('orca.cmd')).toBeLessThan(compileScript.indexOf('csc.exe')) + }) + + it('removes the legacy orca.cmd only after every compile guard has passed', () => { + const script = decodePowerShellCommand(windowsInstallPlan().postWriteCommands[0] ?? '') + const legacyShimRemoval = + "Remove-Item -LiteralPath 'C:/Users/me user/.orca-relay/bin/orca.cmd' -Force -ErrorAction SilentlyContinue" + // Why: a host missing csc.exe or failing the compile must keep its existing + // CLI, so every fail-closed guard precedes the legacy %* shim removal. + const guards = [ + "if (-not $compiler) { Write-Error 'Unable to find the .NET Framework C# compiler required for the Orca SSH CLI launcher.'; exit 1 }", + 'if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }', + "if (-not (Test-Path -LiteralPath 'C:/Users/me user/.orca-relay/bin/orca.exe' -PathType Leaf))" + ] + expect(script).toContain(legacyShimRemoval) + for (const guard of guards) { + expect(script).toContain(guard) + expect(script.indexOf(guard)).toBeLessThan(script.indexOf(legacyShimRemoval)) + } }) itWindows('preserves a multiline argument through the compiled remote launcher', () => { @@ -128,6 +148,51 @@ describe('SSH remote Orca CLI launcher', () => { } }) + itWindows('preserves the existing orca.cmd when the compiler is missing', () => { + const root = mkdtempSync(join(tmpdir(), 'orca remote cli ')) + try { + const binDir = join(root, 'bin').replaceAll('\\', '/') + mkdirSync(binDir, { recursive: true }) + const legacyShimPath = join(binDir, 'orca.cmd') + writeFileSync(legacyShimPath, '@echo legacy orca cli\r\n', 'utf8') + + const plan = createRemoteCliInstallPlan({ + binDir, + relayDir: join(root, 'relay').replaceAll('\\', '/'), + nodePath: process.execPath, + sockPath: '\\\\.\\pipe\\orca-relay-test', + hostPlatform: getRemoteHostPlatform('win32-x64') + }) + for (const file of plan.files) { + mkdirSync(dirname(file.path), { recursive: true }) + writeFileSync(file.path, file.contents, 'utf8') + } + + const encoded = plan.postWriteCommands[0]?.match(/-EncodedCommand\s+(\S+)/)?.[1] + expect(encoded).toBeTruthy() + // Point WINDIR at a directory with no csc.exe so compiler discovery fails. + const compile = spawnSync( + 'powershell.exe', + [ + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Bypass', + '-EncodedCommand', + encoded! + ], + { encoding: 'utf8', env: { ...process.env, WINDIR: root, SystemRoot: root } } + ) + + expect(compile.status).not.toBe(0) + expect(existsSync(legacyShimPath), 'existing orca.cmd must survive a failed install').toBe( + true + ) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + it('keeps the POSIX launcher as an argv-preserving shell exec', () => { const plan = createRemoteCliInstallPlan({ binDir: '/home/me/.orca-relay/bin', diff --git a/src/main/ssh/ssh-remote-cli-launcher.ts b/src/main/ssh/ssh-remote-cli-launcher.ts index a246ce9c9b8..4122e0dc09d 100644 --- a/src/main/ssh/ssh-remote-cli-launcher.ts +++ b/src/main/ssh/ssh-remote-cli-launcher.ts @@ -169,9 +169,6 @@ function createWindowsLauncherCompileCommand( .join(' ') return powerShellCommand( [ - // Why: an upgrade must not leave the old %* batch bridge callable when - // compiler discovery fails; losing the convenience CLI is safer. - `Remove-Item -LiteralPath ${powerShellLiteral(legacyShimPath)} -Force -ErrorAction SilentlyContinue`, `Set-Location -ErrorAction Stop -LiteralPath ${powerShellLiteral(binDir)}`, '$windowsDirectory = if ($env:WINDIR) { $env:WINDIR } else { $env:SystemRoot }', `$compilerCandidates = @((Join-Path $windowsDirectory 'Microsoft.NET\\Framework64\\v4.0.30319\\csc.exe'), (Join-Path $windowsDirectory 'Microsoft.NET\\Framework\\v4.0.30319\\csc.exe'))`, @@ -180,6 +177,9 @@ function createWindowsLauncherCompileCommand( `& $compiler ${compilerArgs}`, 'if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }', `if (-not (Test-Path -LiteralPath ${powerShellLiteral(launcherPath)} -PathType Leaf)) { Write-Error 'The Orca SSH CLI launcher compiler produced no executable.'; exit 1 }`, + // Why: remove the legacy %* bridge only after a successful compile, so a + // host missing csc.exe keeps its existing CLI (orca.exe shadows orca.cmd). + `Remove-Item -LiteralPath ${powerShellLiteral(legacyShimPath)} -Force -ErrorAction SilentlyContinue`, `Remove-Item -LiteralPath ${powerShellLiteral(sourcePath)} -Force` ].join('; ') ) diff --git a/src/main/tray/system-tray.test.ts b/src/main/tray/system-tray.test.ts index 9f5ad72caad..71c94f84e74 100644 --- a/src/main/tray/system-tray.test.ts +++ b/src/main/tray/system-tray.test.ts @@ -2,19 +2,60 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as SystemTrayModule from './system-tray' const { - trayInstances, - menuFromTemplateMock, - createAppIconImageMock, + attentionImage, + baseMacImage, composeAttentionMock, - resizedImage + createAppIconImageMock, + createFromPathMock, + menuFromTemplateMock, + nativeThemeMock, + resizedImage, + retinaMacImage, + themeState, + tintedMacImage, + tintTemplateMock, + trayInstances } = vi.hoisted(() => { - const resizedImage = { resized: true } + const makeImage = (name: string) => ({ + name, + addRepresentation: vi.fn(), + getScaleFactors: vi.fn(() => [1, 2]), + getSize: vi.fn(() => ({ width: 16, height: 16 })), + setTemplateImage: vi.fn(), + toBitmap: vi.fn(() => Buffer.alloc(16 * 16 * 4)), + toDataURL: vi.fn(() => `data:image/png;base64,${name}`) + }) + const resizedImage = makeImage('windows') + const baseMacImage = makeImage('mac-base') + const retinaMacImage = makeImage('mac-retina') + const tintedMacImage = makeImage('mac-tinted') + const attentionImage = makeImage('attention') + const themeState = { updatedListener: null as (() => void) | null } + const nativeThemeMock = { + shouldUseDarkColors: false, + on: vi.fn((_event: string, listener: () => void) => { + themeState.updatedListener = listener + }), + removeListener: vi.fn((_event: string, listener: () => void) => { + if (themeState.updatedListener === listener) { + themeState.updatedListener = null + } + }) + } return { - trayInstances: [] as FakeTray[], - menuFromTemplateMock: vi.fn((template: unknown) => ({ template })), + attentionImage, + baseMacImage, + composeAttentionMock: vi.fn(() => attentionImage), createAppIconImageMock: vi.fn(), - composeAttentionMock: vi.fn((image: unknown) => ({ dotted: image })), - resizedImage + createFromPathMock: vi.fn(), + menuFromTemplateMock: vi.fn((template: unknown) => ({ template })), + nativeThemeMock, + resizedImage, + retinaMacImage, + themeState, + tintedMacImage, + tintTemplateMock: vi.fn(() => tintedMacImage), + trayInstances: [] as FakeTray[] } }) @@ -32,7 +73,17 @@ class FakeTray { vi.mock('electron', () => ({ Tray: FakeTray, - Menu: { buildFromTemplate: menuFromTemplateMock } + Menu: { buildFromTemplate: menuFromTemplateMock }, + nativeImage: { createFromPath: createFromPathMock }, + nativeTheme: nativeThemeMock +})) + +vi.mock('../../../resources/tray/orca-menu-barTemplate.png?asset&asarUnpack', () => ({ + default: '/assets/orca-menu-barTemplate.png' +})) + +vi.mock('../../../resources/tray/orca-menu-barTemplate@2x.png?asset&asarUnpack', () => ({ + default: '/assets/orca-menu-barTemplate@2x.png' })) vi.mock('../app-icon', () => ({ @@ -40,10 +91,12 @@ vi.mock('../app-icon', () => ({ })) vi.mock('./tray-attention-icon', () => ({ - composeTrayAttentionIcon: composeAttentionMock + composeTrayAttentionIcon: composeAttentionMock, + tintTrayTemplateForAttention: tintTemplateMock })) type TrayModule = typeof SystemTrayModule +type MenuItem = { label?: string; type?: string; click?: () => void } const originalPlatform = process.platform @@ -56,7 +109,15 @@ async function loadModule(): Promise { return import('./system-tray') } -type MenuItem = { label?: string; type?: string; click?: () => void } +function createOptions() { + return { + appIcon: 'classic', + onOpen: vi.fn(), + onOpenSettings: vi.fn(), + onCheckForUpdates: vi.fn(), + onQuit: vi.fn() + } +} function builtMenuItems(): MenuItem[] { return menuFromTemplateMock.mock.calls.at(-1)?.[0] as MenuItem[] @@ -66,139 +127,289 @@ beforeEach(() => { trayInstances.length = 0 menuFromTemplateMock.mockClear() composeAttentionMock.mockClear() + tintTemplateMock.mockClear() createAppIconImageMock.mockReset() createAppIconImageMock.mockReturnValue({ resize: vi.fn(() => resizedImage) }) + createFromPathMock.mockReset() + createFromPathMock.mockImplementation((path: string) => + path.includes('@2x') ? retinaMacImage : baseMacImage + ) + for (const image of [baseMacImage, retinaMacImage, tintedMacImage, attentionImage]) { + image.addRepresentation.mockClear() + image.getScaleFactors.mockReset().mockReturnValue([1, 2]) + image.getSize.mockReset().mockReturnValue({ width: 16, height: 16 }) + image.setTemplateImage.mockClear() + image.toBitmap.mockClear() + image.toDataURL.mockClear().mockReturnValue(`data:image/png;base64,${image.name}`) + } + nativeThemeMock.shouldUseDarkColors = false + nativeThemeMock.on.mockClear() + nativeThemeMock.removeListener.mockClear() + themeState.updatedListener = null }) afterEach(() => { setPlatform(originalPlatform) + vi.restoreAllMocks() }) describe('createSystemTray', () => { - it('creates a tray with an Orca tooltip and Open/Quit menu on win32', async () => { + it('keeps the Windows icon, Open/Quit menu, and left-click behavior', async () => { setPlatform('win32') const { createSystemTray } = await loadModule() - const onOpen = vi.fn() - const onQuit = vi.fn() + const options = createOptions() - const tray = createSystemTray({ appIcon: 'classic', onOpen, onQuit }) + createSystemTray(options) - expect(tray).not.toBeNull() expect(trayInstances).toHaveLength(1) expect(trayInstances[0].image).toBe(resizedImage) expect(trayInstances[0].setToolTip).toHaveBeenCalledWith('Orca') - const items = builtMenuItems() - expect(items.map((i) => i.label)).toEqual(['Open Orca', undefined, 'Quit']) - expect(items[1].type).toBe('separator') + expect(builtMenuItems().map((item) => item.label)).toEqual(['Open Orca', undefined, 'Quit']) + const clickHandler = trayInstances[0].on.mock.calls.find((call) => call[0] === 'click')?.[1] + expect(clickHandler).toBeTypeOf('function') + + builtMenuItems()[0].click?.() + ;(clickHandler as () => void)() + builtMenuItems()[2].click?.() + expect(options.onOpen).toHaveBeenCalledTimes(2) + expect(options.onQuit).toHaveBeenCalledOnce() }) - it('wires Open Orca, the tray click, and Quit to their callbacks', async () => { - setPlatform('win32') - const { createSystemTray } = await loadModule() - const onOpen = vi.fn() - const onQuit = vi.fn() - - createSystemTray({ appIcon: 'classic', onOpen, onQuit }) - - const items = builtMenuItems() - items.find((i) => i.label === 'Open Orca')?.click?.() - expect(onOpen).toHaveBeenCalledTimes(1) - - const clickHandler = trayInstances[0].on.mock.calls.find((c) => c[0] === 'click')?.[1] as - | (() => void) - | undefined - clickHandler?.() - expect(onOpen).toHaveBeenCalledTimes(2) - - items.find((i) => i.label === 'Quit')?.click?.() - expect(onQuit).toHaveBeenCalledTimes(1) - }) - - it('is idempotent: a second call does not create a duplicate tray', async () => { - setPlatform('win32') - const { createSystemTray } = await loadModule() - const opts = { appIcon: 'classic', onOpen: vi.fn(), onQuit: vi.fn() } - - const first = createSystemTray(opts) - const second = createSystemTray(opts) - - expect(trayInstances).toHaveLength(1) - expect(second).toBe(first) - }) - - it('is a no-op on non-win32 platforms', async () => { + it('creates a macOS template status item with the full native menu', async () => { setPlatform('darwin') const { createSystemTray } = await loadModule() + const options = createOptions() - const tray = createSystemTray({ appIcon: 'classic', onOpen: vi.fn(), onQuit: vi.fn() }) + createSystemTray(options) - expect(tray).toBeNull() + expect(trayInstances).toHaveLength(1) + expect(trayInstances[0].image).toBe(baseMacImage) + expect(baseMacImage.setTemplateImage).toHaveBeenCalledWith(true) + expect(baseMacImage.addRepresentation).toHaveBeenCalledWith({ + scaleFactor: 2, + dataURL: 'data:image/png;base64,mac-retina' + }) + expect(builtMenuItems().map((item) => item.label)).toEqual([ + 'Open Orca', + undefined, + 'Settings', + 'Check for Updates...', + undefined, + 'Quit' + ]) + expect(trayInstances[0].on).not.toHaveBeenCalled() + expect(nativeThemeMock.on).toHaveBeenCalledWith('updated', expect.any(Function)) + + for (const [label, callback] of [ + ['Open Orca', options.onOpen], + ['Settings', options.onOpenSettings], + ['Check for Updates...', options.onCheckForUpdates], + ['Quit', options.onQuit] + ] as const) { + builtMenuItems() + .find((item) => item.label === label) + ?.click?.() + expect(callback).toHaveBeenCalledOnce() + } + }) + + it('does not create a blank macOS item when the template asset fails to load', async () => { + setPlatform('darwin') + baseMacImage.getSize.mockReturnValue({ width: 0, height: 0 }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { createSystemTray } = await loadModule() + + expect(createSystemTray(createOptions())).toBeNull() expect(trayInstances).toHaveLength(0) + expect(warn).toHaveBeenCalledWith('[system-tray] macOS menu bar icon could not be loaded') + }) + + it('is idempotent and remains disabled on Linux', async () => { + setPlatform('darwin') + const { createSystemTray } = await loadModule() + const options = createOptions() + expect(createSystemTray(options)).toBe(createSystemTray(options)) + expect(trayInstances).toHaveLength(1) + + setPlatform('linux') + const linuxModule = await loadModule() + expect(linuxModule.createSystemTray(options)).toBeNull() + }) +}) + +describe('macOS visibility', () => { + it('creates and destroys the item as the setting toggles', async () => { + setPlatform('darwin') + const { setMacMenuBarIconVisible } = await loadModule() + const options = createOptions() + + expect(setMacMenuBarIconVisible(false, options)).toBeNull() + expect(trayInstances).toHaveLength(0) + expect(setMacMenuBarIconVisible(true, options)).not.toBeNull() + expect(trayInstances).toHaveLength(1) + + setMacMenuBarIconVisible(false, options) + setMacMenuBarIconVisible(false, options) + expect(trayInstances[0].destroy).toHaveBeenCalledOnce() }) }) describe('setTrayAttention', () => { - it('swaps in the dotted icon when active and restores the base when cleared', async () => { + it('preserves the Windows attention swap behavior', async () => { setPlatform('win32') const { createSystemTray, setTrayAttention } = await loadModule() - createSystemTray({ appIcon: 'classic', onOpen: vi.fn(), onQuit: vi.fn() }) - const tray = trayInstances[0] - tray.setImage.mockClear() + createSystemTray(createOptions()) + const created = trayInstances[0] + created.setImage.mockClear() setTrayAttention(true) expect(composeAttentionMock).toHaveBeenCalledWith(resizedImage) - expect(tray.setImage).toHaveBeenCalledWith({ dotted: resizedImage }) - - tray.setImage.mockClear() + expect(created.setImage).toHaveBeenCalledWith(attentionImage) setTrayAttention(false) - expect(tray.setImage).toHaveBeenCalledWith(resizedImage) + expect(created.setImage).toHaveBeenLastCalledWith(resizedImage) }) - it('ignores repeated same-state calls', async () => { - setPlatform('win32') - const { createSystemTray, setTrayAttention } = await loadModule() - createSystemTray({ appIcon: 'classic', onOpen: vi.fn(), onQuit: vi.fn() }) - const tray = trayInstances[0] - tray.setImage.mockClear() - - setTrayAttention(true) - setTrayAttention(true) - - expect(tray.setImage).toHaveBeenCalledTimes(1) - }) - - it('reflects attention that was requested before the tray was created', async () => { - setPlatform('win32') - const { createSystemTray, setTrayAttention } = await loadModule() - - // Fire the event before the (deferred) tray exists. - setTrayAttention(true) - createSystemTray({ appIcon: 'classic', onOpen: vi.fn(), onQuit: vi.fn() }) - const tray = trayInstances[0] - - expect(tray.setImage).toHaveBeenCalledWith({ dotted: resizedImage }) - }) - - it('is a safe no-op on non-win32 platforms', async () => { + it('uses a literal theme-aware glyph for macOS attention, then restores template mode', async () => { setPlatform('darwin') - const { setTrayAttention } = await loadModule() + const { createSystemTray, setTrayAttention } = await loadModule() + createSystemTray(createOptions()) + const created = trayInstances[0] + created.setImage.mockClear() + created.setToolTip.mockClear() - expect(() => setTrayAttention(true)).not.toThrow() - expect(composeAttentionMock).not.toHaveBeenCalled() + setTrayAttention(true) + expect(tintTemplateMock).toHaveBeenCalledWith(baseMacImage, false) + expect(composeAttentionMock).toHaveBeenCalledWith(tintedMacImage) + // Why: the attention image is rebuilt from 1x pixels, so the @2x + // representation must be re-added or the glyph blurs on Retina. + expect(tintTemplateMock).toHaveBeenCalledWith(baseMacImage, false, 2) + expect(attentionImage.addRepresentation).toHaveBeenCalledWith({ + scaleFactor: 2, + dataURL: 'data:image/png;base64,attention' + }) + expect(attentionImage.setTemplateImage).toHaveBeenCalledWith(false) + expect(created.setImage).toHaveBeenCalledWith(attentionImage) + expect(created.setToolTip).toHaveBeenCalledWith('Orca - activity waiting') + + setTrayAttention(false) + expect(baseMacImage.setTemplateImage).toHaveBeenLastCalledWith(true) + expect(created.setImage).toHaveBeenLastCalledWith(baseMacImage) + expect(created.setToolTip).toHaveBeenLastCalledWith('Orca') + }) + + it('recomposes active macOS attention when the system appearance changes', async () => { + setPlatform('darwin') + const { createSystemTray, setTrayAttention } = await loadModule() + createSystemTray(createOptions()) + setTrayAttention(true) + tintTemplateMock.mockClear() + nativeThemeMock.shouldUseDarkColors = true + + themeState.updatedListener?.() + + expect(tintTemplateMock).toHaveBeenCalledWith(baseMacImage, true) + }) + + it('reflects attention requested before deferred macOS creation', async () => { + setPlatform('darwin') + const { createSystemTray, setTrayAttention } = await loadModule() + setTrayAttention(true) + createSystemTray(createOptions()) + + expect(trayInstances[0].setImage).toHaveBeenCalledWith(attentionImage) + }) + + it('ignores repeated same-state attention calls', async () => { + setPlatform('win32') + const { createSystemTray, setTrayAttention } = await loadModule() + createSystemTray(createOptions()) + const created = trayInstances[0] + created.setImage.mockClear() + + setTrayAttention(true) + setTrayAttention(true) + + expect(created.setImage).toHaveBeenCalledTimes(1) + }) + + it('keeps a pending attention dot across a macOS hide/show toggle', async () => { + setPlatform('darwin') + const { setMacMenuBarIconVisible, setTrayAttention } = await loadModule() + const options = createOptions() + setMacMenuBarIconVisible(true, options) + setTrayAttention(true) + + setMacMenuBarIconVisible(false, options) + setMacMenuBarIconVisible(true, options) + + expect(trayInstances).toHaveLength(2) + expect(trayInstances[1].setImage).toHaveBeenCalledWith(attentionImage) }) }) describe('destroySystemTray', () => { - it('destroys an existing tray and is safe to call without one', async () => { - setPlatform('win32') + it('destroys idempotently and removes the macOS appearance listener', async () => { + setPlatform('darwin') const { createSystemTray, destroySystemTray } = await loadModule() - createSystemTray({ appIcon: 'classic', onOpen: vi.fn(), onQuit: vi.fn() }) + createSystemTray(createOptions()) const created = trayInstances[0] + const listener = themeState.updatedListener destroySystemTray() - expect(created.destroy).toHaveBeenCalledTimes(1) + destroySystemTray() - // Second call with no live tray must not throw. - expect(() => destroySystemTray()).not.toThrow() + expect(created.destroy).toHaveBeenCalledOnce() + expect(nativeThemeMock.removeListener).toHaveBeenCalledWith('updated', listener) + }) +}) + +describe('macOS hardening', () => { + it('warns but still creates the item when only the @2x asset fails to load', async () => { + setPlatform('darwin') + retinaMacImage.getSize.mockReturnValue({ width: 0, height: 0 }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { createSystemTray } = await loadModule() + + expect(createSystemTray(createOptions())).not.toBeNull() + expect(trayInstances).toHaveLength(1) + expect(baseMacImage.addRepresentation).not.toHaveBeenCalled() + expect(warn).toHaveBeenCalledWith( + '[system-tray] macOS retina menu bar icon could not be loaded' + ) + }) + + it('degrades to the plain icon instead of throwing when attention composition fails', async () => { + setPlatform('darwin') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { createSystemTray, setTrayAttention } = await loadModule() + createSystemTray(createOptions()) + const created = trayInstances[0] + created.setImage.mockClear() + composeAttentionMock.mockImplementationOnce(() => { + throw new Error('native image failure') + }) + + expect(() => setTrayAttention(true)).not.toThrow() + expect(created.setImage).toHaveBeenLastCalledWith(baseMacImage) + expect(created.setToolTip).toHaveBeenLastCalledWith('Orca') + expect(warn).toHaveBeenCalledWith( + '[system-tray] macOS attention icon failed; showing plain icon', + expect.any(Error) + ) + }) + + it('contains a throwing menu-item callback instead of crashing', async () => { + setPlatform('darwin') + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + const { createSystemTray } = await loadModule() + const options = createOptions() + options.onOpenSettings.mockImplementation(() => { + throw new Error('boom') + }) + createSystemTray(options) + + const settingsItem = builtMenuItems().find((item) => item.label === 'Settings') + expect(() => settingsItem?.click?.()).not.toThrow() + expect(error).toHaveBeenCalledWith('[system-tray] menu action failed', expect.any(Error)) }) }) diff --git a/src/main/tray/system-tray.ts b/src/main/tray/system-tray.ts index ada6f58c4e9..27d2487a3c5 100644 --- a/src/main/tray/system-tray.ts +++ b/src/main/tray/system-tray.ts @@ -1,13 +1,19 @@ -import { Menu, Tray, type NativeImage } from 'electron' +import { Menu, Tray, nativeImage, nativeTheme, type NativeImage } from 'electron' +import menuBarIconPath from '../../../resources/tray/orca-menu-barTemplate.png?asset&asarUnpack' +import menuBarIconRetinaPath from '../../../resources/tray/orca-menu-barTemplate@2x.png?asset&asarUnpack' import { createAppIconImage } from '../app-icon' import { translateMain } from '../i18n/main-i18n' -import { composeTrayAttentionIcon } from './tray-attention-icon' +import { composeTrayAttentionIcon, tintTrayTemplateForAttention } from './tray-attention-icon' -type SystemTrayOptions = { +export type SystemTrayOptions = { /** App icon id from settings; the tray reuses the app icon image. */ appIcon: unknown /** Restore + show + focus the main window (recreating it if needed). */ onOpen: () => void + /** Restore the main window and open its Settings surface. */ + onOpenSettings: () => void + /** Run the existing user-initiated update check. */ + onCheckForUpdates: () => void /** Quit Orca for real (caller must set the quitting latch before quitting). */ onQuit: () => void } @@ -25,6 +31,8 @@ let baseTrayImage: NativeImage | null = null // freshly created tray reflects it immediately. let attentionActive = false +let nativeThemeUpdatedListener: (() => void) | null = null + // Why: on Windows the notification area expects a 16px icon; the app icon PNG // is larger, so downscale to avoid a cropped/blurry tray glyph. const TRAY_ICON_SIZE = 16 @@ -35,46 +43,195 @@ function applyTrayImage(): void { if (!tray || tray.isDestroyed() || !baseTrayImage) { return } + + if (process.platform === 'darwin') { + if (attentionActive) { + try { + // Why: disabling template tinting makes the amber dot possible, but the + // glyph then needs literal pixels chosen for the current menu-bar theme. + const useLightGlyph = nativeTheme.shouldUseDarkColors + const attentionImage = composeTrayAttentionIcon( + tintTrayTemplateForAttention(baseTrayImage, useLightGlyph) + ) + if (baseTrayImage.getScaleFactors().includes(2)) { + // Why: toBitmap reads only the 1x pixels, so rebuild the @2x + // representation or the glyph blurs on Retina during attention. + const retinaAttentionImage = composeTrayAttentionIcon( + tintTrayTemplateForAttention(baseTrayImage, useLightGlyph, 2) + ) + attentionImage.addRepresentation({ + scaleFactor: 2, + dataURL: retinaAttentionImage.toDataURL() + }) + } + attentionImage.setTemplateImage(false) + tray.setImage(attentionImage) + tray.setToolTip(translateMain('tray.activityWaiting', 'Orca - activity waiting')) + return + } catch (error) { + // Why: this path runs inside unguarded callbacks (nativeTheme 'updated', + // the notification bell), so a NativeImage failure must degrade to the + // plain template icon rather than throw an uncaught exception into them. + console.warn('[system-tray] macOS attention icon failed; showing plain icon', error) + } + } + + baseTrayImage.setTemplateImage(true) + tray.setImage(baseTrayImage) + tray.setToolTip('Orca') + return + } + tray.setImage(attentionActive ? composeTrayAttentionIcon(baseTrayImage) : baseTrayImage) } +function createMacMenuBarImage(): NativeImage | null { + const image = nativeImage.createFromPath(menuBarIconPath) + const { width, height } = image.getSize() + if (width <= 0 || height <= 0) { + console.warn('[system-tray] macOS menu bar icon could not be loaded') + return null + } + + const retinaImage = nativeImage.createFromPath(menuBarIconRetinaPath) + const retinaSize = retinaImage.getSize() + if (retinaSize.width > 0 && retinaSize.height > 0) { + try { + // Why: importing the @2x asset guarantees it is packaged; adding the + // representation explicitly avoids relying on bundler-renamed siblings. + image.addRepresentation({ + scaleFactor: 2, + dataURL: retinaImage.toDataURL() + }) + } catch (error) { + // Why: a bad @2x representation must not abort tray creation; the 1x image + // still renders (just blurrier on Retina). + console.warn('[system-tray] macOS retina menu bar icon could not be added', error) + } + } else { + // Why: surface the silently-degraded (non-Retina) icon so a blurry menu-bar + // glyph on a modern display is diagnosable, matching the base-image warning. + console.warn('[system-tray] macOS retina menu bar icon could not be loaded') + } + image.setTemplateImage(true) + return image +} + +function watchMacAppearance(): void { + if (nativeThemeUpdatedListener) { + return + } + nativeThemeUpdatedListener = () => { + if (attentionActive) { + applyTrayImage() + } + } + nativeTheme.on('updated', nativeThemeUpdatedListener) +} + +function stopWatchingMacAppearance(): void { + if (!nativeThemeUpdatedListener) { + return + } + nativeTheme.removeListener('updated', nativeThemeUpdatedListener) + nativeThemeUpdatedListener = null +} + +// Why: Electron Menu/Tray click callbacks are plain event listeners (not +// promise-wrapped like ipcMain.handle), so an uncaught throw here is fatal to +// the main process; contain it to a logged, failed menu action instead. +function safeMenuAction(action: () => void): () => void { + return () => { + try { + action() + } catch (error) { + console.error('[system-tray] menu action failed', error) + } + } +} + /** - * Creates the Windows system tray icon. No-op on macOS/Linux. Idempotent: a - * second call while a tray is alive returns the existing one instead of - * stacking a duplicate ghost icon. + * Creates the Windows notification icon or macOS menu bar status item. No-op + * on Linux. Idempotent: repeated calls never stack duplicate icons. */ export function createSystemTray(opts: SystemTrayOptions): Tray | null { - if (process.platform !== 'win32') { + if (process.platform !== 'win32' && process.platform !== 'darwin') { return null } if (tray && !tray.isDestroyed()) { return tray } - baseTrayImage = createAppIconImage(opts.appIcon).resize({ - width: TRAY_ICON_SIZE, - height: TRAY_ICON_SIZE - }) + + if (process.platform === 'darwin') { + baseTrayImage = createMacMenuBarImage() + if (!baseTrayImage) { + return null + } + } else { + baseTrayImage = createAppIconImage(opts.appIcon).resize({ + width: TRAY_ICON_SIZE, + height: TRAY_ICON_SIZE + }) + } + tray = new Tray(baseTrayImage) // Why: reflect any attention event that fired before the tray existed. applyTrayImage() - tray.setToolTip('Orca') + const menu = Menu.buildFromTemplate([ - { label: translateMain('tray.openOrca', 'Open Orca'), click: () => opts.onOpen() }, + { + label: translateMain('tray.openOrca', 'Open Orca'), + click: safeMenuAction(() => opts.onOpen()) + }, { type: 'separator' }, - { label: translateMain('tray.quit', 'Quit'), click: () => opts.onQuit() } + // Why: reuse the app menu's keys so the two entry points never drift. + ...(process.platform === 'darwin' + ? ([ + { + label: translateMain('menu.settings', 'Settings'), + click: safeMenuAction(() => opts.onOpenSettings()) + }, + { + label: translateMain('menu.checkForUpdates', 'Check for Updates...'), + click: safeMenuAction(() => opts.onCheckForUpdates()) + }, + { type: 'separator' } + ] as Electron.MenuItemConstructorOptions[]) + : []), + { label: translateMain('tray.quit', 'Quit'), click: safeMenuAction(() => opts.onQuit()) } ]) tray.setContextMenu(menu) - // Why: a left-click on the tray icon is the conventional Windows gesture to - // restore a minimized-to-tray app. - tray.on('click', () => opts.onOpen()) + if (process.platform === 'win32') { + tray.setToolTip('Orca') + // Why: a left-click on the tray icon is the conventional Windows gesture to + // restore a minimized-to-tray app; macOS opens the attached menu instead. + tray.on( + 'click', + safeMenuAction(() => opts.onOpen()) + ) + } else { + watchMacAppearance() + } return tray } +/** Applies the persisted macOS visibility preference without affecting Windows. */ +export function setMacMenuBarIconVisible(visible: boolean, opts: SystemTrayOptions): Tray | null { + if (process.platform !== 'darwin') { + return null + } + if (!visible) { + destroySystemTray() + return null + } + return createSystemTray(opts) +} + /** * Shows or hides a red/amber attention dot on the tray icon. Call with `true` * when a terminal bell or agent completion fires while the window is - * minimized/hidden, and `false` once the window is shown again. No-op on - * macOS/Linux (no tray) and safe to call before the tray is created. + * minimized/hidden, and `false` once the window is shown again. Safe to call + * before the tray is created or on Linux where no tray is available. */ export function setTrayAttention(active: boolean): void { if (attentionActive === active) { @@ -86,10 +243,12 @@ export function setTrayAttention(active: boolean): void { /** Destroys the tray icon if present. Safe to call repeatedly or with no tray. */ export function destroySystemTray(): void { + stopWatchingMacAppearance() if (tray && !tray.isDestroyed()) { tray.destroy() } tray = null baseTrayImage = null - attentionActive = false + // Why: attention is owned by the notification/visibility flow, and must + // survive the macOS hide/show toggle so a re-shown icon keeps its dot. } diff --git a/src/main/tray/tray-attention-icon.test.ts b/src/main/tray/tray-attention-icon.test.ts index 42d554417e0..090847e284e 100644 --- a/src/main/tray/tray-attention-icon.test.ts +++ b/src/main/tray/tray-attention-icon.test.ts @@ -12,7 +12,7 @@ vi.mock('electron', () => ({ nativeImage: { createFromBitmap: createFromBitmapMock } })) -import { composeTrayAttentionIcon } from './tray-attention-icon' +import { composeTrayAttentionIcon, tintTrayTemplateForAttention } from './tray-attention-icon' type FakeImage = { getSize: () => { width: number; height: number } @@ -86,3 +86,59 @@ describe('composeTrayAttentionIcon', () => { expect(paintedInBottomLeft).toBe(0) // the opposite corner is never touched }) }) + +describe('tintTrayTemplateForAttention', () => { + it('preserves alpha while selecting literal black pixels for a light menu bar', () => { + createFromBitmapMock.mockClear() + const bitmap = Buffer.from([0x22, 0x33, 0x44, 0x80, 0xaa, 0xbb, 0xcc, 0x00]) + const base = { + getSize: () => ({ width: 2, height: 1 }), + toBitmap: () => bitmap + } + + tintTrayTemplateForAttention(base as never, false) + + expect(createFromBitmapMock.mock.calls[0][0]).toEqual( + Buffer.from([0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00]) + ) + }) + + it('selects literal white pixels for a dark menu bar', () => { + createFromBitmapMock.mockClear() + const base = { + getSize: () => ({ width: 1, height: 1 }), + toBitmap: () => Buffer.from([0x00, 0x00, 0x00, 0xff]) + } + + tintTrayTemplateForAttention(base as never, true) + + expect(createFromBitmapMock.mock.calls[0][0]).toEqual(Buffer.from([0xff, 0xff, 0xff, 0xff])) + }) + + it('keeps white edge pixels premultiplied-valid at partial alpha', () => { + createFromBitmapMock.mockClear() + const base = { + getSize: () => ({ width: 1, height: 1 }), + toBitmap: () => Buffer.from([0x00, 0x00, 0x00, 0x80]) + } + + tintTrayTemplateForAttention(base as never, true) + + // Why: the bitmap is premultiplied — white at 50% coverage is 0x80, not 0xff. + expect(createFromBitmapMock.mock.calls[0][0]).toEqual(Buffer.from([0x80, 0x80, 0x80, 0x80])) + }) + + it('reads and sizes the requested scale factor', () => { + createFromBitmapMock.mockClear() + const toBitmap = vi.fn(() => Buffer.from([0x00, 0x00, 0x00, 0xff])) + const base = { + getSize: () => ({ width: 1, height: 1 }), + toBitmap + } + + tintTrayTemplateForAttention(base as never, true, 2) + + expect(toBitmap).toHaveBeenCalledWith({ scaleFactor: 2 }) + expect(createFromBitmapMock.mock.calls[0][1]).toEqual({ width: 2, height: 2 }) + }) +}) diff --git a/src/main/tray/tray-attention-icon.ts b/src/main/tray/tray-attention-icon.ts index d9b042db37f..ffdf4528d47 100644 --- a/src/main/tray/tray-attention-icon.ts +++ b/src/main/tray/tray-attention-icon.ts @@ -7,6 +7,36 @@ const DOT_RGB = { r: 0xf5, g: 0x9e, b: 0x0b } // A near-white halo separates the dot from the icon glyph on any tray theme. const RING_RGB = { r: 0xff, g: 0xff, b: 0xff } +/** + * Converts a template mask into a literal glyph for the attention state. A + * non-template image needs real RGB values because macOS no longer tints it. + */ +export function tintTrayTemplateForAttention( + base: NativeImage, + useLightGlyph: boolean, + scaleFactor = 1 +): NativeImage { + const { width, height } = base.getSize() + if (width <= 0 || height <= 0) { + return base + } + + const bitmap = Buffer.from(base.toBitmap({ scaleFactor })) + for (let offset = 0; offset < bitmap.length; offset += 4) { + // Why: the bitmap is premultiplied, so a light glyph must write the pixel's + // alpha (not 0xff) or antialiased edges become invalid over-bright pixels. + const channel = useLightGlyph ? bitmap[offset + 3] : 0x00 + bitmap[offset] = channel + bitmap[offset + 1] = channel + bitmap[offset + 2] = channel + } + + return nativeImage.createFromBitmap(bitmap, { + width: width * scaleFactor, + height: height * scaleFactor + }) +} + /** * Returns a copy of `base` with a small amber attention dot composited into the * top-right corner. Electron's NativeImage has no compositing API, so we merge diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 0dd54c2c598..21325c3e036 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -38,6 +38,7 @@ import type { import type { RuntimeMobileSessionTabMove } from '../../shared/runtime-types' import { isNativeFileDropPayload, type NativeFileDropPayload } from '../../shared/native-file-drop' import { requestMobileMarkdownFromRenderer } from './mobile-markdown-request-relay' +import { requestTerminalTabCloseFromRenderer } from './terminal-tab-close-request-relay' import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' import type { ClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection' import { runWorktreeChangeInvalidators } from '../ipc/worktree-change-invalidators' @@ -389,6 +390,7 @@ function registerRuntimeWindowLifecycle( content }) as Promise, closeTerminal: (tabId, paneRuntimeId) => send('ui:closeTerminal', { tabId, paneRuntimeId }), + closeTerminalTab: (tabId) => requestTerminalTabCloseFromRenderer(mainWindow, tabId), sleepWorktree: (worktreeId) => send('ui:sleepWorktree', { worktreeId }), resumeSleepingAgents: (worktreeId) => send('ui:resumeSleepingAgents', { worktreeId }), terminalFitOverrideChanged: (ptyId, mode, cols, rows) => diff --git a/src/main/window/terminal-tab-close-request-relay.test.ts b/src/main/window/terminal-tab-close-request-relay.test.ts new file mode 100644 index 00000000000..49eee1ce299 --- /dev/null +++ b/src/main/window/terminal-tab-close-request-relay.test.ts @@ -0,0 +1,71 @@ +import { EventEmitter } from 'node:events' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const ipcEmitter = new EventEmitter() +const ipcMainMock = { + on: vi.fn((channel: string, listener: (...args: unknown[]) => void) => { + ipcEmitter.on(channel, listener) + }), + removeListener: vi.fn((channel: string, listener: (...args: unknown[]) => void) => { + ipcEmitter.removeListener(channel, listener) + }) +} + +vi.mock('electron', () => ({ ipcMain: ipcMainMock })) + +describe('requestTerminalTabCloseFromRenderer', () => { + beforeEach(() => { + ipcEmitter.removeAllListeners() + ipcMainMock.on.mockClear() + ipcMainMock.removeListener.mockClear() + }) + + it('waits for the targeted renderer durability acknowledgement', async () => { + const { requestTerminalTabCloseFromRenderer } = + await import('./terminal-tab-close-request-relay') + const webContents = { isDestroyed: () => false, send: vi.fn() } + const otherWebContents = {} + const mainWindow = { isDestroyed: () => false, webContents } + const pending = requestTerminalTabCloseFromRenderer(mainWindow as never, 'tab-1') + const request = webContents.send.mock.calls[0]?.[1] as { requestId: string; tabId: string } + + expect(request.tabId).toBe('tab-1') + ipcEmitter.emit( + 'ui:terminalTabCloseResponse', + { sender: otherWebContents }, + { requestId: request.requestId } + ) + let settled = false + void pending.finally(() => { + settled = true + }) + await Promise.resolve() + expect(settled).toBe(false) + + ipcEmitter.emit( + 'ui:terminalTabCloseResponse', + { sender: webContents }, + { requestId: request.requestId } + ) + await expect(pending).resolves.toBeUndefined() + }) + + it('propagates renderer cancellation instead of reporting success', async () => { + const { requestTerminalTabCloseFromRenderer } = + await import('./terminal-tab-close-request-relay') + const webContents = { isDestroyed: () => false, send: vi.fn() } + const pending = requestTerminalTabCloseFromRenderer( + { isDestroyed: () => false, webContents } as never, + 'tab-pinned' + ) + const request = webContents.send.mock.calls[0]?.[1] as { requestId: string } + + ipcEmitter.emit( + 'ui:terminalTabCloseResponse', + { sender: webContents }, + { requestId: request.requestId, error: 'terminal_tab_pinned' } + ) + + await expect(pending).rejects.toThrow('terminal_tab_pinned') + }) +}) diff --git a/src/main/window/terminal-tab-close-request-relay.ts b/src/main/window/terminal-tab-close-request-relay.ts new file mode 100644 index 00000000000..c788f79a95b --- /dev/null +++ b/src/main/window/terminal-tab-close-request-relay.ts @@ -0,0 +1,43 @@ +import { randomUUID } from 'node:crypto' + +import { ipcMain } from 'electron' +import type { BrowserWindow } from 'electron' +import type { + TerminalTabCloseRequest, + TerminalTabCloseResponse +} from '../../shared/terminal-tab-close' + +const TERMINAL_TAB_CLOSE_TIMEOUT_MS = 20_000 + +export async function requestTerminalTabCloseFromRenderer( + mainWindow: BrowserWindow, + tabId: string +): Promise { + if (mainWindow.isDestroyed() || mainWindow.webContents.isDestroyed()) { + throw new Error('renderer_unavailable') + } + const requestId = randomUUID() + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + ipcMain.removeListener('ui:terminalTabCloseResponse', onResponse) + reject(new Error('terminal_tab_close_timeout')) + }, TERMINAL_TAB_CLOSE_TIMEOUT_MS) + const onResponse = (event: Electron.IpcMainEvent, response: TerminalTabCloseResponse): void => { + // Why: request IDs are visible to renderer code; only the selected main + // window may commit or reject its lifecycle transaction. + if (event.sender !== mainWindow.webContents || response.requestId !== requestId) { + return + } + clearTimeout(timeout) + ipcMain.removeListener('ui:terminalTabCloseResponse', onResponse) + if (response.error) { + reject(new Error(response.error)) + } else { + resolve() + } + } + ipcMain.on('ui:terminalTabCloseResponse', onResponse) + const request: TerminalTabCloseRequest = { requestId, tabId } + mainWindow.webContents.send('ui:terminalTabCloseRequest', request) + }) +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 92ba7bd92d9..a3db4bbbc55 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -9,6 +9,10 @@ import type { HostedReviewProvider } from '../shared/hosted-review' import type { NativeFileDropPayload } from '../shared/native-file-drop' +import type { + TerminalTabCloseRequest, + TerminalTabCloseResponse +} from '../shared/terminal-tab-close' import type { LocalLogTailChangedPayload, LocalLogTailReadArgs, @@ -317,6 +321,7 @@ import type { ResolvedSourceControlAiGenerationParams } from '../shared/source-c import type { SourceControlAiSettings } from '../shared/source-control-ai-types' import type { ShellOpenLocalPathResult } from '../shared/shell-open-types' import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../shared/skills' +import type { SkillFreshnessInventory } from '../shared/skill-freshness' import type { CrashReportBreadcrumbData, CrashReportCopyDiagnosticsArgs, @@ -828,10 +833,15 @@ export type NativeChatReadSessionResult = /** Messages appended to a live-tailed transcript since the previous emit. */ export type NativeChatAppendedMessages = NativeChatMessage[] +export type NativeChatSubscriptionFrame = + | { type: 'snapshot'; messages: NativeChatMessage[]; hasMore: boolean; error?: string } + | { type: 'replacement'; messages: NativeChatMessage[]; hasMore: boolean } + | { type: 'appended'; messages: NativeChatMessage[] } + /** Wire payload for the `nativeChat:appended` push channel. */ export type NativeChatAppendedPayload = { subscriptionId: string - messages: NativeChatAppendedMessages + frame: NativeChatSubscriptionFrame } export type NativeChatSubscribeArgs = { @@ -842,6 +852,8 @@ export type NativeChatSubscribeArgs = { sessionId: string /** Authoritative transcript path from the agent hook (providerSession). */ transcriptPath?: string + /** First snapshot size; later readSession calls grow this for pagination. */ + limit?: number } export type NativeChatApi = { @@ -855,11 +867,11 @@ export type NativeChatApi = { limit?: number, transcriptPath?: string ) => Promise - /** Live-tail a transcript: `onAppended` fires with only newly-appended - * messages. Returns an unsubscribe fn that closes the main-process watcher. */ + /** Live-tail a transcript. The first frame is a bounded race-safe snapshot; + * later frames contain only newly appended messages. */ subscribe: ( args: NativeChatSubscribeArgs, - onAppended: (messages: NativeChatAppendedMessages) => void + onFrame: (frame: NativeChatSubscriptionFrame) => void ) => () => void } @@ -1411,11 +1423,18 @@ export type PreloadApi = { onClearBufferRequest: (callback: (data: { ptyId: string }) => void) => () => void sendSerializedBuffer: ( requestId: string, - snapshot: { data: string; cols: number; rows: number; lastTitle?: string } | null + snapshot: { + data: string + cols: number + rows: number + seq?: number + lastTitle?: string + } | null ) => void declarePendingPaneSerializer: (paneKey: string) => Promise settlePaneSerializer: (paneKey: string, gen: number) => Promise clearPendingPaneSerializer: (paneKey: string, gen: number) => Promise + reportRendererSerializerReady?: (ptyId: string) => Promise management: PtyManagementApi } feedback: { @@ -1533,7 +1552,7 @@ export type PreloadApi = { repoId?: string limit?: number query?: string - before?: string + page?: number noCache?: boolean }) => Promise>> prChecks: ( @@ -1996,6 +2015,7 @@ export type PreloadApi = { siteUrl: string email: string apiToken: string + authType?: 'cloud' | 'server' }) => Promise<{ ok: true; viewer: JiraViewer } | { ok: false; error: string }> disconnect: (args?: { siteId?: string }) => Promise selectSite: (args: { siteId: JiraSiteSelection }) => Promise @@ -2236,6 +2256,7 @@ export type PreloadApi = { } skills: { discover: (target?: SkillDiscoveryTarget) => Promise + freshnessInventory: () => Promise } pet: { import: () => Promise @@ -2354,6 +2375,7 @@ export type PreloadApi = { get: (hostId?: ExecutionHostId) => Promise set: (args: WorkspaceSessionState, hostId?: ExecutionHostId) => Promise patch: (args: WorkspaceSessionPatch, hostId?: ExecutionHostId) => Promise + flush: () => Promise readTerminalScrollback: (args: { ref: string }) => string | null setSync: (args: WorkspaceSessionState, hostId?: ExecutionHostId) => void } @@ -2538,7 +2560,10 @@ export type PreloadApi = { connectionId?: string includeIgnored?: boolean bypassEffectiveUpstreamNegativeCache?: boolean + reuseLineStats?: boolean + requestToken?: string }) => Promise + cancelStatus: (args: { requestToken: string }) => Promise submoduleStatus: (args: { worktreePath: string submodulePath: string @@ -2742,6 +2767,8 @@ export type PreloadApi = { recordFeatureInteraction: (id: FeatureInteractionId) => Promise onStateChanged: (callback: (ui: PersistedUIState) => void) => () => void onOpenSettings: (callback: () => void) => () => void + /** Consumes a one-shot tray/menu-bar "open settings" intent queued before mount. */ + consumePendingOpenSettings: () => Promise onOpenSetupGuide: (callback: () => void) => () => void onOpenFeatureTour: (callback: () => void) => () => void onOpenCrashReport: (callback: () => void) => () => void @@ -2841,6 +2868,9 @@ export type PreloadApi = { onRequestTerminalCreate: ( callback: (data: RuntimeTerminalCreateRequestPayload) => void ) => () => void + onRequestTerminalTabMount: ( + callback: (data: { worktreeId: string; tabId?: string; ptyId?: string }) => void + ) => () => void replyTerminalCreate: (reply: { requestId: string tabId?: string @@ -2902,6 +2932,8 @@ export type PreloadApi = { onCloseTerminal: ( callback: (data: { tabId: string; paneRuntimeId?: number }) => void ) => () => void + onTerminalTabCloseRequest: (callback: (request: TerminalTabCloseRequest) => void) => () => void + respondTerminalTabClose: (response: TerminalTabCloseResponse) => void onSleepWorktree: (callback: (data: { worktreeId: string }) => void) => () => void onResumeSleepingAgents: (callback: (data: { worktreeId: string }) => void) => () => void onTerminalZoom: (callback: (direction: 'in' | 'out' | 'reset') => void) => () => void diff --git a/src/preload/index.ts b/src/preload/index.ts index 05e1d62dafe..4aa685d7b56 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -72,6 +72,7 @@ import type { import type { GitHistoryOptions, GitHistoryResult } from '../shared/git-history' import type { ShellOpenLocalPathResult } from '../shared/shell-open-types' import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../shared/skills' +import type { SkillFreshnessInventory } from '../shared/skill-freshness' import type { RuntimeBrowserDriverState, RuntimeMobileSessionTabMove, @@ -176,9 +177,9 @@ import type { KeybindingActionId, KeybindingFileSnapshot } from '../shared/keybi import type { AiVaultListArgs, AiVaultSubagentListArgs } from '../shared/ai-vault-types' import type { AgentType } from '../shared/native-chat-types' import type { - NativeChatAppendedMessages, NativeChatAppendedPayload, - NativeChatReadSessionResult + NativeChatReadSessionResult, + NativeChatSubscriptionFrame } from './api-types' import { ORCA_EDITOR_PREPARE_HOT_EXIT_EVENT, @@ -1142,7 +1143,13 @@ const api = { sendSerializedBuffer: ( requestId: string, - snapshot: { data: string; cols: number; rows: number; lastTitle?: string } | null + snapshot: { + data: string + cols: number + rows: number + seq?: number + lastTitle?: string + } | null ): void => { ipcRenderer.send('pty:serializeBuffer:response', { requestId, snapshot }) }, @@ -1161,6 +1168,9 @@ const api = { clearPendingPaneSerializer: (paneKey: string, gen: number): Promise => ipcRenderer.invoke('pty:clearPendingPaneSerializer', { paneKey, gen }), + reportRendererSerializerReady: (ptyId: string): Promise => + ipcRenderer.invoke('pty:reportRendererSerializerReady', { ptyId }), + management: { listSessions: () => ipcRenderer.invoke('pty:management:listSessions'), killAll: () => ipcRenderer.invoke('pty:management:killAll'), @@ -1319,7 +1329,7 @@ const api = { repoId?: string limit?: number query?: string - before?: string + page?: number noCache?: boolean }): Promise>> => ipcRenderer.invoke('gh:listWorkItems', args), @@ -1735,6 +1745,7 @@ const api = { siteUrl: string email: string apiToken: string + authType?: 'cloud' | 'server' }): Promise<{ ok: true; viewer: unknown } | { ok: false; error: string }> => ipcRenderer.invoke('jira:connect', args), @@ -2206,7 +2217,9 @@ const api = { skills: { discover: (target?: SkillDiscoveryTarget): Promise => - ipcRenderer.invoke('skills:discover', target) + ipcRenderer.invoke('skills:discover', target), + freshnessInventory: (): Promise => + ipcRenderer.invoke('skills:freshnessInventory') }, pet: { @@ -2273,7 +2286,7 @@ const api = { callback: (event: { browserPageId: string origin: string - action: 'opened-external' | 'blocked' + action: 'opened-in-orca' | 'opened-external' | 'blocked' }) => void ): (() => void) => { const listener = ( @@ -2281,7 +2294,7 @@ const api = { data: { browserPageId: string origin: string - action: 'opened-external' | 'blocked' + action: 'opened-in-orca' | 'opened-external' | 'blocked' } ) => callback(data) ipcRenderer.on('browser:popup', listener) @@ -2702,6 +2715,7 @@ const api = { get: (hostId) => ipcRenderer.invoke('session:get', hostId), set: (args, hostId) => ipcRenderer.invoke('session:set', args, hostId), patch: (args, hostId) => ipcRenderer.invoke('session:patch', args, hostId), + flush: () => ipcRenderer.invoke('session:flush'), readTerminalScrollback: (args) => ipcRenderer.sendSync('session:read-terminal-scrollback-sync', args), /** Synchronous session save for beforeunload — blocks until flushed to disk. */ @@ -2962,7 +2976,11 @@ const api = { connectionId?: string includeIgnored?: boolean bypassEffectiveUpstreamNegativeCache?: boolean + reuseLineStats?: boolean + requestToken?: string }): Promise => ipcRenderer.invoke('git:status', args), + cancelStatus: (args: { requestToken: string }): Promise => + ipcRenderer.invoke('git:cancelStatus', args), submoduleStatus: (args: { worktreePath: string submodulePath: string @@ -3154,6 +3172,8 @@ const api = { ipcRenderer.on('ui:openSettings', listener) return () => ipcRenderer.removeListener('ui:openSettings', listener) }, + consumePendingOpenSettings: (): Promise => + ipcRenderer.invoke('ui:consumePendingOpenSettings'), onOpenSetupGuide: (callback: () => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent) => callback() ipcRenderer.on('ui:openSetupGuide', listener) @@ -3512,6 +3532,16 @@ const api = { ipcRenderer.on('terminal:requestTabCreate', listener) return () => ipcRenderer.removeListener('terminal:requestTabCreate', listener) }, + onRequestTerminalTabMount: ( + callback: (data: { worktreeId: string; tabId?: string; ptyId?: string }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { worktreeId: string; tabId?: string; ptyId?: string } + ) => callback(data) + ipcRenderer.on('terminal:requestTabMount', listener) + return () => ipcRenderer.removeListener('terminal:requestTabMount', listener) + }, replyTerminalCreate: (reply: { requestId: string tabId?: string @@ -3669,6 +3699,17 @@ const api = { ipcRenderer.on('ui:closeTerminal', listener) return () => ipcRenderer.removeListener('ui:closeTerminal', listener) }, + onTerminalTabCloseRequest: (callback) => { + const listener = ( + _event: Electron.IpcRendererEvent, + request: Parameters[0] + ) => callback(request) + ipcRenderer.on('ui:terminalTabCloseRequest', listener) + return () => ipcRenderer.removeListener('ui:terminalTabCloseRequest', listener) + }, + respondTerminalTabClose: (response) => { + ipcRenderer.send('ui:terminalTabCloseResponse', response) + }, onSleepWorktree: (callback: (data: { worktreeId: string }) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, data: { worktreeId: string }) => callback(data) @@ -3906,12 +3947,13 @@ const api = { agent: AgentType sessionId: string transcriptPath?: string + limit?: number }, - onAppended: (messages: NativeChatAppendedMessages) => void + onFrame: (frame: NativeChatSubscriptionFrame) => void ): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, payload: NativeChatAppendedPayload) => { if (payload.subscriptionId === args.subscriptionId) { - onAppended(payload.messages) + onFrame(payload.frame) } } ipcRenderer.on('nativeChat:appended', listener) diff --git a/src/relay/fs-handler-git-fallback.ts b/src/relay/fs-handler-git-fallback.ts index ae418f9e881..8de2ad3394c 100644 --- a/src/relay/fs-handler-git-fallback.ts +++ b/src/relay/fs-handler-git-fallback.ts @@ -9,8 +9,15 @@ import { spawn } from 'node:child_process' import { fileListingCancellationError } from '../shared/file-listing-cancellation' import type { SearchOptions, SearchResult } from './fs-handler-utils' -import { buildGitLsFilesArgsForQuickOpen } from '../shared/quick-open-filter' -import { expandQuickOpenGitFileListing } from '../shared/quick-open-readdir-walk' +import { + buildGitLsFilesArgsForQuickOpen, + shouldExcludeQuickOpenRelPath, + shouldIncludeQuickOpenPath +} from '../shared/quick-open-filter' +import { + expandQuickOpenGitFileListing, + parseQuickOpenGitLsFilesEntry +} from '../shared/quick-open-readdir-walk' import { buildGitGrepArgs, buildSubmatchRegex, @@ -33,19 +40,21 @@ import { buildRelayGitEnv } from './relay-command-env' export function listFilesWithGit( rootPath: string, excludePathPrefixes: readonly string[] = [], - options: { signal?: AbortSignal } = {} + options: { signal?: AbortSignal; maxResults?: number } = {} ): Promise { - const { signal } = options + const { signal, maxResults } = options if (signal?.aborted) { return Promise.reject(fileListingCancellationError(signal)) } const gitPaths = new Set() const directoryPaths = new Set() + const directFileCandidates = new Set() const { primary, ignoredPass } = buildGitLsFilesArgsForQuickOpen(excludePathPrefixes) const children: { child: ReturnType isDone: () => boolean reject: (error: Error) => void + resolve: () => void }[] = [] const runGitLsFiles = (args: string[]): Promise => { @@ -53,15 +62,32 @@ export function listFilesWithGit( let buf = '' let done = false - const processPath = (path: string): void => { + const processPath = (path: string): boolean => { if (!path) { - return + return false } if (path.endsWith('/')) { directoryPaths.add(path) } else { gitPaths.add(path) + if (maxResults !== undefined) { + // Why: this duplicate classification exists only to stop bounded + // scans; unbounded SSH scans must not retain another full listing. + const parsed = parseQuickOpenGitLsFilesEntry(path) + const relPath = parsed.path.replace(/\/+$/, '') + if ( + !parsed.isGitlink && + !parsed.isUntrackedDir && + shouldIncludeQuickOpenPath(relPath) && + !shouldExcludeQuickOpenRelPath(relPath, excludePathPrefixes) + ) { + directFileCandidates.add(relPath) + } + } } + // Why: placeholders need IO classification and can disappear; only + // guaranteed final files are allowed to stop the remote Git processes. + return maxResults !== undefined && directFileCandidates.size >= maxResults } const child = spawn('git', ['ls-files', ...args], { @@ -100,7 +126,8 @@ export function listFilesWithGit( children.push({ child, isDone: () => done, - reject: rejectPass + reject: rejectPass, + resolve: resolvePass }) function handleStdoutData(chunk: string): void { @@ -108,7 +135,11 @@ export function listFilesWithGit( let start = 0 let idx = buf.indexOf('\0', start) while (idx !== -1) { - processPath(buf.substring(start, idx)) + if (processPath(buf.substring(start, idx))) { + buf = '' + finishAtLimit() + return + } start = idx + 1 idx = buf.indexOf('\0', start) } @@ -131,8 +162,10 @@ export function listFilesWithGit( rejectPass(new Error(`git ls-files killed by ${signal}`)) return } - if (buf) { - processPath(buf) + if (buf && processPath(buf)) { + buf = '' + finishAtLimit() + return } if (code === 0) { resolvePass() @@ -170,19 +203,27 @@ export function listFilesWithGit( } } + function finishAtLimit(): void { + for (const entry of children) { + if (entry.isDone()) { + continue + } + entry.resolve() + if (entry.child.exitCode === null && entry.child.signalCode === null) { + entry.child.kill() + } + } + } + // Why: a cancelled scan (workspace switch, superseded request) must stop // its git children right away instead of streaming a huge tree the caller // has already abandoned over the shared SSH channel. const onAbort = (): void => killSurvivors('git ls-files cancelled') signal?.addEventListener('abort', onAbort, { once: true }) - return Promise.all([ - runGitLsFiles(primary), + const runIgnoredPass = () => // Why: ignored files are supplementary — a failed or timed-out ignored - // pass must not discard the primary listing the user actually needs - // (#7719 root cause: the all-or-nothing failure showed zero files). - // Entries streamed before the failure are kept; a cancelled scan still - // rejects via the primary pass or the expansion's cancellation check. + // pass must not discard the primary listing the user actually needs. runGitLsFiles(ignoredPass).catch((err: Error) => { if (!signal?.aborted) { console.warn( @@ -191,18 +232,26 @@ export function listFilesWithGit( ) } }) - ]) + const passes = + maxResults === undefined + ? Promise.all([runGitLsFiles(primary), runIgnoredPass()]) + : runGitLsFiles(primary).then(() => + directFileCandidates.size < maxResults ? runIgnoredPass() : Promise.resolve() + ) + + return passes .then(async () => { const files = await expandQuickOpenGitFileListing({ rootPath, gitPaths, directoryPaths, excludePathPrefixes, - signal + signal, + maxResults }) // Why: directory placeholders are expanded after Git exits; restore // Git's path order for empty queries and fuzzy-score ties over SSH. - return files.sort() + return files.sort().slice(0, maxResults) }) .catch((err) => { killSurvivors('git ls-files canceled after sibling failure') diff --git a/src/relay/fs-handler-list-files-ignored.test.ts b/src/relay/fs-handler-list-files-ignored.test.ts index d76fe40b517..1ca5c2f5327 100644 --- a/src/relay/fs-handler-list-files-ignored.test.ts +++ b/src/relay/fs-handler-list-files-ignored.test.ts @@ -97,6 +97,24 @@ describe('relay quick open ignored file listing', () => { expect(ignoredArgs).toContain('!packages/other/**') }) + it('stops after the primary relay rg pass fills the result budget', async () => { + const primaryProc = createMockProcess() + const ignoredProc = createMockProcess() + let callIndex = 0 + spawnMock.mockImplementation(() => (++callIndex === 1 ? primaryProc : ignoredProc)) + + const promise = listFilesWithRg('/remote/root', [], { maxResults: 2 }) + ;(primaryProc.stdout as unknown as EventEmitter).emit( + 'data', + 'src/one.ts\nsrc/two.ts\nsrc/three.ts\n' + ) + + await expect(promise).resolves.toEqual(['src/one.ts', 'src/two.ts']) + expect(primaryProc.kill).toHaveBeenCalled() + expect(ignoredProc.kill).not.toHaveBeenCalled() + expect(callIndex).toBe(1) + }) + it('git fallback ignored pass includes ignored non-env files', async () => { const root = await makeTempRoot() await writeRel(root, 'dist/generated.js') @@ -146,6 +164,35 @@ describe('relay quick open ignored file listing', () => { expect(ignoredArgs).toContain(':(exclude,glob)packages/other/**') }) + it('stops after primary relay Git files fill the result budget', async () => { + const primaryProc = createMockProcess() + const ignoredProc = createMockProcess() + let callIndex = 0 + spawnMock.mockImplementation(() => (++callIndex === 1 ? primaryProc : ignoredProc)) + + const promise = listFilesWithGit('/remote/root', [], { maxResults: 2 }) + ;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/one.ts\0src/two.ts') + primaryProc.emit('close', 0, null) + await expect(promise).resolves.toEqual(['src/one.ts', 'src/two.ts']) + expect(primaryProc.kill).toHaveBeenCalled() + expect(ignoredProc.kill).not.toHaveBeenCalled() + expect(callIndex).toBe(1) + }) + + it('does not let a discarded relay Git placeholder consume the result budget', async () => { + const primaryProc = createMockProcess() + spawnMock.mockReturnValue(primaryProc) + + const promise = listFilesWithGit('/remote/root', [], { maxResults: 1 }) + ;(primaryProc.stdout as unknown as EventEmitter).emit( + 'data', + `discarded/\0${staged('100644', 'src/kept.ts')}\0` + ) + + await expect(promise).resolves.toEqual(['src/kept.ts']) + expect(primaryProc.kill).toHaveBeenCalled() + }) + it('git fallback fills nested git repos returned as root-relative placeholders', async () => { const root = await makeTempRoot() await writeRel(root, 'README.md') diff --git a/src/relay/fs-handler-list-files.ts b/src/relay/fs-handler-list-files.ts index bbe7912ee61..c63a352786e 100644 --- a/src/relay/fs-handler-list-files.ts +++ b/src/relay/fs-handler-list-files.ts @@ -29,9 +29,9 @@ export const LIST_FILES_TIMEOUT_MS = 25_000 export function listFilesWithRg( rootPath: string, excludePathPrefixes: readonly string[] = [], - options: { signal?: AbortSignal } = {} + options: { signal?: AbortSignal; maxResults?: number } = {} ): Promise { - const { signal } = options + const { signal, maxResults } = options if (signal?.aborted) { return Promise.reject(fileListingCancellationError(signal)) } @@ -67,6 +67,9 @@ export function listFilesWithRg( return true } files.add(relPath) + if (maxResults !== undefined && files.size >= maxResults) { + finishAtLimit() + } return true } @@ -134,6 +137,9 @@ export function listFilesWithRg( if (processLine(passBuf.substring(start, idx))) { passFileCount++ } + if (done) { + return + } start = idx + 1 idx = passBuf.indexOf('\n', start) } @@ -199,6 +205,16 @@ export function listFilesWithRg( } } + function finishAtLimit(): void { + if (done) { + return + } + done = true + signal?.removeEventListener('abort', onAbort) + killSurvivors('rg list reached bounded result limit') + resolve(Array.from(files).slice(0, maxResults)) + } + // Why: a cancelled scan (workspace switch, superseded request) must stop // its rg children immediately instead of letting them walk the tree to // completion and flood the relay with stdout it will only discard. @@ -212,7 +228,16 @@ export function listFilesWithRg( } signal?.addEventListener('abort', onAbort, { once: true }) - Promise.all([runPass(primary), runPass(ignoredPass)]) + const passes = + maxResults === undefined + ? Promise.all([runPass(primary), runPass(ignoredPass)]) + : // Why: deterministic primary-first budgeting prevents a large ignored + // tree from starving ordinary source paths on a remote host. + runPass(primary).then(() => + files.size < maxResults ? runPass(ignoredPass) : Promise.resolve() + ) + + passes .then(() => { if (done) { return diff --git a/src/relay/fs-handler-readdir-fallback.test.ts b/src/relay/fs-handler-readdir-fallback.test.ts new file mode 100644 index 00000000000..d3e5591ab32 --- /dev/null +++ b/src/relay/fs-handler-readdir-fallback.test.ts @@ -0,0 +1,26 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { listFilesWithReaddir } from './fs-handler-readdir-fallback' + +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('relay readdir file-list fallback', () => { + it('returns a bounded prefix instead of rejecting at the requested result limit', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-relay-readdir-')) + roots.push(root) + await mkdir(join(root, 'src')) + await writeFile(join(root, 'src', 'a.ts'), 'a') + await writeFile(join(root, 'src', 'b.ts'), 'b') + + const files = await listFilesWithReaddir(root, [], { maxResults: 1 }) + + expect(files).toHaveLength(1) + expect(['src/a.ts', 'src/b.ts']).toContain(files[0]) + }) +}) diff --git a/src/relay/fs-handler-readdir-fallback.ts b/src/relay/fs-handler-readdir-fallback.ts index d7c9172183b..25006788f1b 100644 --- a/src/relay/fs-handler-readdir-fallback.ts +++ b/src/relay/fs-handler-readdir-fallback.ts @@ -24,11 +24,12 @@ import { export async function listFilesWithReaddir( rootPath: string, excludePathPrefixes: readonly string[] = [], - options: { signal?: AbortSignal } = {} + options: { signal?: AbortSignal; maxResults?: number } = {} ): Promise { return listQuickOpenFilesWithReaddir(rootPath, { excludePathPrefixes, budget: createQuickOpenReaddirBudget(), + maxResults: options.maxResults, signal: options.signal }) } diff --git a/src/relay/fs-handler.ts b/src/relay/fs-handler.ts index baea79a88c8..9c146af1450 100644 --- a/src/relay/fs-handler.ts +++ b/src/relay/fs-handler.ts @@ -356,6 +356,12 @@ export class FsHandler { private listFiles(params: Record, context?: RequestContext): Promise { const rootPath = expandTilde(params.rootPath as string) + const maxResults = + typeof params.maxResults === 'number' && + Number.isInteger(params.maxResults) && + params.maxResults > 0 + ? Math.min(params.maxResults, 20_001) + : undefined // Why: the main-to-relay RPC adds excludePaths so nested linked worktrees // don't get double-scanned. The shared helper validates the shape and // normalizes into root-relative prefixes; malformed input yields [] so @@ -366,21 +372,22 @@ export class FsHandler { // aborting a stale scan when the workspace changes or the host cancels. return this.listFilesScans.run({ clientId: context?.clientId ?? 0, - key: JSON.stringify([rootPath, excludePathPrefixes]), + key: JSON.stringify([rootPath, excludePathPrefixes, maxResults]), signal: context?.signal, - start: (signal) => this.runListFilesScan(rootPath, excludePathPrefixes, signal) + start: (signal) => this.runListFilesScan(rootPath, excludePathPrefixes, signal, maxResults) }) } private async runListFilesScan( rootPath: string, excludePathPrefixes: string[], - signal: AbortSignal + signal: AbortSignal, + maxResults?: number ): Promise { const rgAvailable = await checkRgAvailable() throwIfFileListingCancelled(signal) if (rgAvailable) { - return listFilesWithRg(rootPath, excludePathPrefixes, { signal }) + return listFilesWithRg(rootPath, excludePathPrefixes, { signal, maxResults }) } // Why: git ls-files only works inside git repos. Use rev-parse to detect // git ancestry — unlike checking for a local .git entry, this works from @@ -401,7 +408,7 @@ export class FsHandler { // budget errors into install-rg guidance; genuine git failures keep // their own messages. try { - return await listFilesWithGit(rootPath, excludePathPrefixes, { signal }) + return await listFilesWithGit(rootPath, excludePathPrefixes, { signal, maxResults }) } catch (err) { if (isQuickOpenReaddirBudgetError(err)) { throw new Error(await buildInstallRgMessage(err)) @@ -415,7 +422,7 @@ export class FsHandler { // problem, so translate the opaque cap error into actionable guidance // the user can act on directly from the error toast. try { - return await listFilesWithReaddir(rootPath, excludePathPrefixes, { signal }) + return await listFilesWithReaddir(rootPath, excludePathPrefixes, { signal, maxResults }) } catch (err) { // Why: a cancelled scan is not an rg-availability problem; wrapping it // in install-rg guidance would surface bogus advice on the client. diff --git a/src/relay/git-handler-ops.ts b/src/relay/git-handler-ops.ts index 0bb862fbcea..395e31badb3 100644 --- a/src/relay/git-handler-ops.ts +++ b/src/relay/git-handler-ops.ts @@ -16,7 +16,13 @@ import { readWorkingDiffFile } from './git-working-file-read' export type GitExec = ( args: string[], cwd: string, - opts?: { maxBuffer?: number; disableOptionalLocks?: boolean; stdin?: string; timeout?: number } + opts?: { + maxBuffer?: number + disableOptionalLocks?: boolean + signal?: AbortSignal + stdin?: string + timeout?: number + } ) => Promise<{ stdout: string; stderr: string }> export type GitBufferExec = (args: string[], cwd: string) => Promise diff --git a/src/relay/git-handler-status-ops.test.ts b/src/relay/git-handler-status-ops.test.ts index 044fa82bf6a..4a7494d0956 100644 --- a/src/relay/git-handler-status-ops.test.ts +++ b/src/relay/git-handler-status-ops.test.ts @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { GitExec } from './git-handler-ops' import { getStatusOp } from './git-handler-status-ops' import { clearNoEffectiveUpstreamStatusCache } from './git-status-upstream-negative-cache' +import { clearGitStatusLineStatsCache } from '../shared/git-status-line-stats-cache' const LARGE_STATUS_ENTRY_COUNT = 150_000 @@ -26,6 +27,7 @@ describe('getStatusOp', () => { beforeEach(() => { clearNoEffectiveUpstreamStatusCache() + clearGitStatusLineStatsCache() tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-git-status-')) }) @@ -79,6 +81,51 @@ describe('getStatusOp', () => { expect(result.entries).toHaveLength(5) }) + it('reuses unchanged line stats only for hinted safety reads', async () => { + const statusOutput = `${buildBranchStatusOutput('head-1', '(detached)')}\n1 .M N... 100644 100644 100644 aaaa aaaa src/a.ts` + const git = vi.fn(async (args) => { + if (args.includes('status')) { + return { stdout: statusOutput, stderr: '' } + } + if (args.includes('diff')) { + return { stdout: '3\t2\tsrc/a.ts\n', stderr: '' } + } + throw new Error(`Unexpected git command: ${args.join(' ')}`) + }) + + await getStatusOp(git, { worktreePath: tmpDir }) + const reused = await getStatusOp(git, { worktreePath: tmpDir, reuseLineStats: true }) + await getStatusOp(git, { worktreePath: tmpDir }) + + expect(reused.entries).toContainEqual( + expect.objectContaining({ path: 'src/a.ts', added: 3, removed: 2 }) + ) + expect(git.mock.calls.filter(([args]) => args.includes('diff'))).toHaveLength(2) + }) + + it('forwards the request abort signal to status and numstat subprocesses', async () => { + const controller = new AbortController() + const git = vi.fn(async (args) => { + if (args.includes('status')) { + return { + stdout: '1 .M N... 100644 100644 100644 aaaa aaaa src/a.ts', + stderr: '' + } + } + if (args.includes('diff')) { + return { stdout: '1\t0\tsrc/a.ts\n', stderr: '' } + } + throw new Error(`Unexpected git command: ${args.join(' ')}`) + }) + + await getStatusOp(git, { worktreePath: tmpDir }, { signal: controller.signal }) + + expect(git.mock.calls).not.toHaveLength(0) + for (const [, , options] of git.mock.calls) { + expect(options?.signal).toBe(controller.signal) + } + }) + it('caches no-effective-upstream probes across status polls for the same head', async () => { const git = vi.fn(async (args) => { if (args.includes('status')) { diff --git a/src/relay/git-handler-status-ops.ts b/src/relay/git-handler-status-ops.ts index 4f48ec9b6da..81d0a4eb83b 100644 --- a/src/relay/git-handler-status-ops.ts +++ b/src/relay/git-handler-status-ops.ts @@ -20,6 +20,11 @@ import { type GitLineStats } from '../shared/git-uncommitted-line-stats' import { DEFAULT_GIT_STATUS_LIMIT } from '../shared/git-status-limit' +import { + beginGitStatusLineStatsCacheWrite, + clearGitStatusLineStatsCacheKey, + reuseOrRecomputeGitStatusLineStats +} from '../shared/git-status-line-stats-cache' export async function resolveGitDir(worktreePath: string): Promise { const dotGitPath = path.join(worktreePath, '.git') @@ -58,7 +63,8 @@ export async function detectConflictOperation(worktreePath: string): Promise + params: Record, + options: { signal?: AbortSignal } = {} ): Promise<{ entries: Record[] conflictOperation: string @@ -70,6 +76,8 @@ export async function getStatusOp( statusLength?: number }> { const worktreePath = params.worktreePath as string + const lineStatsCacheKey = `relay\0${worktreePath}` + const lineStatsWriteToken = beginGitStatusLineStatsCacheWrite(lineStatsCacheKey) const includeIgnored = params.includeIgnored === true // Why: reject non-finite/negative limits so the cap guard stays reliable // (NaN would silently disable capping; negatives would over-truncate). @@ -106,7 +114,8 @@ export async function getStatusOp( const { stdout } = await git(statusArgs, worktreePath, { // Why: status polling is read-like; avoid refreshing the index and racing // terminal Git commands on `.git/worktrees/*/index.lock`. - disableOptionalLocks: true + disableOptionalLocks: true, + signal: options.signal }) const parsed = parseStatusOutput(stdout) head = parsed.head @@ -134,6 +143,9 @@ export async function getStatusOp( const branchName = getShortBranchName(branch) if (branchName) { try { + // Why: this probe coalesces across concurrent status reads, so one + // request's abort must not reject the shared in-flight promise for + // the others; the probe is small and its cached result stays useful. upstreamStatus = await readOrProbeNoEffectiveUpstreamStatus( { worktreePath, branchName, upstreamName: upstreamStatus?.upstreamName }, (args) => git(args, worktreePath), @@ -155,7 +167,12 @@ export async function getStatusOp( } } } - } catch { + } catch (error) { + // Why: an aborted scan must reject, not resolve — swallowing here would let + // a cancelled request be mistaken for a completed (empty) status result. + if (options.signal?.aborted) { + throw error + } // not a git repo or git not available } @@ -165,7 +182,25 @@ export async function getStatusOp( // running numstat over a huge change set would reintroduce the cost the limit // exists to avoid. if (!didHitLimit) { - await attachLineStats(git, worktreePath, entries) + await reuseOrRecomputeGitStatusLineStats({ + cacheKey: lineStatsCacheKey, + head, + entries, + writeToken: lineStatsWriteToken, + reuse: params.reuseLineStats === true, + isAborted: () => options.signal?.aborted === true, + recompute: () => attachLineStats(git, worktreePath, entries, options.signal) + }) + } else { + clearGitStatusLineStatsCacheKey(lineStatsCacheKey, lineStatsWriteToken) + } + + // Why: abort after the porcelain read (e.g. during unmerged/upstream/line-stats + // work) must still reject — never resolve a cancelled scan as completed. + if (options.signal?.aborted) { + const error = new Error('The operation was aborted.') + error.name = 'AbortError' + throw error } return { @@ -182,29 +217,39 @@ export async function getStatusOp( async function runNumstat( git: GitExec, worktreePath: string, - cached: boolean -): Promise> { + cached: boolean, + signal?: AbortSignal +): Promise | null> { try { const { stdout } = await git( ['-c', 'core.quotePath=false', 'diff', ...(cached ? ['--cached'] : []), '--numstat', '-M'], worktreePath, - { disableOptionalLocks: true } + { disableOptionalLocks: true, signal } ) return parseNumstat(stdout) - } catch { + } catch (error) { + // Why: an aborted pass must reject so a cancelled scan is never treated as + // a completed one; only a genuine (non-abort) numstat failure degrades to + // uncounted rows below. + if (signal?.aborted) { + throw error + } // Why: a numstat failure should leave rows without counts rather than break - // the whole status refresh. - return new Map() + // the whole status refresh. Null (vs an empty map) tells the caller the + // pass is incomplete and must not be cached. + return null } } +/** Returns false when a numstat pass failed, so callers skip caching it. */ async function attachLineStats( git: GitExec, worktreePath: string, - entries: Record[] -): Promise { + entries: Record[], + signal?: AbortSignal +): Promise { if (entries.length === 0) { - return + return true } const hasStaged = entries.some((entry) => entry.area === 'staged') const hasUnstaged = entries.some((entry) => entry.area === 'unstaged') @@ -213,21 +258,22 @@ async function attachLineStats( .map((entry) => entry.path as string) const emptyStats = new Map() const [stagedStats, unstagedStats, untrackedStats] = await Promise.all([ - hasStaged ? runNumstat(git, worktreePath, true) : Promise.resolve(emptyStats), - hasUnstaged ? runNumstat(git, worktreePath, false) : Promise.resolve(emptyStats), - collectUntrackedAdditions(worktreePath, untrackedPaths) + hasStaged ? runNumstat(git, worktreePath, true, signal) : Promise.resolve(emptyStats), + hasUnstaged ? runNumstat(git, worktreePath, false, signal) : Promise.resolve(emptyStats), + collectUntrackedAdditions(worktreePath, untrackedPaths, signal) ]) for (const entry of entries) { const filePath = entry.path as string applyLineStats( entry as { added?: number; removed?: number }, entry.area === 'staged' - ? stagedStats.get(filePath) + ? (stagedStats ?? emptyStats).get(filePath) : entry.area === 'unstaged' - ? unstagedStats.get(filePath) + ? (unstagedStats ?? emptyStats).get(filePath) : untrackedStats.get(filePath) ) } + return stagedStats !== null && unstagedStats !== null } function getShortBranchName(branch: string | undefined): string | null { diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index 6872c5f5bbd..66b5eb2198c 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -78,6 +78,7 @@ import { import { GitResponseStreamRegistry } from './git-response-stream' import { GIT_RESPONSE_STREAM_THRESHOLD } from './protocol' import { endSubprocessStdin } from '../shared/subprocess-stdin-write' +import { clearGitStatusLineStatsCache } from '../shared/git-status-line-stats-cache' const execFileAsync = promisify(execFile) const MAX_GIT_BUFFER = 10 * 1024 * 1024 @@ -198,7 +199,7 @@ export class GitHandler { } private registerHandlers(): void { - this.dispatcher.onRequest('git.status', (p) => this.getStatus(p)) + this.dispatcher.onRequest('git.status', (p, context) => this.getStatus(p, context)) this.dispatcher.onRequest('git.submoduleStatus', (p) => this.getSubmoduleStatus(p)) this.dispatcher.onRequest('git.checkIgnored', (p) => this.checkIgnored(p)) this.dispatcher.onRequest('git.history', (p) => this.history(p)) @@ -287,6 +288,7 @@ export class GitHandler { private clearGitMutationReadCaches(): void { this.gitDiffReadDedupe.clear() + clearGitStatusLineStatsCache() clearSubmodulePathsCache(this.submodulePathsCache) } @@ -342,9 +344,9 @@ export class GitHandler { return stdout } - private async getStatus(params: Record) { + private async getStatus(params: Record, context: RequestContext) { this.gitDiffReadDedupe.clear() - return getStatusOp(this.git.bind(this), params) + return getStatusOp(this.git.bind(this), params, { signal: context.signal }) } // Why: the parent status only lists a single gitlink row per submodule. The diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 014e3950e42..ab19d2e244a 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -59,6 +59,8 @@ import RightSidebar from './components/right-sidebar' import { StarNagCard } from './components/StarNagCard' import { StarNagAgentValueMomentObserver } from './components/star-nag/StarNagAgentValueMomentObserver' import { StarNagToastHost } from './components/star-nag/StarNagToastHost' +import { SkillFreshnessNudge } from './components/skills/SkillFreshnessNudge' +import { SkillFreshnessUpdateDialog } from './components/skills/SkillFreshnessUpdateDialog' import { TelemetryFirstLaunchSurface } from './components/TelemetryFirstLaunchSurface' import { ZoomOverlay } from './components/ZoomOverlay' import { onOnboardingReopened } from './components/onboarding/show-onboarding-event' @@ -164,7 +166,11 @@ import { type KeybindingContext, type PhysicalModifierToken } from '../../shared/keybindings' -import { toRuntimeExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' +import { + isRuntimeOwnedSshTargetId, + toRuntimeExecutionHostId, + type ExecutionHostId +} from '../../shared/execution-host' import { ModifierDoubleTapDetector, toModifierDoubleTapEvent @@ -1001,7 +1007,13 @@ function App(): React.JSX.Element { // tabs through pty.attach on the relay. Passphrase-protected targets // are deferred to tab focus to avoid stacking credential dialogs at // startup before the user has context. - const connectionIds = sessionRead.session.activeConnectionIdsAtShutdown ?? [] + // Why: runtime-owned (ephemeral-VM) targets must never be dialed from + // the renderer — ssh.connect would dispose the runtime layer's live + // relay session. Main's windowless-promotion path can persist such + // ids into this list, so filter at the consumption boundary too. + const connectionIds = (sessionRead.session.activeConnectionIdsAtShutdown ?? []).filter( + (targetId) => !isRuntimeOwnedSshTargetId(targetId) + ) if (connectionIds.length > 0) { try { const SSH_RECONNECT_TIMEOUT_MS = 15_000 @@ -2818,10 +2830,20 @@ function App(): React.JSX.Element { > + {/* Why: the dialog hosts a live terminal pane, which requires the + link-routing preference context; mounting outside crashes it. */} + + + + {/* Why: rendered last so it sits after all -webkit-app-region:drag elements in DOM order. Electron's hit-test for drag regions is DOM-order-based and diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 99fc42d7111..af47d26736d 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -27,7 +27,6 @@ import { GitPullRequestDraft, List, LoaderCircle, - Lock, Minus, Plus, RefreshCw, @@ -90,6 +89,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' import TaskProjectSourceCombobox from '@/components/task-project-source-combobox' +import { JiraConnectDialog } from '@/components/jira-connect-dialog' import { LinearApiKeyDialog } from '@/components/linear-api-key-dialog' import { LinearScopeSelector } from '@/components/linear-scope-selector' import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' @@ -205,7 +205,11 @@ import { type TaskPageRepoSourceState } from '@/components/task-page-cache-selectors' import { shouldHideTaskPageListChrome } from '@/components/task-page-list-chrome-visibility' -import { accumulateWorkItemPages } from '@/components/task-page-work-item-pagination' +import { + getTaskPagePerRepoLimit, + taskPageToGitHubApiPage +} from '@/components/task-page-work-item-pagination' +import { sortWorkItemsByNumber } from '../../../shared/work-items' import LinearIssueAttributeFilterDropdowns from '@/components/linear-issue-attribute-filter-dropdowns' import { resolveLinearIssueAttributeFilterPrimaryTeam } from '@/components/linear-issue-attribute-filter-primary-team' import { @@ -3114,7 +3118,6 @@ export default function TaskPage(): React.JSX.Element { const jiraStatus = useAppStore((s) => s.jiraStatus) const jiraStatusChecked = useAppStore((s) => s.jiraStatusChecked) const jiraStatusContextKey = useAppStore((s) => s.jiraStatusContextKey) - const connectJira = useAppStore((s) => s.connectJira) const selectJiraSite = useAppStore((s) => s.selectJiraSite) const searchJiraIssues = useAppStore((s) => s.searchJiraIssues) const listJiraIssues = useAppStore((s) => s.listJiraIssues) @@ -3801,15 +3804,23 @@ export default function TaskPage(): React.JSX.Element { // once, but the result is reconciled into existing rows to avoid a full // table shuffle when only status/key fields changed. const landingGitHubRefreshKeysRef = useRef>(new Set()) - // Why: pages holds all fetched pages of work items. Page 0 is seeded from - // cache for instant first paint; subsequent pages are loaded via date cursors. - const [pages, setPages] = useState(() => { + // Why: divide the display budget between repos so one provider page maps to + // one UI page without truncating rows that later provider pages cannot return. + const githubPerRepoPageLimit = getTaskPagePerRepoLimit( + selectedRepos.length, + PER_REPO_FETCH_LIMIT, + CROSS_REPO_DISPLAY_LIMIT + ) + const githubPageSize = githubPerRepoPageLimit * Math.max(1, selectedRepos.length) + // Why: null entries are pages not fetched yet. Numbered provider pages let a + // high-page click load that page directly without rate-limiting intermediate reads. + const [pages, setPages] = useState<(GitHubWorkItem[] | null)[]>(() => { const trimmed = initialTaskQuery.trim() const merged: GitHubWorkItem[] = [] for (const r of selectedRepos) { const cached = getCachedWorkItems( r.id, - PER_REPO_FETCH_LIMIT, + githubPerRepoPageLimit, trimmed, r.path, getTaskPageRepoSourceContext(r, 'github') @@ -3821,15 +3832,13 @@ export default function TaskPage(): React.JSX.Element { if (merged.length === 0) { return [[]] } - const page0 = [...merged] - .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()) - .slice(0, CROSS_REPO_DISPLAY_LIMIT) + const page0 = sortWorkItemsByNumber(merged).slice(0, githubPageSize) return [page0] }) const [currentPage, setCurrentPage] = useState(0) const [paginationLoading, setPaginationLoading] = useState(false) const [loadingTargetPage, setLoadingTargetPage] = useState(null) - const [totalItemCount, setTotalItemCount] = useState(null) + const [countedTotalPages, setCountedTotalPages] = useState(null) const fetchWorkItemsNextPage = useAppStore((s) => s.fetchWorkItemsNextPage) const countWorkItemsAcrossRepos = useAppStore((s) => s.countWorkItemsAcrossRepos) @@ -3859,7 +3868,7 @@ export default function TaskPage(): React.JSX.Element { selectTaskPageWorkItemsCacheEntries( s.workItemsCache, selectedRepos.map(getTaskPageRepoCacheInput), - PER_REPO_FETCH_LIMIT, + githubPerRepoPageLimit, appliedWorkItemsCacheQuery ) ) @@ -3982,6 +3991,9 @@ export default function TaskPage(): React.JSX.Element { setPages((current) => { let changed = false const nextPages = current.map((page) => { + if (!page) { + return page + } let pageChanged = false const nextPage = page.map((item) => { if (item.id !== itemKey.id || item.repoId !== itemKey.repoId) { @@ -5836,6 +5848,7 @@ export default function TaskPage(): React.JSX.Element { }, [newLinearStates.data, newLinearIssueStateId]) const [linearConnectOpen, setLinearConnectOpen] = useState(false) + const [jiraConnectOpen, setJiraConnectOpen] = useState(false) useContextualTour( 'tasks', !dialogWorkItem && @@ -5845,6 +5858,7 @@ export default function TaskPage(): React.JSX.Element { !newLinearProjectOpen && !newLinearIssueOpen && !linearConnectOpen && + !jiraConnectOpen && activeModal === 'none', 'tasks_open' ) @@ -5884,12 +5898,6 @@ export default function TaskPage(): React.JSX.Element { const [newJiraIssueCustomFieldValues, setNewJiraIssueCustomFieldValues] = useState< Record >({}) - const [jiraConnectOpen, setJiraConnectOpen] = useState(false) - const [jiraSiteUrlDraft, setJiraSiteUrlDraft] = useState('') - const [jiraEmailDraft, setJiraEmailDraft] = useState('') - const [jiraApiTokenDraft, setJiraApiTokenDraft] = useState('') - const [jiraConnectState, setJiraConnectState] = useState<'idle' | 'connecting' | 'error'>('idle') - const [jiraConnectError, setJiraConnectError] = useState(null) const includeJiraSiteNameInProjectLabel = selectedJiraSiteId === 'all' const previousProviderRuntimeContextKeyRef = useRef(providerRuntimeContextKey) @@ -6156,6 +6164,9 @@ export default function TaskPage(): React.JSX.Element { const seen = new Set() const logins: string[] = [] for (const page of pages) { + if (!page) { + continue + } for (const item of page) { if ( !item.author || @@ -6229,45 +6240,31 @@ export default function TaskPage(): React.JSX.Element { } }, [ensurePRChecksLoaded, filteredWorkItems, githubMode, showPRManagementColumns, taskSource]) - // Why: each page is bounded by both the per-repo fetch budget (so a single - // repo can yield at most PER_REPO_FETCH_LIMIT rows per page) and the - // post-merge display cap. Dividing the total by CROSS_REPO_DISPLAY_LIMIT - // alone under-counts pages when the per-repo budget is the tighter bound — - // e.g. one repo with 60 open PRs yielded 36 rows on page 0 and ceil(60/100) - // = 1 total pages, hiding the pager entirely. - const effectivePageSize = Math.max( - 1, - Math.min(PER_REPO_FETCH_LIMIT * Math.max(1, selectedRepos.length), CROSS_REPO_DISPLAY_LIMIT) - ) - // Why: if the search-API count is unavailable (rate-limited, transient - // failure — `countWorkItemsAcrossRepos` swallows errors and returns 0), - // infer there's at least one more page whenever the last loaded page - // filled to the per-page capacity. Otherwise pagination would silently - // hide and trap the user on page 0 with no way to advance. - const lastPageFull = (pages.at(-1)?.length ?? 0) >= effectivePageSize + let lastLoadedPageIndex = 0 + for (let index = 0; index < pages.length; index += 1) { + if (pages[index] !== null) { + lastLoadedPageIndex = index + } + } + // Why: when counts fail, a full loaded page is enough evidence to expose one + // more page without pretending unfetched sparse entries are empty results. + const lastLoadedPageFull = + (pages[lastLoadedPageIndex]?.length ?? 0) >= Math.max(1, githubPageSize) + const fallbackTotalPages = lastLoadedPageFull + ? Math.max(pages.length, lastLoadedPageIndex + 2) + : Math.max(1, pages.length) const totalPages = - totalItemCount && totalItemCount > 0 - ? Math.max(pages.length, Math.ceil(totalItemCount / effectivePageSize)) - : lastPageFull - ? pages.length + 1 - : pages.length + countedTotalPages && countedTotalPages > 0 + ? Math.max(pages.length, countedTotalPages) + : fallbackTotalPages - // Why: loads the next page using the oldest item's updatedAt as a cursor. - // When targetPage is provided (from clicking a numbered page beyond loaded - // pages), it chains fetches until that page is loaded. + // Why: numbered provider pages support random access. Load only the clicked + // page so a high-page jump does not exhaust GitHub's Search API rate bucket. const handleLoadNextPage = useCallback( async (targetPage?: number) => { if (paginationLoading || selectedRepos.length === 0) { return } - const lastPage = pages.at(-1) - if (!lastPage || lastPage.length === 0) { - return - } - const oldestItem = lastPage.at(-1) - if (!oldestItem?.updatedAt) { - return - } const q = stripRepoQualifiers(appliedTaskSearch.trim()) const repoArgs = selectedRepos.map((r) => ({ repoId: r.id, @@ -6277,37 +6274,32 @@ export default function TaskPage(): React.JSX.Element { })) const requestGeneration = paginationGenerationRef.current - const target = targetPage ?? pages.length + const target = targetPage ?? currentPage + 1 setPaginationLoading(true) setLoadingTargetPage(target) try { - const result = await accumulateWorkItemPages({ - existingPages: pages, - initialCursor: oldestItem.updatedAt, - targetPage: target, - // Why: uniform page size keeps deduped pages from shrinking below the - // size `totalPages` (count ÷ effectivePageSize) assumes — otherwise - // the per-page overlap loss would strand the tail items. - pageSize: effectivePageSize, - isCancelled: () => paginationGenerationRef.current !== requestGeneration, - fetchPage: async (cursor) => { - const { items } = await fetchWorkItemsNextPage( - repoArgs, - PER_REPO_FETCH_LIMIT, - CROSS_REPO_DISPLAY_LIMIT, - q, - cursor - ) - return { items } - } - }) - if (result.cancelled) { + const { items } = await fetchWorkItemsNextPage( + repoArgs, + githubPerRepoPageLimit, + githubPageSize, + q, + taskPageToGitHubApiPage(target) + ) + if (paginationGenerationRef.current !== requestGeneration) { return } - if (result.newPages.length > 0) { - setPages((prev) => [...prev, ...result.newPages]) - setCurrentPage(target < result.loadedPages ? target : result.loadedPages - 1) + if (items.length === 0) { + return } + setPages((previous) => { + const next = [...previous] + while (next.length <= target) { + next.push(null) + } + next[target] = items + return next + }) + setCurrentPage(target) } catch (err) { console.error('Failed to load next page:', err) } finally { @@ -6320,10 +6312,11 @@ export default function TaskPage(): React.JSX.Element { [ paginationLoading, selectedRepos, - pages, + currentPage, appliedTaskSearch, fetchWorkItemsNextPage, - effectivePageSize + githubPageSize, + githubPerRepoPageLimit ] ) @@ -6399,7 +6392,7 @@ export default function TaskPage(): React.JSX.Element { for (const r of selectedRepos) { const cached = getCachedWorkItems( r.id, - PER_REPO_FETCH_LIMIT, + githubPerRepoPageLimit, q, r.path, getTaskPageRepoSourceContext(r, 'github') @@ -6415,14 +6408,10 @@ export default function TaskPage(): React.JSX.Element { // no repo has a cache entry for it), we clear the previous query's rows // rather than leaving them on screen under the spinner. const page0 = - preMerged.length > 0 - ? [...preMerged] - .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()) - .slice(0, CROSS_REPO_DISPLAY_LIMIT) - : [] + preMerged.length > 0 ? sortWorkItemsByNumber(preMerged).slice(0, githubPageSize) : [] setPages([page0]) setCurrentPage(0) - setTotalItemCount(null) + setCountedTotalPages(null) setTasksError(null) setFailedCount(0) // reset so a prior failure banner doesn't linger setTasksLoading(anyUncached) @@ -6464,7 +6453,7 @@ export default function TaskPage(): React.JSX.Element { // newer retry's source from the set. Clearing only the keys captured // when this effect dispatched preserves later additions. const dispatchedRetrySourceKeys = retryingSourceKeys - void fetchWorkItemsAcrossRepos(repoArgs, PER_REPO_FETCH_LIMIT, CROSS_REPO_DISPLAY_LIMIT, q, { + void fetchWorkItemsAcrossRepos(repoArgs, githubPerRepoPageLimit, githubPageSize, q, { ...deriveTaskPageGitHubWorkItemsFetchOptions(forcedFetch, shouldProbeOnLanding) }) .then(({ items, failedCount: failed }) => { @@ -6542,10 +6531,11 @@ export default function TaskPage(): React.JSX.Element { executionHostId: r.executionHostId, sourceContext: getTaskPageRepoSourceContext(r, 'github') })), - q - ).then((count) => { + q, + githubPerRepoPageLimit + ).then(({ totalPages: countedPages }) => { if (!cancelled) { - setTotalItemCount(count) + setCountedTotalPages(countedPages) } }) @@ -8119,33 +8109,6 @@ export default function TaskPage(): React.JSX.Element { [openComposerForJiraItem] ) - const handleJiraConnect = useCallback(async (): Promise => { - const siteUrl = jiraSiteUrlDraft.trim() - const email = jiraEmailDraft.trim() - const apiToken = jiraApiTokenDraft.trim() - if (!siteUrl || !email || !apiToken) { - return - } - setJiraConnectState('connecting') - setJiraConnectError(null) - try { - const result = await connectJira({ siteUrl, email, apiToken }) - if (result.ok) { - setJiraSiteUrlDraft('') - setJiraEmailDraft('') - setJiraApiTokenDraft('') - setJiraConnectState('idle') - setJiraConnectOpen(false) - } else { - setJiraConnectState('error') - setJiraConnectError(result.error) - } - } catch (error) { - setJiraConnectState('error') - setJiraConnectError(error instanceof Error ? error.message : 'Connection failed') - } - }, [connectJira, jiraApiTokenDraft, jiraEmailDraft, jiraSiteUrlDraft]) - const taskPageListChromeHidden = shouldHideTaskPageListChrome({ taskSource, hasGitHubDetail: Boolean(dialogWorkItem), @@ -9834,7 +9797,7 @@ export default function TaskPage(): React.JSX.Element { totalPages={totalPages} loadingTarget={loadingTargetPage} onPageChange={(page) => { - if (page < pages.length) { + if (pages[page] !== null && pages[page] !== undefined) { setCurrentPage(page) } else { void handleLoadNextPage(page) @@ -10090,16 +10053,7 @@ export default function TaskPage(): React.JSX.Element { )}

- - . -

-

- - {translate( - 'auto.components.TaskPage.2abe22ef76', - 'Your token is encrypted via the OS keychain and stored locally.' - )} -

-
- - - - - - + ) } diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index 9b6fcef5b29..e1bb431c76e 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -105,16 +105,14 @@ import { CREATE_WORKSPACE_QUICK_ACTION_ID } from '@/components/cmd-j/quick-actions' import { buildWorktreeChecksReviewIndex } from '@/components/cmd-j/worktree-checks-review-index' +import { resolvePaletteFocusRestoreTarget } from '@/components/cmd-j/palette-focus-restore-target' import { selectWorktreePaletteCacheInputs } from '@/components/cmd-j/worktree-palette-cache-inputs' import { getRepoHostIdentity } from '@/store/slices/repo-host-identity' import { getComposerEligibleRepos, resolveComposerGitRepoId } from '@/lib/new-workspace-composer-repo' -import { - lookupGitHubWorkItemByOwnerRepoForSource, - lookupGitHubWorkItemForSource -} from '@/lib/github-work-item-source-lookup' +import { lookupGitHubWorkItemForSource } from '@/lib/github-work-item-source-lookup' import type { SettingsNavTarget } from '@/lib/settings-navigation-types' import { getHostDisplayLabelOverrides } from '../../../shared/host-setting-overrides' import { isRuntimeOwnedSshTargetId } from '../../../shared/execution-host' @@ -433,6 +431,11 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { ) const previousBrowserPageIdRef = useRef(null) const previousBrowserFocusTargetRef = useRef<'webview' | 'address-bar'>('webview') + // Why: the exact element focused before Cmd+J opened (e.g. the terminal + // textarea the user was typing in) so Escape restores it precisely instead + // of the first matching surface in the DOM, which may be a background + // worktree's mounted-but-hidden terminal. + const previousFocusElementRef = useRef(null) const activeGroupSnapshotRef = useRef(null) const wasVisibleRef = useRef(false) const skipRestoreFocusRef = useRef(false) @@ -1177,6 +1180,13 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { document.activeElement.closest('[data-orca-browser-address-bar="true"]') ? 'address-bar' : 'webview' + // Why: same timing constraint — capture the pre-dialog focus target now + // so Escape can return focus to the exact input the user left (excluding + // document.body, which isn't a meaningful restore target). + previousFocusElementRef.current = + document.activeElement instanceof HTMLElement && document.activeElement !== document.body + ? document.activeElement + : null skipRestoreFocusRef.current = false setQuery('') setSelectedItemId('') @@ -1243,24 +1253,19 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { useEffect(() => cancelFallbackFocusFrames, [cancelFallbackFocusFrames]) - const focusFallbackSurface = useCallback(() => { - cancelFallbackFocusFrames() - fallbackFocusOuterFrameRef.current = requestAnimationFrame(() => { - fallbackFocusOuterFrameRef.current = null - fallbackFocusInnerFrameRef.current = requestAnimationFrame(() => { - fallbackFocusInnerFrameRef.current = null - const xterm = document.querySelector('.xterm-helper-textarea') as HTMLElement | null - if (xterm) { - xterm.focus() - return - } - const monaco = document.querySelector('.monaco-editor textarea') as HTMLElement | null - if (monaco) { - monaco.focus() - } + const focusFallbackSurface = useCallback( + (preferredTarget?: HTMLElement | null) => { + cancelFallbackFocusFrames() + fallbackFocusOuterFrameRef.current = requestAnimationFrame(() => { + fallbackFocusOuterFrameRef.current = null + fallbackFocusInnerFrameRef.current = requestAnimationFrame(() => { + fallbackFocusInnerFrameRef.current = null + resolvePaletteFocusRestoreTarget(preferredTarget ?? null)?.focus({ preventScroll: true }) + }) }) - }) - }, [cancelFallbackFocusFrames]) + }, + [cancelFallbackFocusFrames] + ) const requestBrowserFocus = useCallback( (detail: { pageId: string; target: 'webview' | 'address-bar' }) => { @@ -1294,7 +1299,10 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { return } if (previousWorktreeIdRef.current) { - focusFallbackSurface() + // Why: dismissing Cmd+J should return to whatever the user was doing — + // restore the exact previously-focused surface (e.g. the terminal they + // were typing in) rather than an arbitrary first match. + focusFallbackSurface(previousFocusElementRef.current) } }, [closeModal, focusFallbackSurface, requestBrowserFocus] @@ -1535,13 +1543,11 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { // Case 1: user pasted a GH issue/PR URL. if (ghLink) { - const { slug, number } = ghLink + const { number } = ghLink const state = useAppStore.getState() // Why: the existing-worktree check only needs the issue/PR number, which - // is repo-agnostic on the worktree meta side. We don't currently cache a - // repo-slug map, so slug-matching against a specific repo happens - // implicitly when we pick a repo for the `gh workItem` lookup below. + // is repo-agnostic on the worktree meta side. const matches = allWorktrees.filter( (w) => !w.isArchived && (w.linkedIssue === number || w.linkedPR === number) ) @@ -1553,73 +1559,21 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { return } - // Resolve via gh.workItem: prefer the active repo, else the first eligible. + // Why: hand the raw URL to the composer's name field so it runs the same + // cross-project detection as Cmd+N — surfacing the "Switch project?" + // dialog when the URL targets a different project. Pre-resolving here + // against an arbitrary repo silently linked cross-project items to the + // wrong project and skipped that prompt. Seed the active repo so the + // field compares the URL against the project the user is currently in. const eligibleRepos = state.repos.filter((r) => isGitRepoKind(r)) const repoForLookup = (state.activeRepoId && eligibleRepos.find((r) => r.id === state.activeRepoId)) || eligibleRepos[0] - if (!repoForLookup) { - openComposer({ prefilledName: trimmed }) - return - } - - prefetchCreateWorkspaceBaseForComposer(repoForLookup.id) - const sourceContext = buildTaskSourceContextFromRepo({ - provider: 'github', - projectId: repoForLookup.id, - repo: repoForLookup - }) - // Why: awaiting inside the user gesture would leave the palette open - // indefinitely on slow networks. Close immediately and populate the - // composer once the lookup returns. - const lookupToken = createLookupGuard.start() - preserveCreateLookupOnCloseRef.current = true - recordFeatureInteraction('cmd-j-create-workspace') - closeModal() - void lookupGitHubWorkItemByOwnerRepoForSource({ - repoPath: repoForLookup.path, - repoId: repoForLookup.id, - sourceContext, - owner: slug.owner, - repo: slug.repo, - number, - type: ghLink.type - }) - .then((item) => { - if (!createLookupGuard.isCurrent(lookupToken)) { - return - } - const data: Record = { initialRepoId: repoForLookup.id } - if (item) { - const linkedWorkItem: LinkedWorkItemSummary = { - type: item.type, - number: item.number, - title: item.title, - url: item.url - } - data.linkedWorkItem = linkedWorkItem - data.prefilledName = - getLinkedWorkItemWorkspaceName(linkedWorkItem)?.seedName ?? - getLinkedWorkItemSuggestedName({ title: item.title }) - } else { - // Fallback: we couldn't resolve the URL, just seed the name. - data.prefilledName = `${slug.owner}-${slug.repo}-${number}` - } - queueMicrotask(() => - openModal('new-workspace-composer', { ...data, telemetrySource: 'command_palette' }) - ) - }) - .catch(() => { - if (!createLookupGuard.isCurrent(lookupToken)) { - return - } - queueMicrotask(() => - openModal('new-workspace-composer', { - initialRepoId: repoForLookup.id, - telemetrySource: 'command_palette' - }) - ) - }) + openComposer( + repoForLookup + ? { prefilledName: trimmed, initialRepoId: repoForLookup.id } + : { prefilledName: trimmed } + ) return } diff --git a/src/renderer/src/components/browser-pane/markup/useMarkupPointerHandlers.ts b/src/renderer/src/components/browser-pane/markup/useMarkupPointerHandlers.ts index 4c6257155a1..6ca25b3cabf 100644 --- a/src/renderer/src/components/browser-pane/markup/useMarkupPointerHandlers.ts +++ b/src/renderer/src/components/browser-pane/markup/useMarkupPointerHandlers.ts @@ -25,8 +25,17 @@ export type MarkupPointerParams = { // Canvas pointer interactions: draw a new shape, or place text. Split out of // useMarkupEditor to keep that hook focused. export function useMarkupPointerHandlers(params: MarkupPointerParams) { - const { busy, tool, color, width, pendingText, canvasRef, setInProgress, setPendingText, setDoc } = - params + const { + busy, + tool, + color, + width, + pendingText, + canvasRef, + setInProgress, + setPendingText, + setDoc + } = params const pointFromEvent = useCallback( (event: { clientX: number; clientY: number }): MarkupPoint => { diff --git a/src/renderer/src/components/cmd-j/palette-focus-restore-target.test.ts b/src/renderer/src/components/cmd-j/palette-focus-restore-target.test.ts new file mode 100644 index 00000000000..76b4f28eb77 --- /dev/null +++ b/src/renderer/src/components/cmd-j/palette-focus-restore-target.test.ts @@ -0,0 +1,60 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it } from 'vitest' +import { resolvePaletteFocusRestoreTarget } from './palette-focus-restore-target' + +afterEach(() => { + document.body.innerHTML = '' +}) + +function addTerminal(id: string): HTMLTextAreaElement { + const textarea = document.createElement('textarea') + textarea.className = 'xterm-helper-textarea' + textarea.dataset.terminal = id + document.body.appendChild(textarea) + return textarea +} + +describe('resolvePaletteFocusRestoreTarget', () => { + it('returns the exact previously-focused element when it is still connected', () => { + // Two terminals mounted (e.g. a background worktree comes first in the DOM); + // the user was typing in the second one before opening Cmd+J. + addTerminal('background') + const active = addTerminal('active') + + expect(resolvePaletteFocusRestoreTarget(active)).toBe(active) + }) + + it('falls back to the first terminal when the previous element is gone', () => { + const first = addTerminal('first') + const detached = document.createElement('textarea') + detached.className = 'xterm-helper-textarea' + // Never appended → not connected (e.g. its pane unmounted while Cmd+J was open). + + expect(detached.isConnected).toBe(false) + expect(resolvePaletteFocusRestoreTarget(detached)).toBe(first) + }) + + it('falls back to the editor textarea when no preferred target and no terminal exist', () => { + const editor = document.createElement('div') + editor.className = 'monaco-editor' + const textarea = document.createElement('textarea') + editor.appendChild(textarea) + document.body.appendChild(editor) + + expect(resolvePaletteFocusRestoreTarget(null)).toBe(textarea) + }) + + it('prefers the terminal over the editor when both are present and nothing was captured', () => { + const terminal = addTerminal('only') + const editor = document.createElement('div') + editor.className = 'monaco-editor' + editor.appendChild(document.createElement('textarea')) + document.body.appendChild(editor) + + expect(resolvePaletteFocusRestoreTarget(null)).toBe(terminal) + }) + + it('returns null when nothing is focusable', () => { + expect(resolvePaletteFocusRestoreTarget(null)).toBeNull() + }) +}) diff --git a/src/renderer/src/components/cmd-j/palette-focus-restore-target.ts b/src/renderer/src/components/cmd-j/palette-focus-restore-target.ts new file mode 100644 index 00000000000..3d739d8b022 --- /dev/null +++ b/src/renderer/src/components/cmd-j/palette-focus-restore-target.ts @@ -0,0 +1,19 @@ +// Why: when Cmd+J closes it must hand focus back to whatever the user was +// doing. Prefer the exact element focused before the palette opened (e.g. the +// specific terminal textarea they were typing in); the querySelector fallbacks +// grab the first match in the DOM, which can be a background worktree's +// mounted-but-hidden terminal rather than the visible one. +export function resolvePaletteFocusRestoreTarget( + preferredTarget: HTMLElement | null, + doc: Document = document +): HTMLElement | null { + if (preferredTarget && preferredTarget.isConnected) { + return preferredTarget + } + const xterm = doc.querySelector('.xterm-helper-textarea') + if (xterm instanceof HTMLElement) { + return xterm + } + const monaco = doc.querySelector('.monaco-editor textarea') + return monaco instanceof HTMLElement ? monaco : null +} diff --git a/src/renderer/src/components/editor/rich-markdown-commands.test.ts b/src/renderer/src/components/editor/rich-markdown-commands.test.ts index d04b1de08f6..5caffa90e7a 100644 --- a/src/renderer/src/components/editor/rich-markdown-commands.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-commands.test.ts @@ -40,14 +40,14 @@ describe('rich markdown slash commands', () => { it('orders commands under section headers', () => { expect(slashCommands.map((command) => `${command.group}:${command.id}`)).toEqual([ 'Headings:heading-1', - 'Headings:toggle-h1', 'Headings:heading-2', - 'Headings:toggle-h2', 'Headings:heading-3', - 'Headings:toggle-h3', 'Headings:heading-4', - 'Headings:toggle-h4', 'Headings:heading-5', + 'Toggle headings:toggle-h1', + 'Toggle headings:toggle-h2', + 'Toggle headings:toggle-h3', + 'Toggle headings:toggle-h4', 'Basic blocks:blockquote', 'Basic blocks:ordered-list', 'Basic blocks:bullet-list', diff --git a/src/renderer/src/components/editor/rich-markdown-heading-slash-commands.tsx b/src/renderer/src/components/editor/rich-markdown-heading-slash-commands.tsx index b71b79f1e25..16d256c5f0f 100644 --- a/src/renderer/src/components/editor/rich-markdown-heading-slash-commands.tsx +++ b/src/renderer/src/components/editor/rich-markdown-heading-slash-commands.tsx @@ -25,27 +25,6 @@ export const headingSlashCommands: SlashCommand[] = [ editor.chain().focus().setHeading({ level: 1 }).run() } }, - { - id: 'toggle-h1', - get label() { - return translate( - 'auto.components.editor.rich.markdown.slash.commands.41482b15ce', - 'Toggle Heading 1' - ) - }, - aliases: ['toggle-h1', 'toggle heading', 'details heading', 'collapse heading'], - icon: icon(ChevronRight), - group: 'Headings', - get description() { - return translate( - 'auto.components.editor.rich.markdown.slash.commands.3294a2c0cc', - 'Create a collapsible section with a large heading summary.' - ) - }, - run: (editor) => { - insertToggle(editor, 'heading-1') - } - }, { id: 'heading-2', get label() { @@ -68,27 +47,6 @@ export const headingSlashCommands: SlashCommand[] = [ editor.chain().focus().setHeading({ level: 2 }).run() } }, - { - id: 'toggle-h2', - get label() { - return translate( - 'auto.components.editor.rich.markdown.slash.commands.7a2c1f9b04', - 'Toggle Heading 2' - ) - }, - aliases: ['toggle-h2'], - icon: icon(ChevronRight), - group: 'Headings', - get description() { - return translate( - 'auto.components.editor.rich.markdown.slash.commands.b3e5d8a1c6', - 'Create a collapsible section with a medium heading summary.' - ) - }, - run: (editor) => { - insertToggle(editor, 'heading-2') - } - }, { id: 'heading-3', get label() { @@ -111,27 +69,6 @@ export const headingSlashCommands: SlashCommand[] = [ editor.chain().focus().setHeading({ level: 3 }).run() } }, - { - id: 'toggle-h3', - get label() { - return translate( - 'auto.components.editor.rich.markdown.slash.commands.2f9d6b4e10', - 'Toggle Heading 3' - ) - }, - aliases: ['toggle-h3'], - icon: icon(ChevronRight), - group: 'Headings', - get description() { - return translate( - 'auto.components.editor.rich.markdown.slash.commands.8c1a3e7d52', - 'Create a collapsible section with a small heading summary.' - ) - }, - run: (editor) => { - insertToggle(editor, 'heading-3') - } - }, { id: 'heading-4', get label() { @@ -154,27 +91,6 @@ export const headingSlashCommands: SlashCommand[] = [ editor.chain().focus().setHeading({ level: 4 }).run() } }, - { - id: 'toggle-h4', - get label() { - return translate( - 'auto.components.editor.rich.markdown.slash.commands.5e0b9c2a71', - 'Toggle Heading 4' - ) - }, - aliases: ['toggle-h4'], - icon: icon(ChevronRight), - group: 'Headings', - get description() { - return translate( - 'auto.components.editor.rich.markdown.slash.commands.d4f16a8b39', - 'Create a collapsible section with a nested heading summary.' - ) - }, - run: (editor) => { - insertToggle(editor, 'heading-4') - } - }, { id: 'heading-5', get label() { @@ -196,5 +112,91 @@ export const headingSlashCommands: SlashCommand[] = [ // Use setHeading (not toggleHeading) so "/h5" is idempotent. editor.chain().focus().setHeading({ level: 5 }).run() } + }, + // Grouped separately (Notion-style) so plain headings and collapsible + // toggle headings are easy to scan as distinct families in the slash menu. + { + id: 'toggle-h1', + get label() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.41482b15ce', + 'Toggle H1' + ) + }, + aliases: ['toggle-h1', 'toggle heading', 'details heading', 'collapse heading'], + icon: icon(ChevronRight), + group: 'Toggle headings', + get description() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.3294a2c0cc', + 'Create a collapsible section with a large heading summary.' + ) + }, + run: (editor) => { + insertToggle(editor, 'heading-1') + } + }, + { + id: 'toggle-h2', + get label() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.7a2c1f9b04', + 'Toggle H2' + ) + }, + aliases: ['toggle-h2'], + icon: icon(ChevronRight), + group: 'Toggle headings', + get description() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.b3e5d8a1c6', + 'Create a collapsible section with a medium heading summary.' + ) + }, + run: (editor) => { + insertToggle(editor, 'heading-2') + } + }, + { + id: 'toggle-h3', + get label() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.2f9d6b4e10', + 'Toggle H3' + ) + }, + aliases: ['toggle-h3'], + icon: icon(ChevronRight), + group: 'Toggle headings', + get description() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.8c1a3e7d52', + 'Create a collapsible section with a small heading summary.' + ) + }, + run: (editor) => { + insertToggle(editor, 'heading-3') + } + }, + { + id: 'toggle-h4', + get label() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.5e0b9c2a71', + 'Toggle H4' + ) + }, + aliases: ['toggle-h4'], + icon: icon(ChevronRight), + group: 'Toggle headings', + get description() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.d4f16a8b39', + 'Create a collapsible section with a nested heading summary.' + ) + }, + run: (editor) => { + insertToggle(editor, 'heading-4') + } } ] diff --git a/src/renderer/src/components/editor/rich-markdown-slash-command-primitives.ts b/src/renderer/src/components/editor/rich-markdown-slash-command-primitives.ts index 447a7c08a53..3f982d9bba9 100644 --- a/src/renderer/src/components/editor/rich-markdown-slash-command-primitives.ts +++ b/src/renderer/src/components/editor/rich-markdown-slash-command-primitives.ts @@ -40,7 +40,13 @@ export type SlashCommandIcon = | { kind: 'component'; component: React.ComponentType<{ className?: string }> } | { kind: 'text'; value: string } -export type SlashCommandGroup = 'Headings' | 'Basic blocks' | 'Advanced' | 'Media' | 'Others' +export type SlashCommandGroup = + | 'Headings' + | 'Toggle headings' + | 'Basic blocks' + | 'Advanced' + | 'Media' + | 'Others' export type SlashCommand = { id: SlashCommandId diff --git a/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts b/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts index 79ea48d8929..ad7a1197323 100644 --- a/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts +++ b/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts @@ -205,8 +205,11 @@ describe('feature interaction writer boundaries', () => { const taskPageSource = componentSource('TaskPage.tsx') const jiraWriter = "recordFeatureInteraction('jira-tasks')" + // End boundary is the declaration after the handler: the Jira connect flow + // now lives in the shared JiraConnectDialog, so handleJiraConnect (the prior + // marker) no longer exists in TaskPage. expect( - sourceBetween(taskPageSource, 'const handleUseJiraItem', 'const handleJiraConnect') + sourceBetween(taskPageSource, 'const handleUseJiraItem', 'const taskPageListChromeHidden') ).toContain(jiraWriter) }) diff --git a/src/renderer/src/components/jira-connect-dialog.tsx b/src/renderer/src/components/jira-connect-dialog.tsx index eff9f0cde24..0de9093185c 100644 --- a/src/renderer/src/components/jira-connect-dialog.tsx +++ b/src/renderer/src/components/jira-connect-dialog.tsx @@ -1,4 +1,4 @@ -import { useId, useState } from 'react' +import { useId, useLayoutEffect, useState } from 'react' import { LoaderCircle, Lock } from 'lucide-react' import { useAppStore } from '@/store' import { useMountedRef } from '@/hooks/useMountedRef' @@ -13,6 +13,7 @@ import { } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' +import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group' import { cn } from '@/lib/utils' import { hasRemoteProviderRuntime } from '@/lib/provider-runtime-context' import { translate } from '@/i18n/i18n' @@ -26,6 +27,10 @@ type JiraConnectDialogProps = { } type ConnectState = 'idle' | 'connecting' | 'error' +type JiraInstanceType = 'cloud' | 'server' +// Self-hosted Jira accepts either a personal access token (Bearer) or classic +// username + password (Basic); older Server/DC instances predate PATs. +type ServerAuthMethod = 'pat' | 'basic' // Why: mirrors the inline Jira connect dialog in TaskPage so the onboarding // "Connect integrations" step can reuse the same site URL + email + API token @@ -45,15 +50,39 @@ export function JiraConnectDialog({ const tokenId = useId() const errorId = useId() + const [instanceType, setInstanceType] = useState('cloud') + const [serverAuthMethod, setServerAuthMethod] = useState('pat') const [siteUrl, setSiteUrl] = useState('') const [email, setEmail] = useState('') const [apiToken, setApiToken] = useState('') const [connectState, setConnectState] = useState('idle') const [connectError, setConnectError] = useState(null) + // Start every open with a clean slate so a previously-typed secret, stale + // instance/auth-method selection, or old error can't linger across reopens. + // Runs before paint so a stale credential never renders for a frame. + useLayoutEffect(() => { + if (!open) { + return + } + setInstanceType('cloud') + setServerAuthMethod('pat') + setSiteUrl('') + setEmail('') + setApiToken('') + setConnectState('idle') + setConnectError(null) + }, [open]) + + const isServer = instanceType === 'server' + // `needsIdentity` folds "Cloud Atlassian email" and "self-hosted Basic + // username" — the identity slot that keys/labels the stored site. PAT auth + // uses no identity, so the email field is hidden and left empty. + const isServerBasic = isServer && serverAuthMethod === 'basic' + const needsIdentity = !isServer || isServerBasic const canSubmit = Boolean(siteUrl.trim()) && - Boolean(email.trim()) && + (!needsIdentity || Boolean(email.trim())) && Boolean(apiToken.trim()) && connectState !== 'connecting' const credentialStorageCopy = hasRemoteProviderRuntime(settings) @@ -67,6 +96,16 @@ export function JiraConnectDialog({ } } + // A Cloud email, a Server username, a PAT, and an account password are + // different secrets; drop the credential fields when the deployment or auth + // method changes so one can't be submitted as another (e.g. a password + // silently riding along as a Bearer PAT). + const clearCredentialsOnModeSwitch = (): void => { + setEmail('') + setApiToken('') + clearErrorOnEdit() + } + const handleOpenChange = (nextOpen: boolean): void => { if (connectState !== 'connecting') { onOpenChange(nextOpen) @@ -77,7 +116,12 @@ export function JiraConnectDialog({ const trimmedSite = siteUrl.trim() const trimmedEmail = email.trim() const trimmedToken = apiToken.trim() - if (!trimmedSite || !trimmedEmail || !trimmedToken || connectState === 'connecting') { + if ( + !trimmedSite || + (needsIdentity && !trimmedEmail) || + !trimmedToken || + connectState === 'connecting' + ) { return } setConnectState('connecting') @@ -85,8 +129,11 @@ export function JiraConnectDialog({ try { const result = await connectJira({ siteUrl: trimmedSite, - email: trimmedEmail, - apiToken: trimmedToken + // Cloud sends the Atlassian email; self-hosted Basic sends the username; + // PAT sends nothing, so a stale email can't key/label the stored site. + email: needsIdentity ? trimmedEmail : '', + apiToken: trimmedToken, + authType: instanceType }) if (!mountedRef.current) { return @@ -95,6 +142,8 @@ export function JiraConnectDialog({ setSiteUrl('') setEmail('') setApiToken('') + setInstanceType('cloud') + setServerAuthMethod('pat') setConnectState('idle') onOpenChange(false) onConnected?.() @@ -121,31 +170,110 @@ export function JiraConnectDialog({ {translate('auto.components.jira.connect.dialog.8388bdea2b', 'Connect Jira site')} - {translate( - 'auto.components.jira.connect.dialog.d785c42b8b', - 'Use a Jira Cloud site URL, Atlassian email, and API token to browse issues.' - )} + {!isServer + ? translate( + 'auto.components.jira.connect.dialog.d785c42b8b', + 'Use a Jira Cloud site URL, Atlassian email, and API token to browse issues.' + ) + : isServerBasic + ? translate( + 'auto.components.jira.connect.dialog.1d947a07ab', + 'Use a self-hosted Jira base URL, username, and password to browse issues.' + ) + : translate( + 'auto.components.jira.connect.dialog.2e2b69e48e', + 'Use a self-hosted Jira base URL and a personal access token to browse issues.' + )}
{ event.preventDefault() void handleConnect() }} >
+ { + if (!value || connectState === 'connecting') { + return + } + setInstanceType(value as JiraInstanceType) + clearCredentialsOnModeSwitch() + }} + aria-label={translate( + 'auto.components.jira.connect.dialog.b67e919bd5', + 'Jira instance type' + )} + > + + {translate('auto.components.jira.connect.dialog.17787d6e4b', 'Atlassian Cloud')} + + + {translate('auto.components.jira.connect.dialog.bc7a831773', 'Self-hosted')} + + + {isServer ? ( + { + if (!value || connectState === 'connecting') { + return + } + setServerAuthMethod(value as ServerAuthMethod) + clearCredentialsOnModeSwitch() + }} + aria-label={translate( + 'auto.components.jira.connect.dialog.f49708c369', + 'Jira authentication method' + )} + > + + {translate( + 'auto.components.jira.connect.dialog.730d973bae', + 'Personal access token' + )} + + + {translate( + 'auto.components.jira.connect.dialog.84a810dd0e', + 'Username & password' + )} + + + ) : null}
{ setSiteUrl(event.target.value) @@ -154,36 +282,66 @@ export function JiraConnectDialog({ disabled={connectState === 'connecting'} />
-
- - { - setEmail(event.target.value) - clearErrorOnEdit() - }} - disabled={connectState === 'connecting'} - /> -
+ {needsIdentity ? ( +
+ + { + setEmail(event.target.value) + clearErrorOnEdit() + }} + disabled={connectState === 'connecting'} + /> +
+ ) : null}
{ setApiToken(event.target.value) @@ -199,24 +357,40 @@ export function JiraConnectDialog({ {connectError}

) : null} -

- {translate('auto.components.jira.connect.dialog.8090504a3e', 'Create a token in')}{' '} - - . -

+

+ ) : isServer ? ( +

+ {translate( + 'auto.components.jira.connect.dialog.ccfb086d3e', + 'Create a personal access token in your Jira profile under Personal Access Tokens.' + )} +

+ ) : ( +

+ {translate('auto.components.jira.connect.dialog.8090504a3e', 'Create a token in')}{' '} + + . +

+ )}

{credentialStorageCopy} diff --git a/src/renderer/src/components/linear-issue-workspace-text.test.ts b/src/renderer/src/components/linear-issue-workspace-text.test.ts new file mode 100644 index 00000000000..eecc60bddf5 --- /dev/null +++ b/src/renderer/src/components/linear-issue-workspace-text.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' + +import type { LinearIssue } from '../../../shared/types' +import { buildLinearIssueBranchName } from './linear-issue-workspace-text' + +function makeIssue(branchName?: string): LinearIssue { + return { + id: 'issue-1', + identifier: 'ENG-123', + title: 'Fix launch context handoff', + url: 'https://linear.app/acme/issue/ENG-123/fix-launch-context-handoff', + branchName, + state: { name: 'Todo', type: 'unstarted', color: '#999999' }, + team: { id: 'team-1', name: 'Engineering', key: 'ENG' }, + labels: [], + labelIds: [], + priority: 3, + estimate: null, + updatedAt: '2026-05-29T12:00:00.000Z' + } +} + +describe('buildLinearIssueBranchName', () => { + it('prefers Linear’s branch name', () => { + expect(buildLinearIssueBranchName(makeIssue(' team/eng-123-fix-launch-context '))).toBe( + 'team/eng-123-fix-launch-context' + ) + }) + + it('falls back to Orca’s generated workspace slug', () => { + expect(buildLinearIssueBranchName(makeIssue())).toBe('eng-123-fix-launch-context-handoff') + expect(buildLinearIssueBranchName(makeIssue(' '))).toBe('eng-123-fix-launch-context-handoff') + }) +}) diff --git a/src/renderer/src/components/linear-issue-workspace-text.ts b/src/renderer/src/components/linear-issue-workspace-text.ts index 0d95641d5ec..b9a9a518e79 100644 --- a/src/renderer/src/components/linear-issue-workspace-text.ts +++ b/src/renderer/src/components/linear-issue-workspace-text.ts @@ -1,5 +1,6 @@ import { getLinearIssueWorkspaceName } from '../../../shared/workspace-name' import type { LinearIssue } from '../../../shared/types' +import { getUsableLinearBranchName } from '../../../shared/new-workspace/workspace-source' const relativeTimeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) @@ -22,7 +23,7 @@ export function formatLinearIssueRelativeTime(input: string): string { } export function buildLinearIssueBranchName(issue: LinearIssue): string { - return getLinearIssueWorkspaceName(issue) + return getUsableLinearBranchName(issue.branchName) ?? getLinearIssueWorkspaceName(issue) } export function buildLinearIssuePrompt(issue: LinearIssue): string { diff --git a/src/renderer/src/components/native-chat/NativeChatEmptyState.tsx b/src/renderer/src/components/native-chat/NativeChatEmptyState.tsx index 8ddb4a46c9b..f7c20ef51f4 100644 --- a/src/renderer/src/components/native-chat/NativeChatEmptyState.tsx +++ b/src/renderer/src/components/native-chat/NativeChatEmptyState.tsx @@ -1,6 +1,7 @@ import { MessageSquare, TriangleAlert } from 'lucide-react' import { translate } from '@/i18n/i18n' import { formatAgentTypeLabel } from '@/lib/agent-status' +import { NATIVE_CHAT_EMPTY_STATE_COPY } from '../../../../shared/native-chat-empty-state' import type { NativeChatSession } from '../../../../shared/native-chat-types' export function NativeChatEmptyState({ @@ -44,28 +45,37 @@ function emptyStateCopy( switch (kind) { case 'loading': return { - title: translate('components.native-chat.state.loading.title', 'Loading conversation…'), + title: translate( + 'components.native-chat.state.loading.title', + NATIVE_CHAT_EMPTY_STATE_COPY.loading.title + ), subtitle: translate( 'components.native-chat.state.loading.subtitle', - 'Reading the agent transcript.' + NATIVE_CHAT_EMPTY_STATE_COPY.loading.subtitle ) } case 'error': return { - title: translate('components.native-chat.state.error.title', 'Could not load conversation'), + title: translate( + 'components.native-chat.state.error.title', + NATIVE_CHAT_EMPTY_STATE_COPY.error.title + ), subtitle: message ?? translate( 'components.native-chat.state.error.subtitle', - 'The transcript could not be read. Toggle back to the terminal to keep working.' + NATIVE_CHAT_EMPTY_STATE_COPY.error.subtitle ) } case 'not-agent': return { - title: translate('components.native-chat.state.notAgent.title', 'No conversation here'), + title: translate( + 'components.native-chat.state.notAgent.title', + NATIVE_CHAT_EMPTY_STATE_COPY.notAgent.title + ), subtitle: translate( 'components.native-chat.state.notAgent.subtitle', - 'This terminal is not running a recognized coding agent.' + NATIVE_CHAT_EMPTY_STATE_COPY.notAgent.subtitle ) } case 'empty': { @@ -73,12 +83,12 @@ function emptyStateCopy( return { title: translate( 'components.native-chat.state.empty.title', - 'Start a chat with {{value0}}', + NATIVE_CHAT_EMPTY_STATE_COPY.empty.title, { value0: agentName } ), subtitle: translate( 'components.native-chat.state.empty.subtitle', - 'Ask {{value0}} to inspect code, explain output, or make a change.', + NATIVE_CHAT_EMPTY_STATE_COPY.empty.subtitle, { value0: agentName } ) } diff --git a/src/renderer/src/components/native-chat/native-chat-diff.ts b/src/renderer/src/components/native-chat/native-chat-diff.ts index 0b0eb791490..fd9bba9a5c6 100644 --- a/src/renderer/src/components/native-chat/native-chat-diff.ts +++ b/src/renderer/src/components/native-chat/native-chat-diff.ts @@ -1,70 +1,6 @@ -// Why: agent edits show up as tool calls (Edit/Write with old/new strings) and -// tool results that contain unified-diff text. The chat renders these as inline -// coloured diffs like the terminal, so detection/parsing is pure and testable. -// Ported from the mobile diffFromToolCall/diffFromText (desktop parity). - -export type DiffLineKind = 'add' | 'del' | 'context' | 'meta' - -export type DiffLine = { - kind: DiffLineKind - text: string -} - -const EDIT_TOOL_NAMES = new Set(['Edit', 'MultiEdit', 'Write', 'str_replace', 'apply_patch']) - -function toLines(value: unknown): string[] { - return typeof value === 'string' ? value.replace(/\n$/, '').split('\n') : [] -} - -/** Build diff lines from an Edit-style tool call input (old_string/new_string), - * or null when the input isn't an editing payload. Old lines render as deletes, - * new lines as adds — a simple, readable before/after rather than a full LCS. */ -export function diffFromToolCall(name: string, input: unknown): DiffLine[] | null { - if (!EDIT_TOOL_NAMES.has(name) || typeof input !== 'object' || input === null) { - return null - } - const obj = input as Record - const oldText = obj.old_string ?? obj.oldString ?? obj.old - const newText = obj.new_string ?? obj.newString ?? obj.new ?? obj.content ?? obj.file_text - const dels = toLines(oldText).map((text): DiffLine => ({ kind: 'del', text })) - const adds = toLines(newText).map((text): DiffLine => ({ kind: 'add', text })) - if (dels.length === 0 && adds.length === 0) { - return null - } - const lines: DiffLine[] = [] - if (typeof obj.file_path === 'string' || typeof obj.path === 'string') { - lines.push({ kind: 'meta', text: String(obj.file_path ?? obj.path) }) - } - return [...lines, ...dels, ...adds] -} - -/** Parse unified-diff-looking text into coloured lines, or null when the text - * doesn't read as a diff (no +/- lines). */ -export function diffFromText(text: string): DiffLine[] | null { - if (typeof text !== 'string' || text.length === 0) { - return null - } - const raw = text.split('\n') - let added = 0 - let removed = 0 - const lines: DiffLine[] = raw.map((line): DiffLine => { - if (line.startsWith('@@') || line.startsWith('diff ') || line.startsWith('index ')) { - return { kind: 'meta', text: line } - } - if (line.startsWith('+') && !line.startsWith('+++')) { - added++ - return { kind: 'add', text: line.slice(1) } - } - if (line.startsWith('-') && !line.startsWith('---')) { - removed++ - return { kind: 'del', text: line.slice(1) } - } - return { kind: 'context', text: line } - }) - // Require a meaningful amount of diff signal so ordinary prose isn't mistaken - // for a diff (a stray leading '-' bullet shouldn't trigger diff rendering). - if (added + removed < 2) { - return null - } - return lines -} +export { + diffFromText, + diffFromToolCall, + type NativeChatDiffLine as DiffLine, + type NativeChatDiffLineKind as DiffLineKind +} from '../../../../shared/native-chat-diff' diff --git a/src/renderer/src/components/native-chat/native-chat-interactive-prompt.ts b/src/renderer/src/components/native-chat/native-chat-interactive-prompt.ts index 8a995a7580d..f8da5702190 100644 --- a/src/renderer/src/components/native-chat/native-chat-interactive-prompt.ts +++ b/src/renderer/src/components/native-chat/native-chat-interactive-prompt.ts @@ -1,141 +1,46 @@ -// Pure parser for the live `agentStatus.interactivePrompt` envelope (JSON the -// host captures from the agent's hook). It resolves to either a structured -// question prompt (AskUserQuestion) or a tool-approval (PermissionRequest), the -// two interactive cards the native chat renders just above the composer. Kept -// pure (no React/IO) so the envelope rules are unit-testable. -// -// Why: the Ask question parser (parseQuestionsShape/parseOptions/the -// QUESTION_TOOL_PARSERS registry/parseToolInput/formatAskAnswer) is a byte-for-byte -// mirror of mobile's `mobile-native-chat-ask.ts` — Metro can't import these runtime -// values from src/shared, so both copies must stay in sync; parity is asserted by -// `src/shared/native-chat-ask-parser-parity.test.ts`. The approval-card logic below -// (ChatApproval/parseApprovalFromStatus/ESCAPE) is desktop-only. - -import type { - AskOption, - AskPrompt, - AskQuestion, - InteractiveQuestionParser -} from '../../../../shared/native-chat-ask-types' import { translate } from '@/i18n/i18n' +import { + buildAskAnswerKeys, + formatAskAnswer, + hasAskAnswer, + parseAskFromStatus, + registerQuestionTool, + type AskAnswerKeyGroup, + type AskAnswerSelection, + type AskOption, + type AskPrompt, + type AskQuestion, + type InteractiveQuestionParser +} from '../../../../shared/native-chat-ask' -export type { AskOption, AskPrompt, AskQuestion, InteractiveQuestionParser } +export { + buildAskAnswerKeys, + formatAskAnswer, + hasAskAnswer, + parseAskFromStatus, + registerQuestionTool, + type AskAnswerKeyGroup, + type AskAnswerSelection, + type AskOption, + type AskPrompt, + type AskQuestion, + type InteractiveQuestionParser +} -/** A detected tool-approval, rendered as an Allow/Deny card. Each option's - * `send` is the literal string written back to the agent's PTY when chosen - * (a number to allow; the ESC char to deny). */ export type ChatApproval = { title: string detail?: string options: { label: string; send: string }[] } -/** The interactive card to render for the current live status, or null. A - * question takes precedence over an approval when both somehow parse. */ export type InteractivePromptCard = | { kind: 'question'; prompt: AskPrompt } | { kind: 'approval'; approval: ChatApproval } | null -// ESC interrupts the agent over the PTY (matches how the composer forwards -// Escape), so "Cancel"/"Deny" sends this byte. const ESCAPE = String.fromCharCode(27) -// Registry of question-tool parsers keyed by the tool name the agent reports. -// To support a new terminal/agent's question tool, register its parser here (or -// via registerQuestionTool) — the renderer and wiring stay unchanged. -const QUESTION_TOOL_PARSERS = new Map() - -export function registerQuestionTool(toolName: string, parser: InteractiveQuestionParser): void { - QUESTION_TOOL_PARSERS.set(toolName, parser) -} - -/** Claude's AskUserQuestion shape: `{ questions: [{ question, header, - * multiSelect, options: [{ label, description }] }] }`. Also the de-facto - * default shape, so a new agent that reuses it works without registration. */ -function parseQuestionsShape(input: unknown): AskPrompt | null { - if (!input || typeof input !== 'object') { - return null - } - const rawQuestions = (input as { questions?: unknown }).questions - if (!Array.isArray(rawQuestions) || rawQuestions.length === 0) { - return null - } - const questions: AskQuestion[] = [] - for (const raw of rawQuestions) { - if (!raw || typeof raw !== 'object') { - continue - } - const q = raw as Record - const question = typeof q.question === 'string' ? q.question : '' - const options = parseOptions(q.options) - if (question || options.length > 0) { - questions.push({ - question, - header: typeof q.header === 'string' ? q.header : undefined, - multiSelect: q.multiSelect === true, - options - }) - } - } - return questions.length > 0 ? { questions } : null -} - -function parseOptions(raw: unknown): AskOption[] { - if (!Array.isArray(raw)) { - return [] - } - return raw - .map((o): AskOption | null => { - if (typeof o === 'string') { - return { label: o } - } - if (o && typeof o === 'object' && typeof (o as { label?: unknown }).label === 'string') { - const obj = o as { label: string; description?: unknown } - return { - label: obj.label, - description: typeof obj.description === 'string' ? obj.description : undefined - } - } - return null - }) - .filter((o): o is AskOption => o !== null) -} - -// Claude's AskUserQuestion (and aliases) ship the canonical questions shape. -for (const name of ['AskUserQuestion', 'ask_user_question', 'askUserQuestion']) { - QUESTION_TOOL_PARSERS.set(name, parseQuestionsShape) -} - -/** Resolve an interactive-prompt payload to an AskPrompt: try the tool's - * registered parser first, then fall back to the canonical questions shape so a - * new agent that happens to use the same structure works without registration. */ -function parseToolInput(toolName: string | undefined, input: unknown): AskPrompt | null { - const parser = toolName ? QUESTION_TOOL_PARSERS.get(toolName) : undefined - return (parser ? parser(input) : null) ?? parseQuestionsShape(input) -} - -/** Parse the live `agentStatus.interactivePrompt` (the agent's untruncated - * question-tool input as JSON) into an AskPrompt, or null. Dispatches through - * the tool's registered parser (keyed by `toolName`) with the canonical - * questions shape as the fallback. */ -export function parseAskFromStatus( - interactivePrompt: string | undefined | null, - toolName?: string -): AskPrompt | null { - if (!interactivePrompt) { - return null - } - try { - return parseToolInput(toolName, JSON.parse(interactivePrompt)) - } catch { - return null - } -} - -/** Parse the `{ approval: { tool, summary } }` envelope (emitted by the host on - * a PermissionRequest) into an Allow/Deny card, or null. Allow sends "1"; Deny - * sends ESC — matching the common TUI approval prompt. */ +/** Parse the desktop-only approval envelope; question parsing stays cross-platform. */ export function parseApprovalFromStatus( interactivePrompt: string | undefined | null ): ChatApproval | null { @@ -172,9 +77,6 @@ export function parseApprovalFromStatus( } } -/** Resolve the live `interactivePrompt` to the single card to render. A question - * takes precedence over an approval. `toolName` is forwarded to the Ask branch - * for registry dispatch; the approval branch ignores it. */ export function parseInteractivePrompt( interactivePrompt: string | undefined | null, toolName?: string @@ -184,118 +86,5 @@ export function parseInteractivePrompt( return { kind: 'question', prompt } } const approval = parseApprovalFromStatus(interactivePrompt) - if (approval) { - return { kind: 'approval', approval } - } - return null -} - -/** One question's chosen answer, normalized for delivery: the selected option - * indices (in option order) plus any free-text "other" answer. Index-based (not - * label text) so the answer can be delivered by the selector's stable option - * number — see `buildAskAnswerKeys`. */ -export type AskAnswerSelection = { indices: number[]; other?: string } - -/** A single keystroke group to write to the agent PTY. `raw` bytes (option - * numbers, Enter, arrows) are written verbatim as keystrokes; `text` is a - * free-text answer the caller runs through its paste sanitizer before writing. */ -export type AskAnswerKeyGroup = { raw: string } | { text: string } - -/** True when this question is answered (a picked option or typed free text). */ -function isAnswered(sel: AskAnswerSelection | undefined): boolean { - return (sel?.indices.length ?? 0) > 0 || (sel?.other ?? '').trim().length > 0 -} - -/** The picked labels + trimmed free text for one question, in option order. */ -function answerLabels(question: AskQuestion, sel: AskAnswerSelection | undefined): string[] { - const labels = (sel?.indices ?? []) - .map((i) => question.options[i]?.label ?? '') - .filter((l) => l.length > 0) - const other = (sel?.other ?? '').trim() - return other ? [...labels, other] : labels -} - -/** Build the human-readable answer text: one line per question, in question - * order, each the selected label(s) + free text joined by ", ". Empty answers - * stay empty lines so N lines always == N questions. Used for agents whose - * question tool commits a pasted answer (not Claude's arrow-navigate selector). */ -export function formatAskAnswer(prompt: AskPrompt, selections: AskAnswerSelection[]): string { - return prompt.questions.map((q, i) => answerLabels(q, selections[i]).join(', ')).join('\n') -} - -// Claude's AskUserQuestion is an arrow-navigate selector: a bare Enter commits -// the HIGHLIGHTED default (the first option), and pasted label text does not move -// the highlight — so answering by label silently delivered every non-first pick -// as the first option (STA-1860). Instead we drive the selector by each option's -// stable 1-based number (which matches the card's badge), the marker it commits -// on. Right-arrow steps to the next question / the Submit tab. Verified live -// against Claude Code's TUI; groups are written spaced apart (see the desktop -// sender) because a navigation keystroke batched with Enter commits before the -// selector has applied it. -const ASK_ENTER = '\r' -const ASK_NEXT_TAB = '\x1b[C' - -/** Build the ordered keystroke groups that answer a Claude Code AskUserQuestion. - * Each group is written a step apart so the selector applies it before the next. - * - * - single-select pick → the option number (selects AND commits; in a - * multi-question prompt it auto-advances to the next question) - * - free-text answer → the "Type something" row number, the text, then Enter - * - multi-select → each option number TOGGLES its checkbox, then a step - * to the Submit tab - * - a multi-question prompt (and a lone multi-select) finishes on a Submit - * confirmation, so it ends with one Enter - * - * (Option counts are ≤ the tool's cap of a few, so single-digit numbers always - * address every row.) */ -export function buildAskAnswerKeys( - prompt: AskPrompt, - selections: AskAnswerSelection[] -): AskAnswerKeyGroup[] { - const questions = prompt.questions - const multiQuestion = questions.length > 1 - const groups: AskAnswerKeyGroup[] = [] - - questions.forEach((q, qi) => { - const sel = selections[qi] - const other = (sel?.other ?? '').trim() - const typeSomething = String(q.options.length + 1) - - if (q.multiSelect) { - for (const i of sel?.indices ?? []) { - groups.push({ raw: String(i + 1) }) - } - if (other) { - groups.push({ raw: typeSomething }, { text: other }, { raw: ASK_ENTER }) - } - // A multi-select never auto-advances; step to the next tab (the Submit tab - // when this is the last question). - groups.push({ raw: ASK_NEXT_TAB }) - } else if (other) { - // Single-select can only carry one value, so route any answer that - // includes free text through the "Type something" row as one string. - groups.push( - { raw: typeSomething }, - { text: answerLabels(q, sel).join(', ') }, - { raw: ASK_ENTER } - ) - } else if ((sel?.indices.length ?? 0) > 0) { - groups.push({ raw: String(sel!.indices[0]! + 1) }) - } else if (multiQuestion) { - // Unanswered question in a multi-question prompt: step past it. - groups.push({ raw: ASK_NEXT_TAB }) - } - }) - - const endsOnSubmitTab = - multiQuestion || (questions.length === 1 && questions[0]!.multiSelect === true) - if (endsOnSubmitTab && groups.length > 0) { - groups.push({ raw: ASK_ENTER }) - } - return groups -} - -/** Whether any question in `selections` carries an answer worth submitting. */ -export function hasAskAnswer(prompt: AskPrompt, selections: AskAnswerSelection[]): boolean { - return prompt.questions.some((_, i) => isAnswered(selections[i])) + return approval ? { kind: 'approval', approval } : null } diff --git a/src/renderer/src/components/native-chat/native-chat-live-status.ts b/src/renderer/src/components/native-chat/native-chat-live-status.ts index bd31627f8ce..7936fd7e231 100644 --- a/src/renderer/src/components/native-chat/native-chat-live-status.ts +++ b/src/renderer/src/components/native-chat/native-chat-live-status.ts @@ -17,9 +17,12 @@ export type NativeChatLiveMergeInput = { agent: AgentType /** Live hook state for the pane, or null when no hook entry exists. */ hookState: AgentStatusState | null - /** True before the initial readSession resolves; forces 'loading'. */ + /** Epoch ms when the current hook state began, or null when unknown. Lets a + * stale 'working' self-heal once this turn's own assistant reply has landed. */ + stateStartedAt?: number | null + /** True before the initial snapshot resolves; forces 'loading'. */ loading?: boolean - /** Set when the initial read failed; forces 'error'. */ + /** Set when the initial snapshot failed; forces 'error'. */ error?: string } @@ -30,13 +33,12 @@ export type NativeChatLiveMergeInput = { * * Precedence: * - error / loading overrides win outright. - * - hook 'working' shows a live working indicator BEFORE the assistant turn - * lands in the transcript. Once the transcript's last message is an - * assistant reply (the turn flushed), 'working' is no longer asserted and - * the derived 'ready' status from the assembler stands. + * - hook 'working' stays authoritative until the hook exits that state OR this + * turn's own assistant reply lands (a trailing reply newer than + * stateStartedAt); a trailing reply from an EARLIER turn does not suppress it. */ export function mergeNativeChatLiveSession(input: NativeChatLiveMergeInput): NativeChatSession { - const { sources, sessionId, agent, hookState, loading, error } = input + const { sources, sessionId, agent, hookState, stateStartedAt, loading, error } = input if (error) { return assembleNativeChatSession({ sources, sessionId, agent, status: 'error', error }) } @@ -44,7 +46,7 @@ export function mergeNativeChatLiveSession(input: NativeChatLiveMergeInput): Nat return assembleNativeChatSession({ sources, sessionId, agent, status: 'loading' }) } - const status = liveStatusOverride(sources, hookState) + const status = liveStatusOverride(hookState, sources, stateStartedAt) return assembleNativeChatSession({ sources, sessionId, @@ -54,24 +56,38 @@ export function mergeNativeChatLiveSession(input: NativeChatLiveMergeInput): Nat } function liveStatusOverride( + hookState: AgentStatusState | null, sources: NativeChatSources, - hookState: AgentStatusState | null + stateStartedAt: number | null | undefined ): NativeChatSessionStatus | undefined { // Only 'working' drives a live override; blocked/waiting/done leave the // derived (ready/empty) status alone so completed turns render normally. if (hookState !== 'working') { return undefined } - // If the transcript has already flushed the in-flight assistant reply, the - // turn is effectively visible — don't keep asserting 'working' on top of it. - if (lastMessageIsFreshAssistant(sources)) { + // Self-heal a stale 'working' (dropped/late Stop hook): if this turn's own + // assistant reply has already landed, the turn is effectively visible — stop + // asserting 'working'. A trailing reply from a PRIOR turn (older than the + // working turn's start) must not suppress it: the agent is working again. + if (trailingAssistantPostDates(sources, stateStartedAt)) { return undefined } return 'working' } -function lastMessageIsFreshAssistant(sources: NativeChatSources): boolean { - const transcript = sources.transcript ?? [] - const last = transcript.at(-1) - return last?.role === 'assistant' +/** True when the transcript's last message is an assistant reply that landed at + * or after `stateStartedAt`. Unknown timings (no start, no message timestamp) + * return false so the caller keeps 'working' — the safe, non-regressing default. */ +function trailingAssistantPostDates( + sources: NativeChatSources, + stateStartedAt: number | null | undefined +): boolean { + if (stateStartedAt == null) { + return false + } + const last = (sources.transcript ?? []).at(-1) + if (last?.role !== 'assistant' || last.timestamp == null) { + return false + } + return last.timestamp >= stateStartedAt } diff --git a/src/renderer/src/components/native-chat/native-chat-noise.ts b/src/renderer/src/components/native-chat/native-chat-noise.ts index 70e7a1d8cd3..6ef06b130a6 100644 --- a/src/renderer/src/components/native-chat/native-chat-noise.ts +++ b/src/renderer/src/components/native-chat/native-chat-noise.ts @@ -1,36 +1 @@ -import { isTextBlock, type NativeChatMessage } from '../../../../shared/native-chat-types' -import { isKnownHarnessInjectedUserTurnText } from '../../../../shared/harness-injected-user-turns' - -// Why: harness machinery turns land in the transcript but are not real user -// messages, so the chat filters them out (they were confusingly rendered as -// the user's own bubbles). The classifier lives in -// src/shared/harness-injected-user-turns.ts, shared with the agent-status -// prompt pipeline; structurally marked turns (isMeta etc.) are already -// dropped by the Claude transcript decoder. - -function messageText(message: NativeChatMessage): string { - return message.blocks - .filter(isTextBlock) - .map((b) => b.text) - .join('') - .trim() -} - -/** True when a message is harness machinery rather than real conversation. Only - * user/system turns qualify — assistant/tool turns and any turn carrying real - * tool activity are always kept. */ -export function isNoiseMessage(message: NativeChatMessage): boolean { - if (message.role !== 'user' && message.role !== 'system') { - return false - } - // Keep turns that carry tool activity (e.g. a user turn with tool results). - if (message.blocks.some((b) => b.type === 'tool-call' || b.type === 'tool-result')) { - return false - } - return isKnownHarnessInjectedUserTurnText(messageText(message)) -} - -/** Drop harness-noise messages from a transcript. */ -export function stripNoiseMessages(messages: readonly NativeChatMessage[]): NativeChatMessage[] { - return messages.filter((m) => !isNoiseMessage(m)) -} +export { isNoiseMessage, stripNoiseMessages } from '../../../../shared/native-chat-noise' diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts index fa07de93486..59ff443fd24 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts @@ -5,37 +5,21 @@ import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection' import type { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' import type { AskAnswerKeyGroup } from './native-chat-interactive-prompt' +import { + NATIVE_CHAT_ADVANCE_BUFFER_MS, + NATIVE_CHAT_QUESTION_STEP_MS, + NATIVE_CHAT_SUBMIT_DELAY_MS +} from '../../../../shared/native-chat-answer-stepping' import { buildNativeChatImagePasteBytes, buildNativeChatPasteBytes, NATIVE_CHAT_SUBMIT } from './native-chat-send' -// Why: agent TUIs swallow a `\r` bundled into the same pty write as a framed -// paste, so a one-shot send leaves the text sitting in the input box, unsent. -// Write the body first, then the Enter after a delay so the agent processes the -// paste before the submit. The gap must clear the agent's paste-handling latency -// even while it's BUSY (Codex): a short gap (60ms) fires Enter before a busy -// Codex has landed the paste into its input, so the submit hits an empty box and -// the message sits "Queued" forever. 500ms is orca-runtime's proven value in -// writeTerminalAction({enter:true}), so match it here. -export const NATIVE_CHAT_SUBMIT_DELAY_MS = 500 +export { NATIVE_CHAT_ADVANCE_BUFFER_MS, NATIVE_CHAT_QUESTION_STEP_MS, NATIVE_CHAT_SUBMIT_DELAY_MS } + export const NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS = 300 -// Why: answering Claude's AskUserQuestion is a MULTI-STEP keystroke sequence -// (select an option, advance to the next question, land on Submit). Each step -// re-renders the selector, and a keystroke sent before the next step has -// rendered — or an Enter batched with the navigation before it — lands on the -// wrong (or no) row. This buffer pads the between-keystroke gap so each step -// renders first; kept generous so it still holds on slower machines / under SSH -// round-trip latency, where a tighter cadence misfires the answer. -export const NATIVE_CHAT_ADVANCE_BUFFER_MS = 500 - -/** Wall-clock gap between successive AskUserQuestion keystroke groups: the proven - * pty submit gap plus the advance buffer that lets each selector step render - * before the next keystroke is written. */ -export const NATIVE_CHAT_QUESTION_STEP_MS = - NATIVE_CHAT_SUBMIT_DELAY_MS + NATIVE_CHAT_ADVANCE_BUFFER_MS /** Cancels an in-flight send's pending pty writes (the delayed Enter, and any * later question bodies/Enters). Safe to call after the send completes. */ diff --git a/src/renderer/src/components/native-chat/native-chat-session-transport.test.ts b/src/renderer/src/components/native-chat/native-chat-session-transport.test.ts index 324cdd48248..849897c386b 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-transport.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-transport.test.ts @@ -119,22 +119,81 @@ describe('runtime subscribe', () => { } } - it('forwards only appended frames with an array of messages', async () => { + it('normalizes the first frame to a snapshot and reconnect replays to appends', async () => { markRuntimeEnvironmentCompatible(ENV) const { deliver } = stubSubscribe() - const onAppended = vi.fn() + const onFrame = vi.fn() const transport = getNativeChatSessionTransport(ENV) - transport.subscribe({ subscriptionId: 's-1', agent: 'claude', sessionId: 'sess-1' }, onAppended) + transport.subscribe({ subscriptionId: 's-1', agent: 'claude', sessionId: 'sess-1' }, onFrame) await Promise.resolve() deliver({ type: 'appended', messages: [message('m-1')] }) + deliver({ type: 'snapshot', messages: [message('m-snapshot')] }) + deliver({ type: 'replacement', messages: [message('m-replacement')], hasMore: true }) deliver({ type: 'ready' }) // non-appended, ignored deliver({ type: 'appended', messages: undefined }) // no array, ignored deliver({ type: 'appended', messages: [message('m-2')] }, false) // !ok, ignored - expect(onAppended).toHaveBeenCalledTimes(1) - expect(onAppended).toHaveBeenCalledWith([message('m-1')]) + expect(onFrame).toHaveBeenCalledTimes(3) + expect(onFrame).toHaveBeenNthCalledWith(1, { + type: 'snapshot', + messages: [message('m-1')], + hasMore: false + }) + expect(onFrame).toHaveBeenNthCalledWith(2, { + type: 'snapshot', + messages: [message('m-snapshot')], + hasMore: false + }) + expect(onFrame).toHaveBeenNthCalledWith(3, { + type: 'replacement', + messages: [message('m-replacement')], + hasMore: true + }) + }) + + it('settles with an empty snapshot when the first ok frame is an unrecognized shape', async () => { + markRuntimeEnvironmentCompatible(ENV) + const { deliver } = stubSubscribe() + const onFrame = vi.fn() + const transport = getNativeChatSessionTransport(ENV) + + transport.subscribe({ subscriptionId: 's-1', agent: 'claude', sessionId: 'sess-1' }, onFrame) + await Promise.resolve() + + // An ok response we can't parse must still un-stick the view exactly once, + // carrying any error the runtime sent. + deliver({ type: 'ready', error: 'boom' }) + deliver({ type: 'ready' }) + + expect(onFrame).toHaveBeenCalledTimes(1) + expect(onFrame).toHaveBeenCalledWith({ + type: 'snapshot', + messages: [], + hasMore: false, + error: 'boom' + }) + }) + + it('carries an error on a post-initial reconnect snapshot', async () => { + markRuntimeEnvironmentCompatible(ENV) + const { deliver } = stubSubscribe() + const onFrame = vi.fn() + const transport = getNativeChatSessionTransport(ENV) + + transport.subscribe({ subscriptionId: 's-1', agent: 'claude', sessionId: 'sess-1' }, onFrame) + await Promise.resolve() + + deliver({ type: 'snapshot', messages: [message('m-1')] }) + deliver({ type: 'snapshot', messages: [], error: 'runtime too old' }) + + expect(onFrame).toHaveBeenLastCalledWith({ + type: 'snapshot', + messages: [], + hasMore: false, + error: 'runtime too old' + }) }) it('sync unsubscribe closes the dedicated stream and issues no unsubscribe RPC (KTD-6)', async () => { @@ -186,6 +245,115 @@ describe('runtime subscribe', () => { } }) + it('keeps retrying when a reconnect subscribe rejects', async () => { + vi.useFakeTimers() + try { + markRuntimeEnvironmentCompatible(ENV) + const attempts: { + callbacks: { + onResponse: (response: { ok: boolean; result?: unknown }) => void + onClose?: () => void + } + resolve: (handle: { unsubscribe: () => void; sendBinary: () => void }) => void + reject: (error: Error) => void + }[] = [] + runtimeEnvironmentsSubscribe.mockImplementation((_args, callbacks) => { + return new Promise((resolve, reject) => attempts.push({ callbacks, resolve, reject })) + }) + const transport = getNativeChatSessionTransport(ENV) + + transport.subscribe({ subscriptionId: 's-1', agent: 'claude', sessionId: 'sess-1' }, vi.fn()) + attempts[0]!.resolve({ unsubscribe: vi.fn(), sendBinary: vi.fn() }) + attempts[0]!.callbacks.onResponse({ + ok: true, + result: { type: 'snapshot', messages: [], hasMore: false } + }) + attempts[0]!.callbacks.onClose?.() + await vi.advanceTimersByTimeAsync(RUNTIME_NATIVE_CHAT_RECONNECT_MS) + expect(attempts).toHaveLength(2) + + attempts[1]!.reject(new Error('reconnect failed')) + await vi.advanceTimersByTimeAsync(RUNTIME_NATIVE_CHAT_RECONNECT_MS) + + expect(attempts).toHaveLength(3) + } finally { + vi.useRealTimers() + } + }) + + it('closes and retries when an established reconnect returns an error envelope', async () => { + vi.useFakeTimers() + try { + markRuntimeEnvironmentCompatible(ENV) + const attempts: { + callbacks: { + onResponse: (response: { ok: boolean; result?: unknown }) => void + onClose?: () => void + } + unsubscribe: ReturnType + }[] = [] + runtimeEnvironmentsSubscribe.mockImplementation((_args, callbacks) => { + const unsubscribe = vi.fn() + attempts.push({ callbacks, unsubscribe }) + return Promise.resolve({ unsubscribe, sendBinary: vi.fn() }) + }) + const transport = getNativeChatSessionTransport(ENV) + + transport.subscribe({ subscriptionId: 's-1', agent: 'claude', sessionId: 'sess-1' }, vi.fn()) + await vi.advanceTimersByTimeAsync(0) + attempts[0]!.callbacks.onResponse({ + ok: true, + result: { type: 'snapshot', messages: [], hasMore: false } + }) + attempts[0]!.callbacks.onClose?.() + await vi.advanceTimersByTimeAsync(RUNTIME_NATIVE_CHAT_RECONNECT_MS) + expect(attempts).toHaveLength(2) + + attempts[1]!.callbacks.onResponse({ ok: false }) + attempts[1]!.callbacks.onClose?.() + expect(attempts[1]!.unsubscribe).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(RUNTIME_NATIVE_CHAT_RECONNECT_MS) + + expect(attempts).toHaveLength(3) + } finally { + vi.useRealTimers() + } + }) + + it('closes an out-of-order stale handle without replacing the active reconnect', async () => { + vi.useFakeTimers() + try { + markRuntimeEnvironmentCompatible(ENV) + const attempts: { + callbacks: { onClose?: () => void } + resolve: (handle: { unsubscribe: () => void; sendBinary: () => void }) => void + }[] = [] + runtimeEnvironmentsSubscribe.mockImplementation((_args, callbacks) => { + return new Promise((resolve) => attempts.push({ callbacks, resolve })) + }) + const transport = getNativeChatSessionTransport(ENV) + const stop = transport.subscribe( + { subscriptionId: 's-1', agent: 'claude', sessionId: 'sess-1' }, + vi.fn() + ) + + attempts[0]!.callbacks.onClose?.() + await vi.advanceTimersByTimeAsync(RUNTIME_NATIVE_CHAT_RECONNECT_MS) + const activeUnsubscribe = vi.fn() + attempts[1]!.resolve({ unsubscribe: activeUnsubscribe, sendBinary: vi.fn() }) + await Promise.resolve() + const staleUnsubscribe = vi.fn() + attempts[0]!.resolve({ unsubscribe: staleUnsubscribe, sendBinary: vi.fn() }) + await Promise.resolve() + stop() + + expect(staleUnsubscribe).toHaveBeenCalledOnce() + expect(activeUnsubscribe).toHaveBeenCalledOnce() + } finally { + vi.useRealTimers() + } + }) + it('closes a stream that resolves after unsubscribe (cancel race)', async () => { markRuntimeEnvironmentCompatible(ENV) const unsubscribe = vi.fn() diff --git a/src/renderer/src/components/native-chat/native-chat-session-transport.ts b/src/renderer/src/components/native-chat/native-chat-session-transport.ts index 2851a4d84e4..a57d255b6ca 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-transport.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-transport.ts @@ -44,7 +44,7 @@ export function toRuntimeNativeChatErrorMessage(err: unknown): string { const localNativeChatTransport: NativeChatSessionTransport = { readSession: (agent, sessionId, limit, transcriptPath) => window.api.nativeChat.readSession(agent, sessionId, limit, transcriptPath), - subscribe: (args, onAppended) => window.api.nativeChat.subscribe(args, onAppended) + subscribe: (args, onFrame) => window.api.nativeChat.subscribe(args, onFrame) } function createRuntimeNativeChatTransport(environmentId: string): NativeChatSessionTransport { @@ -63,25 +63,33 @@ function createRuntimeNativeChatTransport(environmentId: string): NativeChatSess return { error: toRuntimeNativeChatErrorMessage(err) } } }, - subscribe: (args, onAppended) => { - const { subscriptionId, agent, sessionId, transcriptPath } = args + subscribe: (args, onFrame) => { + const { subscriptionId, agent, sessionId, transcriptPath, limit } = args let cancelled = false + let receivedInitial = false let handleUnsubscribe: (() => void) | null = null let reconnectTimer: ReturnType | null = null + let activeAttempt = 0 + let reconnectPendingAttempt: number | null = null // A mid-stream drop (runtime restart, network blip, relay reconnect) closes // the dedicated stream socket. Re-open it after a short backoff so the live // tail resumes without a manual chat toggle; the fresh drain re-emits the // windowed tail, which merges by id so no turn is duplicated. An initial // connect failure lands in `.catch` instead (and is surfaced through the - // parallel readSession error), so a too-old/absent runtime never spins here. - const scheduleReconnect = (): void => { + // initial error frame), so a too-old/absent runtime never spins here. + const scheduleReconnect = (attempt: number): void => { + if (attempt !== activeAttempt) { + return + } handleUnsubscribe = null + reconnectPendingAttempt = attempt if (cancelled || reconnectTimer) { return } reconnectTimer = setTimeout(() => { reconnectTimer = null + reconnectPendingAttempt = null if (!cancelled) { openStream() } @@ -89,48 +97,121 @@ function createRuntimeNativeChatTransport(environmentId: string): NativeChatSess } const openStream = (): void => { + const attempt = ++activeAttempt void window.api.runtimeEnvironments .subscribe( { selector: environmentId, method: 'nativeChat.subscribe', - params: { subscriptionId, agent, sessionId, transcriptPath }, + params: { subscriptionId, agent, sessionId, transcriptPath, limit }, timeoutMs: 15_000 }, { onResponse: (response) => { - if (cancelled) { + if (cancelled || attempt !== activeAttempt) { return } if (response.ok === false) { + if (!receivedInitial) { + receivedInitial = true + onFrame({ + type: 'snapshot', + messages: [], + hasMore: false, + error: toRuntimeNativeChatErrorMessage(new RuntimeRpcCallError(response)) + }) + } else { + handleUnsubscribe?.() + scheduleReconnect(attempt) + } return } const frame = response.result as { type?: string messages?: NativeChatAppendedMessages + hasMore?: boolean + error?: string } - if (frame?.type === 'appended' && Array.isArray(frame.messages)) { - onAppended(frame.messages) + if ( + (frame?.type === 'appended' || + frame?.type === 'snapshot' || + frame?.type === 'replacement') && + Array.isArray(frame.messages) + ) { + if (!receivedInitial) { + receivedInitial = true + onFrame({ + type: 'snapshot', + messages: frame.messages, + hasMore: frame.hasMore ?? frame.messages.length >= (limit ?? 300), + ...(frame.error ? { error: frame.error } : {}) + }) + } else if (frame.type === 'snapshot') { + onFrame({ + type: 'snapshot', + messages: frame.messages, + hasMore: frame.hasMore ?? false, + ...(frame.error ? { error: frame.error } : {}) + }) + } else { + onFrame( + frame.type === 'replacement' + ? { + type: 'replacement', + messages: frame.messages, + hasMore: frame.hasMore ?? false + } + : { type: 'appended', messages: frame.messages } + ) + } + } else if (!receivedInitial) { + // Why: an ok response whose payload shape we don't recognize + // would otherwise never flip receivedInitial, stranding the view + // on 'loading'. Settle it with an empty snapshot (carrying any + // error the runtime sent) so the UI resolves. + receivedInitial = true + onFrame({ + type: 'snapshot', + messages: [], + hasMore: false, + ...(frame?.error ? { error: frame.error } : {}) + }) } }, // Established-then-dropped: resume the tail. onClose also fires on our // own teardown, but `cancelled` short-circuits the reconnect there. - onError: scheduleReconnect, - onClose: scheduleReconnect + onError: () => scheduleReconnect(attempt), + onClose: () => scheduleReconnect(attempt) } ) .then((handle) => { - handleUnsubscribe = handle.unsubscribe - // The stream resolved after teardown already ran — close it now so the - // late-arriving handle doesn't leak (same race as runtime-file-client). - if (cancelled) { + // Why: reconnect attempts may resolve out of order. A stale handle + // must never replace or tear down the current stream. + if (cancelled || attempt !== activeAttempt || reconnectPendingAttempt === attempt) { handle.unsubscribe() - handleUnsubscribe = null + return } + handleUnsubscribe = handle.unsubscribe }) - .catch(() => { + .catch((err: unknown) => { + if (cancelled || attempt !== activeAttempt) { + return + } // Initial subscribe failed (e.g. a too-old runtime lacking the method). - // The parallel readSession surfaces the error; don't reconnect-spin. + // Surface the same compatibility-specific copy as a direct read. + if (!receivedInitial) { + receivedInitial = true + onFrame({ + type: 'snapshot', + messages: [], + hasMore: false, + error: toRuntimeNativeChatErrorMessage(err) + }) + return + } + // Why: a failed reconnect is still a transient dropped-stream state; + // keep retrying after the backoff instead of stranding a live view. + scheduleReconnect(attempt) }) } @@ -147,6 +228,7 @@ function createRuntimeNativeChatTransport(environmentId: string): NativeChatSess clearTimeout(reconnectTimer) reconnectTimer = null } + reconnectPendingAttempt = null handleUnsubscribe?.() handleUnsubscribe = null } diff --git a/src/renderer/src/components/native-chat/native-chat-tool-fold.ts b/src/renderer/src/components/native-chat/native-chat-tool-fold.ts index f49370f415b..e18bf8f4764 100644 --- a/src/renderer/src/components/native-chat/native-chat-tool-fold.ts +++ b/src/renderer/src/components/native-chat/native-chat-tool-fold.ts @@ -1,58 +1,6 @@ -// Pure folding logic for the native chat tool runs. Claude emits each tool call -// as its own assistant message and each result as a tool-role message; folding -// every tool-only message into the preceding assistant turn lets the view -// collapse a whole turn's tool activity under one "N tool calls" line. Kept out -// of the .tsx so the merge/split rules are unit-testable without rendering. -// Ported from the mobile foldToolMessages/splitNativeChatBlocks (mobile parity). - -import { - isToolCallBlock, - isToolResultBlock, - type NativeChatBlock, - type NativeChatMessage -} from '../../../../shared/native-chat-types' - -/** True when a message carries nothing but tool calls/results — the shape Claude - * emits for the tool half of a turn. */ -function isToolOnlyMessage(message: NativeChatMessage): boolean { - return ( - message.blocks.length > 0 && - message.blocks.every((block) => isToolCallBlock(block) || isToolResultBlock(block)) - ) -} - -/** Fold a turn's tool activity into the assistant message it belongs to, so the - * view can collapse a whole turn's tools under one line. A tool-only message is - * merged into the preceding assistant turn when one exists; otherwise it stands - * on its own (e.g. an orphan result with no preceding assistant prose). */ -export function foldToolMessages(messages: readonly NativeChatMessage[]): NativeChatMessage[] { - const out: NativeChatMessage[] = [] - for (const message of messages) { - const prev = out.at(-1) - if (isToolOnlyMessage(message) && prev && prev.role === 'assistant') { - out[out.length - 1] = { ...prev, blocks: [...prev.blocks, ...message.blocks] } - } else { - out.push(message) - } - } - return out -} - -/** Split a message's blocks into prose (text/image) and tool (call/result), so - * the view renders the agent's words first and folds the tool activity into a - * separate collapsible run beneath it. */ -export function splitNativeChatBlocks(blocks: readonly NativeChatBlock[]): { - prose: NativeChatBlock[] - tools: NativeChatBlock[] -} { - const prose: NativeChatBlock[] = [] - const tools: NativeChatBlock[] = [] - for (const block of blocks) { - if (isToolCallBlock(block) || isToolResultBlock(block)) { - tools.push(block) - } else { - prose.push(block) - } - } - return { prose, tools } -} +export { + foldToolMessages, + pairToolBlocks, + splitNativeChatBlocks, + type NativeChatToolPair +} from '../../../../shared/native-chat-tool-fold' diff --git a/src/renderer/src/components/native-chat/native-chat-tool-summary.ts b/src/renderer/src/components/native-chat/native-chat-tool-summary.ts index d79ab1f43c9..2122c1fb7da 100644 --- a/src/renderer/src/components/native-chat/native-chat-tool-summary.ts +++ b/src/renderer/src/components/native-chat/native-chat-tool-summary.ts @@ -1,101 +1,8 @@ -// Why: tool-call inputs are arbitrary JSON; the chat row only shows a one-line -// preview so a long file body or diff doesn't dominate the conversation. Kept -// pure so the truncation/serialization rules are unit-testable. Ported from the -// mobile summarizeToolInput/summarizeToolRun (desktop parity). - -import { basename } from '../../lib/path' -import { isToolCallBlock, type NativeChatBlock } from '../../../../shared/native-chat-types' - -const MAX_PREVIEW_LENGTH = 80 - -/** One-line, length-capped preview of a tool-call input payload. Strings pass - * through; objects/arrays serialize to compact JSON; everything collapses - * whitespace and truncates with an ellipsis. Returns '' when there's nothing - * worth showing. */ -export function summarizeToolInput(input: unknown): string { - const raw = toRawPreview(input) - const collapsed = raw.replace(/\s+/g, ' ').trim() - if (collapsed.length <= MAX_PREVIEW_LENGTH) { - return collapsed - } - return `${collapsed.slice(0, MAX_PREVIEW_LENGTH - 1)}…` -} - -/** Full, pretty-printed tool-call input for the expanded detail view. Strings - * pass through as-is; objects/arrays print as indented JSON so a diff-less call - * (e.g. a question payload) reads cleanly instead of one long minified line. */ -export function formatToolInput(input: unknown): string { - if (input === null || input === undefined) { - return '' - } - if (typeof input === 'string') { - return input - } - if (typeof input === 'number' || typeof input === 'boolean') { - return String(input) - } - try { - return JSON.stringify(input, null, 2) ?? '' - } catch { - return '' - } -} - -/** A very short hint for a tool call in a one-line run summary: the target file's - * basename when present, else a clipped preview of the input. */ -export function briefToolArg(input: unknown): string { - if (input && typeof input === 'object') { - const obj = input as Record - const path = obj.file_path ?? obj.path ?? obj.notebook_path - if (typeof path === 'string' && path.length > 0) { - return basename(path) - } - const cmd = obj.command ?? obj.cmd ?? obj.query ?? obj.pattern - if (typeof cmd === 'string') { - return summarizeToolInput(cmd).slice(0, 28) - } - } - return summarizeToolInput(input).slice(0, 28) -} - -/** One-line summary of a run of tool calls: "Bash git status · Edit app.tsx · …". - * Only tool-call blocks contribute names; results are skipped. */ -export function summarizeToolRun(blocks: readonly NativeChatBlock[]): string { - const parts: string[] = [] - for (const block of blocks) { - if (!isToolCallBlock(block)) { - continue - } - const name = block.name.trim() - // Skip nameless tool calls so the join can't produce orphan separators - // ("Tool · ·"). - if (!name) { - continue - } - const brief = briefToolArg(block.input) - parts.push(brief ? `${name} ${brief}` : name) - } - return parts.join(' · ') -} - -/** Count the tool calls in a run (used for the "N tool calls" fold label). */ -export function countToolCalls(blocks: readonly NativeChatBlock[]): number { - return blocks.filter(isToolCallBlock).length -} - -function toRawPreview(input: unknown): string { - if (input === null || input === undefined) { - return '' - } - if (typeof input === 'string') { - return input - } - if (typeof input === 'number' || typeof input === 'boolean') { - return String(input) - } - try { - return JSON.stringify(input) ?? '' - } catch { - return '' - } -} +export { + briefToolArg, + countToolCalls, + formatToolInput, + summarizeToolInput, + summarizeToolRun, + toolFilePath +} from '../../../../shared/native-chat-tool-summary' diff --git a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts index e7c70e120cf..cb49cd9c57d 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts @@ -2,6 +2,7 @@ import { useCallback, useLayoutEffect, useRef } from 'react' import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection' import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' import type { AgentType } from '../../../../shared/native-chat-types' +import { shouldStepNativeChatAskAnswer } from '../../../../shared/native-chat-agent-support' import { buildAskAnswerKeys, formatAskAnswer, @@ -80,10 +81,11 @@ export function useNativeChatInteractiveSend( // highlighted option, not a pasted label, so answer it with per-option // keystrokes (by option number), paced so each step renders before the next. // Other agents' question tools commit a pasted answer, so send label text. - const handle: NativeChatSendHandle = - agent === 'claude' - ? sendNativeChatAskAnswer(settings, targetPtyId, buildAskAnswerKeys(prompt, selections)) - : sendNativeChatMessage(settings, targetPtyId, formatAskAnswer(prompt, selections)) + // Gate on the transcript agent (not `=== 'claude'`) so OpenClaude — which + // runs the same selector — takes the keystroke path too. + const handle: NativeChatSendHandle = shouldStepNativeChatAskAnswer(agent) + ? sendNativeChatAskAnswer(settings, targetPtyId, buildAskAnswerKeys(prompt, selections)) + : sendNativeChatMessage(settings, targetPtyId, formatAskAnswer(prompt, selections)) inFlightRef.current = handle return handle.settleAfterMs }, diff --git a/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts b/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts index f60ce34d3c8..89ae267c8e5 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts @@ -15,16 +15,31 @@ const { transportFactory, getMockTransport, resetMockTransports } = vi.hoisted(( readSession: ReturnType subscribe: ReturnType unsubscribe: ReturnType + emit: (frame: unknown) => void } const transports = new Map() - const getMockTransport = (ownerId: string | null): MockTransport => { + // autoSnapshot mirrors a watchable transcript's immediate first frame; pass + // false to model a not-yet-flushed transcript whose server-side resolve poll + // has nothing to emit yet (#8401) — only readSession drives the view then. + const getMockTransport = ( + ownerId: string | null, + opts?: { autoSnapshot?: boolean } + ): MockTransport => { let transport = transports.get(ownerId) if (!transport) { const unsubscribe = vi.fn() + let listener: (frame: unknown) => void = () => {} transport = { unsubscribe, readSession: vi.fn().mockResolvedValue({ messages: [] }), - subscribe: vi.fn(() => unsubscribe) + subscribe: vi.fn((_args, onFrame) => { + listener = onFrame + if (opts?.autoSnapshot !== false) { + onFrame({ type: 'snapshot', messages: [], hasMore: false }) + } + return unsubscribe + }), + emit: (frame) => listener(frame) } transports.set(ownerId, transport) } @@ -74,16 +89,42 @@ describe('mergeNativeChatLiveSession', () => { expect(session.messages).toHaveLength(1) }) - it("clears 'working' once the assistant message flushes to the transcript", () => { + it("keeps 'working' authoritative when a prior assistant message is present", () => { const session = mergeNativeChatLiveSession({ sources: { transcript: [user('u-1', 'do a thing'), assistant('a-1', 'done')] }, sessionId: 'sess', agent: 'claude', hookState: 'working' }) + expect(session.status).toBe('working') + }) + + it("self-heals a stale 'working' once this turn's assistant reply lands", () => { + // Trailing assistant reply (ts 2) post-dates the working turn's start (ts 1), + // so a dropped/late Stop hook must not strand 'working' on a visible reply. + const session = mergeNativeChatLiveSession({ + sources: { transcript: [user('u-1', 'go'), assistant('a-1', 'done')] }, + sessionId: 'sess', + agent: 'claude', + hookState: 'working', + stateStartedAt: 1 + }) expect(session.status).toBe('ready') }) + it("keeps 'working' when the trailing assistant reply predates the working turn", () => { + // Trailing reply (ts 2) is older than the new working turn (started at ts 5), + // so the agent is working again — the stale reply must not suppress 'working'. + const session = mergeNativeChatLiveSession({ + sources: { transcript: [user('u-1', 'go'), assistant('a-1', 'prior')] }, + sessionId: 'sess', + agent: 'claude', + hookState: 'working', + stateStartedAt: 5 + }) + expect(session.status).toBe('working') + }) + it('leaves completed states (done/waiting/blocked) on the derived status', () => { const session = mergeNativeChatLiveSession({ sources: { transcript: [user('u-1', 'hi')] }, @@ -177,17 +218,14 @@ describe('useNativeChatLiveSession — transport routing', () => { resetMockTransports() }) - it('routes the initial read and subscribe through the runtime transport', async () => { + it('seeds from readSession alongside the subscription for the initial runtime load', async () => { await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) expect(transportFactory).toHaveBeenCalledWith('env-1') const transport = getMockTransport('env-1') - expect(transport.readSession).toHaveBeenCalledWith( - AGENT, - SESSION, - expect.any(Number), - undefined - ) + // The independent seed guards against a stuck 'loading' when the stream never + // delivers a snapshot; a live snapshot still wins when it does (see below). + expect(transport.readSession).toHaveBeenCalledOnce() expect(transport.subscribe).toHaveBeenCalledOnce() }) @@ -228,11 +266,16 @@ describe('useNativeChatLiveSession — transport routing', () => { expect(transport.unsubscribe).toHaveBeenCalledOnce() }) - it('surfaces a runtime read error in the error phase (R4 end-to-end)', async () => { - getMockTransport('env-1').readSession.mockResolvedValueOnce({ error: 'runtime too old' }) - + it('surfaces a runtime snapshot error in the error phase (R4 end-to-end)', async () => { await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) - await flush() + await act(async () => { + getMockTransport('env-1').emit({ + type: 'snapshot', + messages: [], + hasMore: false, + error: 'runtime too old' + }) + }) expect(latest?.status).toBe('error') expect(latest?.error).toBe('runtime too old') @@ -261,11 +304,6 @@ describe('useNativeChatLiveSession — transport routing', () => { assistant(`m-${n}`, 't') ) const first = getMockTransport('env-1') - first.readSession.mockResolvedValueOnce({ messages: many }) - let resolveEarlier: (result: { messages: NativeChatMessage[] }) => void = () => {} - first.readSession.mockImplementationOnce( - () => new Promise((resolve) => (resolveEarlier = resolve)) - ) const root = await render({ paneKey: PANE, @@ -273,6 +311,14 @@ describe('useNativeChatLiveSession — transport routing', () => { sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) + await act(async () => { + first.emit({ type: 'snapshot', messages: many, hasMore: true }) + }) + // Arm the pending read for load-earlier only; the initial seed already ran. + let resolveEarlier: (result: { messages: NativeChatMessage[] }) => void = () => {} + first.readSession.mockImplementationOnce( + () => new Promise((resolve) => (resolveEarlier = resolve)) + ) // Kick off load-earlier against env-1, then flip the owner before it resolves. await act(async () => { latest?.loadEarlier() @@ -292,6 +338,162 @@ describe('useNativeChatLiveSession — transport routing', () => { expect(latest?.messages.map((m) => m.id)).not.toContain('stale') }) + + it('discards a load-earlier resolve from before transcript replacement', async () => { + const transport = getMockTransport('env-1') + const many = Array.from({ length: NATIVE_CHAT_INITIAL_LIMIT }, (_unused, n) => + assistant(`old-${n}`, 'old') + ) + await render({ + paneKey: PANE, + agent: AGENT, + sessionId: SESSION, + runtimeEnvironmentId: 'env-1' + }) + await act(async () => transport.emit({ type: 'snapshot', messages: many, hasMore: true })) + // Arm the pending read for load-earlier only; the initial seed already ran. + let resolveEarlier: (result: { messages: NativeChatMessage[] }) => void = () => {} + transport.readSession.mockImplementationOnce( + () => new Promise((resolve) => (resolveEarlier = resolve)) + ) + await act(async () => latest?.loadEarlier()) + + await act(async () => + transport.emit({ + type: 'replacement', + messages: [assistant('replacement', 'new inode')], + hasMore: false + }) + ) + await act(async () => { + resolveEarlier({ messages: [assistant('stale-page', 'old inode')] }) + await Promise.resolve() + }) + + expect(latest?.messages.map((message) => message.id)).toEqual(['replacement']) + }) + + it('discards a load-earlier resolve from before a reconnect snapshot', async () => { + const transport = getMockTransport('env-1') + const many = Array.from({ length: NATIVE_CHAT_INITIAL_LIMIT }, (_unused, n) => + assistant(`old-${n}`, 'old') + ) + await render({ + paneKey: PANE, + agent: AGENT, + sessionId: SESSION, + runtimeEnvironmentId: 'env-1' + }) + await act(async () => transport.emit({ type: 'snapshot', messages: many, hasMore: true })) + // Arm the pending read for load-earlier only; the initial seed already ran. + let resolveEarlier: (result: { messages: NativeChatMessage[] }) => void = () => {} + transport.readSession.mockImplementationOnce( + () => new Promise((resolve) => (resolveEarlier = resolve)) + ) + await act(async () => latest?.loadEarlier()) + + await act(async () => + transport.emit({ + type: 'snapshot', + messages: [assistant('reconnected', 'fresh snapshot')], + hasMore: false + }) + ) + await act(async () => { + resolveEarlier({ messages: [assistant('stale-page', 'old generation')] }) + await Promise.resolve() + }) + + expect(latest?.messages.map((message) => message.id)).toEqual(['reconnected']) + }) + + it('discards a load-earlier resolve from before a transcript-path rebind', async () => { + const transport = getMockTransport('env-1') + const many = Array.from({ length: NATIVE_CHAT_INITIAL_LIMIT }, (_unused, n) => + assistant(`old-path-${n}`, 'old') + ) + const root = await render({ + paneKey: PANE, + agent: AGENT, + sessionId: SESSION, + transcriptPath: '/old/transcript', + runtimeEnvironmentId: 'env-1' + }) + await act(async () => transport.emit({ type: 'snapshot', messages: many, hasMore: true })) + // Arm the pending read for load-earlier only; the initial seed already ran. + let resolveEarlier: (result: { messages: NativeChatMessage[] }) => void = () => {} + transport.readSession.mockImplementationOnce( + () => new Promise((resolve) => (resolveEarlier = resolve)) + ) + await act(async () => latest?.loadEarlier()) + + await rerender(root, { + paneKey: PANE, + agent: AGENT, + sessionId: SESSION, + transcriptPath: '/new/transcript', + runtimeEnvironmentId: 'env-1' + }) + await act(async () => { + resolveEarlier({ messages: [assistant('stale-old-path', 'old transcript')] }) + await Promise.resolve() + }) + + expect(latest?.messages.map((message) => message.id)).not.toContain('stale-old-path') + }) + + it('seeds ready from readSession when the subscription never delivers a frame', async () => { + const transport = getMockTransport('env-1') + // Older runtime: the stream wires only appends and stays silent for an empty + // tail, so no snapshot arrives — the independent seed must settle the view. + transport.subscribe.mockImplementation(() => transport.unsubscribe) + transport.readSession.mockResolvedValueOnce({ messages: [user('u-seed', 'hi')] }) + + await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) + + expect(latest?.status).not.toBe('loading') + expect(latest?.messages.map((message) => message.id)).toEqual(['u-seed']) + }) + + it('keeps the live snapshot when a readSession seed resolves after it', async () => { + const transport = getMockTransport('env-1') + // The seed resolves only after the authoritative snapshot has already landed. + let resolveSeed: (result: { messages: NativeChatMessage[] }) => void = () => {} + transport.readSession.mockImplementationOnce( + () => new Promise((resolve) => (resolveSeed = resolve)) + ) + + await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) + await act(async () => + transport.emit({ type: 'snapshot', messages: [user('u-live', 'live')], hasMore: false }) + ) + await act(async () => { + resolveSeed({ messages: [user('u-stale', 'stale')] }) + await Promise.resolve() + }) + + expect(latest?.messages.map((message) => message.id)).toEqual(['u-live']) + }) + + it("self-heals a stale 'working' hook once this turn's reply has landed", async () => { + // The hook must read stateStartedAt from the store and thread it into the + // merge, so a dropped Stop hook doesn't strand 'Agent is working'. + useAppStore.setState({ + agentStatusByPaneKey: { [PANE]: { state: 'working', stateStartedAt: 1 } as never } + }) + const transport = getMockTransport('env-1') + await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) + await act(async () => + transport.emit({ + type: 'snapshot', + messages: [user('u-1', 'go'), assistant('a-1', 'done')], + hasMore: false + }) + ) + + // The reply (ts 2) post-dates stateStartedAt (1): the stuck 'working' heals. + expect(latest?.status).toBe('ready') + }) }) // Regression for #8401: a just-created Claude Code session's transcript can @@ -338,7 +540,7 @@ describe('useNativeChatLiveSession — notFound retry (#8401)', () => { it('retries a notFound miss with backoff and settles into ready without ever exposing an error', async () => { vi.useFakeTimers() - const transport = getMockTransport('env-1') + const transport = getMockTransport('env-1', { autoSnapshot: false }) transport.readSession .mockResolvedValueOnce({ error: 'No transcript found', notFound: true }) .mockResolvedValueOnce({ messages: [assistant('a-1', 'hello')] }) @@ -358,7 +560,7 @@ describe('useNativeChatLiveSession — notFound retry (#8401)', () => { it('surfaces an error once the ~60s retry window is exhausted', async () => { vi.useFakeTimers() - const transport = getMockTransport('env-1') + const transport = getMockTransport('env-1', { autoSnapshot: false }) transport.readSession.mockResolvedValue({ error: 'No transcript found', notFound: true }) await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) @@ -374,22 +576,15 @@ describe('useNativeChatLiveSession — notFound retry (#8401)', () => { it('renders live-appended content instead of loading while the read is still retrying', async () => { vi.useFakeTimers() - const transport = getMockTransport('env-1') + const transport = getMockTransport('env-1', { autoSnapshot: false }) transport.readSession.mockResolvedValue({ error: 'No transcript found', notFound: true }) - let onAppended: ((messages: NativeChatMessage[]) => void) | null = null - transport.subscribe.mockImplementationOnce( - (_args: unknown, cb: (m: NativeChatMessage[]) => void) => { - onAppended = cb - return transport.unsubscribe - } - ) await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) expect(latest?.status).toBe('loading') // The watcher's first drain lands mid-retry — content must win over the spinner. await act(async () => { - onAppended?.([assistant('a-early', 'landed during retry')]) + transport.emit({ type: 'appended', messages: [assistant('a-early', 'landed during retry')] }) }) expect(latest?.status).not.toBe('loading') @@ -397,21 +592,14 @@ describe('useNativeChatLiveSession — notFound retry (#8401)', () => { }) it('renders live-appended content even when the initial read settled into a permanent error', async () => { - const transport = getMockTransport('env-1') + const transport = getMockTransport('env-1', { autoSnapshot: false }) transport.readSession.mockResolvedValueOnce({ error: 'unreadable transcript' }) - let onAppended: ((messages: NativeChatMessage[]) => void) | null = null - transport.subscribe.mockImplementationOnce( - (_args: unknown, cb: (m: NativeChatMessage[]) => void) => { - onAppended = cb - return transport.unsubscribe - } - ) await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) expect(latest?.status).toBe('error') await act(async () => { - onAppended?.([assistant('a-late', 'landed late')]) + transport.emit({ type: 'appended', messages: [assistant('a-late', 'landed late')] }) }) expect(latest?.status).not.toBe('error') diff --git a/src/renderer/src/components/native-chat/use-native-chat-live-session.ts b/src/renderer/src/components/native-chat/use-native-chat-live-session.ts index 384ae724769..ec1ebbc8226 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-live-session.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-live-session.ts @@ -8,7 +8,6 @@ import { } from '../../../../shared/native-chat-types' import { applyAppend, - boundNativeChatWindow, createNativeChatMerger, replaceList } from '../../../../shared/native-chat-merge' @@ -130,7 +129,7 @@ export function useNativeChatLiveSession( // The active read window; raised by loadEarlier to page in older history. const limitRef = useRef(NATIVE_CHAT_INITIAL_LIMIT) - // Appended messages accumulate separately from the initial read so a re-read + // Appended messages accumulate separately from the initial snapshot so pagination // (session change or load-earlier) doesn't lose in-flight appends mid-swap; // they reset with the same effect that re-subscribes. Live frames merge by id // (re-emitted ids replace in place, no unbounded concat) and the bucket is @@ -143,6 +142,11 @@ export function useNativeChatLiveSession( // Live hook state for this pane, selected narrowly so unrelated status churn // doesn't re-render the chat view. const hookState = useAppStore((s) => s.agentStatusByPaneKey[paneKey]?.state ?? null) + // When that state began (epoch ms). A separate primitive selector so it doesn't + // churn renders; lets a stale 'working' self-heal once this turn's reply lands. + const hookStateStartedAt = useAppStore( + (s) => s.agentStatusByPaneKey[paneKey]?.stateStartedAt ?? null + ) const latestSessionId = useRef(sessionId) latestSessionId.current = sessionId @@ -150,6 +154,7 @@ export function useNativeChatLiveSession( // host is discarded after an owner flip (the session id can stay the same). const latestTransport = useRef(transport) latestTransport.current = transport + const transcriptEpochRef = useRef(0) // Incremental assembler: reset on the base axis (session/agent/read swap), // applyAppends on the hot append axis. `appliedTranscriptRef` is the exact @@ -161,6 +166,10 @@ export function useNativeChatLiveSession( const baseMessagesRef = useRef(EMPTY_MESSAGES) useEffect(() => { + // Why: agent/path/owner rebinds can keep the same session and transport; + // every source generation must invalidate pagination captured before it. + transcriptEpochRef.current += 1 + setLoadingEarlier(false) if (!sessionId) { // No session id yet: nothing to read or tail. Surface live hook state on // an empty transcript; backfills once the id arrives (effect re-runs). @@ -172,6 +181,9 @@ export function useNativeChatLiveSession( } let cancelled = false + // Set by the first authoritative snapshot/replacement frame so the + // independent readSession seed below can never clobber a live snapshot. + let frameArrived = false let retryTimer: ReturnType | null = null const retryStartedAt = Date.now() // Re-bound as a plain const: TS doesn't retain the `!sessionId` narrowing @@ -184,11 +196,19 @@ export function useNativeChatLiveSession( setAppended([]) setHasMore(false) + // Independent initial seed: the subscribe stream normally delivers the first + // snapshot, but a persistent initial-drain error, or an older runtime whose + // subscribe only wires onAppend, would otherwise strand the view at 'loading' + // forever. Apply this only while no authoritative frame has landed yet, so a + // live snapshot always wins and a late seed never repaints it. function loadSession(attempt: number): void { + if (frameArrived) { + return + } void transport .readSession(agent, activeSessionId, limitRef.current, transcriptPath ?? undefined) .then((result) => { - if (cancelled) { + if (cancelled || frameArrived) { return } if (result && 'error' in result) { @@ -209,7 +229,7 @@ export function useNativeChatLiveSession( setHasMore(hasMoreNativeChatHistory(messages.length, limitRef.current)) }) .catch((err: unknown) => { - if (!cancelled) { + if (!cancelled && !frameArrived) { setRead({ phase: 'error', error: err instanceof Error ? err.message : String(err) }) } }) @@ -219,19 +239,36 @@ export function useNativeChatLiveSession( const subscriptionId = nextSubscriptionId() const unsubscribe = transport.subscribe( - { subscriptionId, agent, sessionId, transcriptPath: transcriptPath ?? undefined }, - (messages) => { + { + subscriptionId, + agent, + sessionId, + transcriptPath: transcriptPath ?? undefined, + limit: limitRef.current + }, + (frame) => { if (!cancelled) { + if (frame.type === 'snapshot' || frame.type === 'replacement') { + // Why: reconnect snapshots and inode replacements are both + // authoritative generations; older pagination must not repaint them. + frameArrived = true + transcriptEpochRef.current += 1 + setLoadingEarlier(false) + if ('error' in frame && frame.error) { + setRead({ phase: 'error', error: frame.error }) + return + } + replaceList(appendMergerRef.current, frame.messages) + setAppended([]) + setRead({ phase: 'ready', messages: appendMergerRef.current.list }) + setHasMore(frame.hasMore) + return + } // Merge by id (re-emits replace in place) then bound to the window so // the bucket can't grow without limit. The base read still holds older // turns, and the assembler re-dedups the concat, so trimming the recent // append tail can't drop a turn the base window still covers (#6). - const merged = applyAppend(appendMergerRef.current, messages) - const bounded = boundNativeChatWindow(merged, limitRef.current) - if (bounded !== merged) { - replaceList(appendMergerRef.current, bounded) - } - setAppended(appendMergerRef.current.list) + setAppended(applyAppend(appendMergerRef.current, frame.messages, limitRef.current)) } } ) @@ -266,13 +303,18 @@ export function useNativeChatLiveSession( return } const nextLimit = nextNativeChatLimit(limitRef.current) + const requestEpoch = transcriptEpochRef.current setLoadingEarlier(true) void transport .readSession(agent, sessionId, nextLimit, transcriptPath ?? undefined) .then((result) => { // Ignore a stale resolve from a session that swapped OR an owner that // flipped underneath us — either would paint the wrong host's history. - if (latestSessionId.current !== sessionId || latestTransport.current !== transport) { + if ( + latestSessionId.current !== sessionId || + latestTransport.current !== transport || + transcriptEpochRef.current !== requestEpoch + ) { return } if (!result || 'error' in result) { @@ -293,7 +335,9 @@ export function useNativeChatLiveSession( // Always clear the loading flag — even after a session swap — so a stale // resolve can't leave loadingEarlier stuck true on the new session. Only // APPLYING the result above is gated on the session-id match. - setLoadingEarlier(false) + if (transcriptEpochRef.current === requestEpoch) { + setLoadingEarlier(false) + } }) }, [agent, sessionId, transcriptPath, transport, hasMore, loadingEarlier, read.phase]) @@ -307,7 +351,7 @@ export function useNativeChatLiveSession( // Base axis: the read's message array reference changes on session swap and // loadEarlier; sessionId/agent identify the conversation. Any change forces a // full reset so a missed trigger can't leave the cache stale. - const baseSig = `${agent}${sessionId ?? ''}` + const baseSig = `${agent}\u0000${sessionId ?? ''}` const baseChanged = baseSig !== baseSigRef.current || baseMessages !== baseMessagesRef.current const applied = appliedTranscriptRef.current const isSuffixExtension = @@ -338,6 +382,7 @@ export function useNativeChatLiveSession( sessionId, agent, hookState, + stateStartedAt: hookStateStartedAt, // Why: a watcher append (fix for #8401) can land content while the read is // still retrying ('loading') or after it settled into 'error' — in both // cases showing the live content beats a spinner or a stale error, so each @@ -352,6 +397,7 @@ export function useNativeChatLiveSession( sessionId, agent, hookState, + hookStateStartedAt, hasMore, loadingEarlier, loadEarlier, diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx index c651aabada3..f7ffb83bfd6 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx @@ -20,12 +20,7 @@ import { getRestorableAiVaultScope, normalizeAiVaultScopeForContext } from './ai-vault-scope-state' -import { - countAiVaultViewAdjustments, - DEFAULT_AI_VAULT_GROUP, - DEFAULT_AI_VAULT_HIDE_EMPTY_SESSIONS, - DEFAULT_AI_VAULT_SORT -} from './ai-vault-view-defaults' +import { countAiVaultViewAdjustments } from './ai-vault-view-defaults' import { buildAiVaultProjectContext } from './ai-vault-session-projects' import { resolveAiVaultSessionResumeActions, @@ -35,14 +30,7 @@ import { useAiVaultSessionLaunchActions } from './ai-vault-session-launch-action import { useAiVaultSessionWorktreeMap } from './ai-vault-session-worktree' import { openAiVaultSessionLogInOrca } from './ai-vault-session-log-open' import { useAiVaultOriginalPaneActions } from './ai-vault-original-pane-actions' -import { - AI_VAULT_AGENTS, - type AiVaultAgent, - type AiVaultGroup, - type AiVaultScope, - type AiVaultSession, - type AiVaultSort -} from '../../../../shared/ai-vault-types' +import type { AiVaultScope, AiVaultSession } from '../../../../shared/ai-vault-types' import { translate } from '@/i18n/i18n' import { AiVaultPanelHeader } from './AiVaultPanelHeader' import { AiVaultSessionVirtualList } from './AiVaultSessionVirtualList' @@ -52,6 +40,7 @@ import { buildRuntimeAiVaultHostScopeOptions, useAiVaultExecutionHostScope } from './ai-vault-host-scope' +import { usePersistedAiVaultViewOptions } from './use-persisted-ai-vault-view-options' export default function AiVaultPanel(): React.JSX.Element { const activeWorktreeId = useActiveWorktreeId() @@ -74,11 +63,19 @@ export default function AiVaultPanel(): React.JSX.Element { const { getOriginalPaneTarget, getSessionLiveState, jumpToOriginalPane, jumpToWorktree } = useAiVaultOriginalPaneActions() const [query, setQuery] = useState('') + // Why: scope depends on current workspace/project availability, so only stable view options persist. const [scope, setScope] = useState(DEFAULT_AI_VAULT_SCOPE) - const [sort, setSort] = useState(DEFAULT_AI_VAULT_SORT) - const [group, setGroup] = useState(DEFAULT_AI_VAULT_GROUP) - const [hideEmptySessions, setHideEmptySessions] = useState(DEFAULT_AI_VAULT_HIDE_EMPTY_SESSIONS) - const [agents, setAgents] = useState([...AI_VAULT_AGENTS]) + const { + agents, + sort, + group, + hideEmptySessions, + setSort, + setGroup, + setHideEmptySessions, + setAgentEnabled, + resetViewOptions + } = usePersistedAiVaultViewOptions() const [collapsedGroups, setCollapsedGroups] = useState>(() => new Set()) const userChangedScopeRef = useRef(false) const preferredScopeRef = useRef(DEFAULT_AI_VAULT_SCOPE) @@ -281,23 +278,6 @@ export default function AiVaultPanel(): React.JSX.Element { ] ) - const setAgentEnabled = useCallback((agent: AiVaultAgent, enabled: boolean) => { - setAgents((current) => { - if (enabled) { - return current.includes(agent) ? current : [...current, agent] - } - const next = current.filter((entry) => entry !== agent) - return next.length > 0 ? next : current - }) - }, []) - - const resetViewOptions = useCallback(() => { - setAgents([...AI_VAULT_AGENTS]) - setSort(DEFAULT_AI_VAULT_SORT) - setGroup(DEFAULT_AI_VAULT_GROUP) - setHideEmptySessions(DEFAULT_AI_VAULT_HIDE_EMPTY_SESSIONS) - }, []) - const handleScopeChange = useCallback((nextScope: AiVaultScope) => { preferredScopeRef.current = nextScope userChangedScopeRef.current = nextScope !== DEFAULT_AI_VAULT_SCOPE diff --git a/src/renderer/src/components/right-sidebar/ai-vault-view-options-persistence.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-view-options-persistence.test.ts new file mode 100644 index 00000000000..2ad8e260bdd --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-view-options-persistence.test.ts @@ -0,0 +1,143 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AI_VAULT_AGENTS } from '../../../../shared/ai-vault-types' +import { + AI_VAULT_VIEW_OPTIONS_STORAGE_KEY, + createDefaultAiVaultViewOptions, + enabledAiVaultAgents, + normalizeAiVaultViewOptions, + readAiVaultViewOptions, + writeAiVaultViewOptions +} from './ai-vault-view-options-persistence' + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('AI Vault view option persistence', () => { + it('uses the documented defaults when no stored value exists', () => { + const storage = { getItem: vi.fn(() => null), setItem: vi.fn() } + + expect(readAiVaultViewOptions(storage)).toEqual(createDefaultAiVaultViewOptions()) + }) + + it('normalizes malformed fields and removes unknown or duplicate agents', () => { + expect( + normalizeAiVaultViewOptions({ + disabledAgents: ['codex', 'unknown', 'codex', 7], + sort: 'invalid', + group: 'agent', + hideEmptySessions: 'yes' + }) + ).toEqual({ + disabledAgents: ['codex'], + sort: 'updated', + group: 'agent', + hideEmptySessions: false + }) + }) + + it('preserves every valid sort, group, and boolean value', () => { + expect( + normalizeAiVaultViewOptions({ + disabledAgents: [], + sort: 'updated', + group: 'project', + hideEmptySessions: false + }) + ).toEqual({ + disabledAgents: [], + sort: 'updated', + group: 'project', + hideEmptySessions: false + }) + expect( + normalizeAiVaultViewOptions({ + disabledAgents: [], + sort: 'created', + group: 'folder', + hideEmptySessions: true + }) + ).toEqual({ + disabledAgents: [], + sort: 'created', + group: 'folder', + hideEmptySessions: true + }) + expect(normalizeAiVaultViewOptions({ group: 'agent' }).group).toBe('agent') + }) + + it('falls back to all enabled when stored state disables the whole catalog', () => { + const normalized = normalizeAiVaultViewOptions({ + disabledAgents: [...AI_VAULT_AGENTS], + sort: 'created', + group: 'folder', + hideEmptySessions: true + }) + + expect(normalized.disabledAgents).toEqual([]) + expect(enabledAiVaultAgents(normalized.disabledAgents)).toEqual([...AI_VAULT_AGENTS]) + }) + + it('falls back safely when JSON or storage access is unavailable', () => { + const malformed = { getItem: vi.fn(() => '{not-json'), setItem: vi.fn() } + const unavailable = { + getItem: vi.fn(() => { + throw new Error('blocked') + }), + setItem: vi.fn() + } + + expect(readAiVaultViewOptions(malformed)).toEqual(createDefaultAiVaultViewOptions()) + expect(readAiVaultViewOptions(unavailable)).toEqual(createDefaultAiVaultViewOptions()) + expect(readAiVaultViewOptions(null)).toEqual(createDefaultAiVaultViewOptions()) + }) + + it('falls back when the renderer storage getter is blocked', () => { + const blockedWindow = {} + Object.defineProperty(blockedWindow, 'localStorage', { + get: () => { + throw new Error('blocked') + } + }) + vi.stubGlobal('window', blockedWindow) + + expect(readAiVaultViewOptions()).toEqual(createDefaultAiVaultViewOptions()) + expect(writeAiVaultViewOptions(createDefaultAiVaultViewOptions())).toBe(false) + }) + + it('writes a normalized, versioned per-client value', () => { + const storage = { getItem: vi.fn(() => null), setItem: vi.fn() } + + expect( + writeAiVaultViewOptions( + { + disabledAgents: ['codex'], + sort: 'created', + group: 'folder', + hideEmptySessions: true + }, + storage + ) + ).toBe(true) + expect(storage.setItem).toHaveBeenCalledWith( + AI_VAULT_VIEW_OPTIONS_STORAGE_KEY, + JSON.stringify({ + disabledAgents: ['codex'], + sort: 'created', + group: 'folder', + hideEmptySessions: true + }) + ) + }) + + it('reports failed storage writes without throwing', () => { + const storage = { + getItem: vi.fn(() => null), + setItem: vi.fn(() => { + throw new Error('quota exceeded') + }) + } + + expect(writeAiVaultViewOptions(createDefaultAiVaultViewOptions(), storage)).toBe(false) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-view-options-persistence.ts b/src/renderer/src/components/right-sidebar/ai-vault-view-options-persistence.ts new file mode 100644 index 00000000000..5b7a4b799cf --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-view-options-persistence.ts @@ -0,0 +1,114 @@ +import { + AI_VAULT_AGENTS, + type AiVaultAgent, + type AiVaultGroup, + type AiVaultSort +} from '../../../../shared/ai-vault-types' +import { + DEFAULT_AI_VAULT_GROUP, + DEFAULT_AI_VAULT_HIDE_EMPTY_SESSIONS, + DEFAULT_AI_VAULT_SORT +} from './ai-vault-view-defaults' + +export const AI_VAULT_VIEW_OPTIONS_STORAGE_KEY = 'orca.aiVault.viewOptions.v1' + +export type AiVaultViewOptions = { + disabledAgents: AiVaultAgent[] + sort: AiVaultSort + group: AiVaultGroup + hideEmptySessions: boolean +} + +type AiVaultViewOptionsStorage = { + getItem: (key: string) => string | null + setItem: (key: string, value: string) => void +} + +export function createDefaultAiVaultViewOptions(): AiVaultViewOptions { + return { + disabledAgents: [], + sort: DEFAULT_AI_VAULT_SORT, + group: DEFAULT_AI_VAULT_GROUP, + hideEmptySessions: DEFAULT_AI_VAULT_HIDE_EMPTY_SESSIONS + } +} + +export function enabledAiVaultAgents(disabledAgents: readonly AiVaultAgent[]): AiVaultAgent[] { + const disabled = new Set(disabledAgents) + return AI_VAULT_AGENTS.filter((agent) => !disabled.has(agent)) +} + +function isAiVaultSort(value: unknown): value is AiVaultSort { + return value === 'updated' || value === 'created' +} + +function isAiVaultGroup(value: unknown): value is AiVaultGroup { + return value === 'project' || value === 'folder' || value === 'agent' +} + +export function normalizeAiVaultViewOptions(value: unknown): AiVaultViewOptions { + const record = value && typeof value === 'object' ? (value as Record) : {} + const catalog = new Set(AI_VAULT_AGENTS) + const normalizedDisabledAgents = Array.isArray(record.disabledAgents) + ? [...new Set(record.disabledAgents)].filter( + (agent): agent is AiVaultAgent => typeof agent === 'string' && catalog.has(agent) + ) + : [] + // Why: a stale catalog or hand-edited value must not leave the panel with no selectable agents. + const disabledAgents = + normalizedDisabledAgents.length < AI_VAULT_AGENTS.length ? normalizedDisabledAgents : [] + + return { + disabledAgents, + sort: isAiVaultSort(record.sort) ? record.sort : DEFAULT_AI_VAULT_SORT, + group: isAiVaultGroup(record.group) ? record.group : DEFAULT_AI_VAULT_GROUP, + hideEmptySessions: + typeof record.hideEmptySessions === 'boolean' + ? record.hideEmptySessions + : DEFAULT_AI_VAULT_HIDE_EMPTY_SESSIONS + } +} + +function getRendererStorage(): AiVaultViewOptionsStorage | null { + if (typeof window === 'undefined') { + return null + } + try { + return window.localStorage + } catch { + return null + } +} + +export function readAiVaultViewOptions( + storage: AiVaultViewOptionsStorage | null = getRendererStorage() +): AiVaultViewOptions { + if (!storage) { + return createDefaultAiVaultViewOptions() + } + try { + const raw = storage.getItem(AI_VAULT_VIEW_OPTIONS_STORAGE_KEY) + return raw ? normalizeAiVaultViewOptions(JSON.parse(raw)) : createDefaultAiVaultViewOptions() + } catch { + return createDefaultAiVaultViewOptions() + } +} + +export function writeAiVaultViewOptions( + options: AiVaultViewOptions, + storage: AiVaultViewOptionsStorage | null = getRendererStorage() +): boolean { + if (!storage) { + return false + } + try { + // Why: view preferences are per client, so desktop and mobile must not overwrite each other. + storage.setItem( + AI_VAULT_VIEW_OPTIONS_STORAGE_KEY, + JSON.stringify(normalizeAiVaultViewOptions(options)) + ) + return true + } catch { + return false + } +} diff --git a/src/renderer/src/components/right-sidebar/coalesced-poll-runner.ts b/src/renderer/src/components/right-sidebar/coalesced-poll-runner.ts index 1040ed279e2..5c2c443997f 100644 --- a/src/renderer/src/components/right-sidebar/coalesced-poll-runner.ts +++ b/src/renderer/src/components/right-sidebar/coalesced-poll-runner.ts @@ -25,6 +25,17 @@ export type SlowTaskBackoffOptions = { // so a fixed idle gap makes polling effectively continuous (#7983). Scale the // gap with the previous run's duration — aggressively for evidence-free timer // ticks, mildly for change signals — capped so results never go minutes-stale. +// Shared with the git-status refresh scheduler so both pacing machines keep +// the same guarantee: sustained triggers can never run the task back-to-back. +export function slowTaskRequiredIdleMs( + lastRunDurationMs: number, + multiplier: number, + minIntervalMs: number, + maxIntervalMs: number +): number { + return Math.max(minIntervalMs, Math.min(lastRunDurationMs * multiplier, maxIntervalMs)) +} + export function createCoalescedPollRunner( task: () => Promise, options?: { @@ -49,7 +60,12 @@ export function createCoalescedPollRunner( const multiplier = trigger?.changeSignal ? backoff.changeSignalMultiplier : backoff.idleMultiplier - return Math.max(minIntervalMs, Math.min(lastRunDurationMs * multiplier, backoff.maxIntervalMs)) + return slowTaskRequiredIdleMs( + lastRunDurationMs, + multiplier, + minIntervalMs, + backoff.maxIntervalMs + ) } const clearScheduledRun = (): void => { diff --git a/src/renderer/src/components/right-sidebar/git-status-refresh-ordering.ts b/src/renderer/src/components/right-sidebar/git-status-refresh-ordering.ts new file mode 100644 index 00000000000..9f4c45e0c17 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/git-status-refresh-ordering.ts @@ -0,0 +1,100 @@ +const MAX_REFRESH_ORDERING_WORKTREES = 1024 +const strictUpstreamRefreshGenerationByWorktree = new Map() +const automaticRefreshGenerationByWorktree = new Map() +// Why: automatic ordering is latest-APPLIED-wins, not latest-started-wins. A +// later-started refresh that fails (or never applies) must not veto an earlier +// refresh's good result, so the fence only advances when a result is applied. +const lastAppliedAutomaticGenerationByWorktree = new Map() +const automaticUpstreamRefreshInFlightByWorktree = new Map() + +export type AutomaticRefreshOrder = { + strictGeneration: number + automaticGeneration: number +} + +function trimRefreshOrderingState(): void { + for (const worktreeId of strictUpstreamRefreshGenerationByWorktree.keys()) { + if (strictUpstreamRefreshGenerationByWorktree.size <= MAX_REFRESH_ORDERING_WORKTREES) { + break + } + if (automaticUpstreamRefreshInFlightByWorktree.has(worktreeId)) { + continue + } + strictUpstreamRefreshGenerationByWorktree.delete(worktreeId) + } +} + +export function beginAutomaticUpstreamRefresh(worktreeId: string): AutomaticRefreshOrder { + automaticUpstreamRefreshInFlightByWorktree.set( + worktreeId, + (automaticUpstreamRefreshInFlightByWorktree.get(worktreeId) ?? 0) + 1 + ) + const automaticGeneration = (automaticRefreshGenerationByWorktree.get(worktreeId) ?? 0) + 1 + automaticRefreshGenerationByWorktree.set(worktreeId, automaticGeneration) + return { + strictGeneration: strictUpstreamRefreshGenerationByWorktree.get(worktreeId) ?? 0, + automaticGeneration + } +} + +export function finishAutomaticUpstreamRefresh(worktreeId: string): void { + const count = automaticUpstreamRefreshInFlightByWorktree.get(worktreeId) ?? 0 + if (count <= 1) { + automaticUpstreamRefreshInFlightByWorktree.delete(worktreeId) + automaticRefreshGenerationByWorktree.delete(worktreeId) + lastAppliedAutomaticGenerationByWorktree.delete(worktreeId) + } else { + automaticUpstreamRefreshInFlightByWorktree.set(worktreeId, count - 1) + } + trimRefreshOrderingState() +} + +export function shouldApplyAutomaticUpstreamRefresh( + worktreeId: string, + order: AutomaticRefreshOrder, + shouldApply?: () => boolean +): boolean { + return ( + (strictUpstreamRefreshGenerationByWorktree.get(worktreeId) ?? 0) === order.strictGeneration && + order.automaticGeneration >= (lastAppliedAutomaticGenerationByWorktree.get(worktreeId) ?? 0) && + (shouldApply?.() ?? true) + ) +} + +/** + * Check-and-claim variant used immediately before applying state. On success + * it advances the applied fence so an older concurrent refresh that settles + * later cannot overwrite this refresh's newer result. + */ +export function claimAutomaticUpstreamRefreshApply( + worktreeId: string, + order: AutomaticRefreshOrder, + shouldApply?: () => boolean +): boolean { + if (!shouldApplyAutomaticUpstreamRefresh(worktreeId, order, shouldApply)) { + return false + } + lastAppliedAutomaticGenerationByWorktree.set( + worktreeId, + Math.max( + lastAppliedAutomaticGenerationByWorktree.get(worktreeId) ?? 0, + order.automaticGeneration + ) + ) + return true +} + +export function beginStrictUpstreamRefresh(worktreeId: string): void { + strictUpstreamRefreshGenerationByWorktree.set( + worktreeId, + (strictUpstreamRefreshGenerationByWorktree.get(worktreeId) ?? 0) + 1 + ) + trimRefreshOrderingState() +} + +export function clearGitStatusRefreshOrderingStateForTests(): void { + strictUpstreamRefreshGenerationByWorktree.clear() + automaticRefreshGenerationByWorktree.clear() + lastAppliedAutomaticGenerationByWorktree.clear() + automaticUpstreamRefreshInFlightByWorktree.clear() +} diff --git a/src/renderer/src/components/right-sidebar/git-status-refresh-scheduler.test.ts b/src/renderer/src/components/right-sidebar/git-status-refresh-scheduler.test.ts new file mode 100644 index 00000000000..8421e4b56d6 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/git-status-refresh-scheduler.test.ts @@ -0,0 +1,274 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + createGitStatusRefreshScheduler, + type GitStatusRefreshReason +} from './git-status-refresh-scheduler' + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve = (): void => {} + const promise = new Promise((settle) => { + resolve = settle + }) + return { promise, resolve } +} + +async function flushMicrotasks(): Promise { + for (let index = 0; index < 5; index += 1) { + await Promise.resolve() + } +} + +function createScheduler( + task: (request: { reason: GitStatusRefreshReason; signal: AbortSignal }) => Promise +) { + return createGitStatusRefreshScheduler(task, { + safetyIntervalMs: 60_000, + activityDebounceMs: 125, + activityMinGapMs: 3000, + slowTaskBackoff: { + idleMultiplier: 5, + changeSignalMultiplier: 1, + maxIntervalMs: 5 * 60_000 + } + }) +} + +describe('createGitStatusRefreshScheduler', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('refreshes immediately on resume and uses one 60-second safety timeout', async () => { + vi.useFakeTimers() + const reasons: GitStatusRefreshReason[] = [] + const task = vi.fn(async ({ reason }: { reason: GitStatusRefreshReason }) => { + reasons.push(reason) + }) + const scheduler = createScheduler(task) + + scheduler.resumeSafety() + expect(task).toHaveBeenCalledTimes(1) + await flushMicrotasks() + + await vi.advanceTimersByTimeAsync(59_999) + expect(task).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + + expect(task).toHaveBeenCalledTimes(2) + expect(reasons).toEqual(['activity', 'safety']) + }) + + it('coalesces activity signals and restarts the safety horizon', async () => { + vi.useFakeTimers() + const task = vi.fn(async () => {}) + const scheduler = createScheduler(task) + + scheduler.resumeSafety() + await flushMicrotasks() + await vi.advanceTimersByTimeAsync(59_000) + + scheduler.signal() + scheduler.signal() + scheduler.signal() + await vi.advanceTimersByTimeAsync(124) + expect(task).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + expect(task).toHaveBeenCalledTimes(2) + await flushMicrotasks() + + await vi.advanceTimersByTimeAsync(59_999) + expect(task).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(1) + expect(task).toHaveBeenCalledTimes(3) + }) + + it('serializes in-flight work and retains one trailing activity refresh', async () => { + vi.useFakeTimers() + const calls: ReturnType[] = [] + let active = 0 + let maxActive = 0 + const task = vi.fn(() => { + const call = deferred() + calls.push(call) + active += 1 + maxActive = Math.max(maxActive, active) + return call.promise.finally(() => { + active -= 1 + }) + }) + const scheduler = createScheduler(task) + + scheduler.resumeSafety() + scheduler.signal() + scheduler.signal() + scheduler.refreshNow() + expect(task).toHaveBeenCalledTimes(1) + + calls[0]?.resolve() + await flushMicrotasks() + // Why: the trailing refresh respects the 3s activity floor from run end. + expect(task).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(3000) + expect(task).toHaveBeenCalledTimes(2) + expect(maxActive).toBe(1) + + calls[1]?.resolve() + await flushMicrotasks() + await vi.advanceTimersByTimeAsync(60_000) + expect(task).toHaveBeenCalledTimes(3) + }) + + it('spaces sustained signal bursts by the activity floor instead of running back-to-back', async () => { + vi.useFakeTimers() + const calls: ReturnType[] = [] + const task = vi.fn(() => { + const call = deferred() + calls.push(call) + return call.promise + }) + const scheduler = createScheduler(task) + + scheduler.resumeSafety() + scheduler.signal() + calls[0]?.resolve() + await flushMicrotasks() + expect(task).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(2999) + expect(task).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + expect(task).toHaveBeenCalledTimes(2) + + // A signal mid-flight plus immediate settle still waits out the floor. + scheduler.signal() + calls[1]?.resolve() + await flushMicrotasks() + expect(task).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(3000) + expect(task).toHaveBeenCalledTimes(3) + }) + + it('stretches activity and safety pacing after a slow scan', async () => { + vi.useFakeTimers() + const calls: ReturnType[] = [] + const task = vi.fn(() => { + const call = deferred() + calls.push(call) + return call.promise + }) + const scheduler = createScheduler(task) + + scheduler.resumeSafety() + // The first scan takes 30 seconds; a signal arrives while it is running. + scheduler.signal() + await vi.advanceTimersByTimeAsync(30_000) + calls[0]?.resolve() + await flushMicrotasks() + expect(task).toHaveBeenCalledTimes(1) + + // Trailing activity waits max(3s floor, 1x scan duration) = 30s. + await vi.advanceTimersByTimeAsync(29_999) + expect(task).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + expect(task).toHaveBeenCalledTimes(2) + + // The second scan is also slow; the next safety waits max(60s, 5x) = 150s. + await vi.advanceTimersByTimeAsync(30_000) + calls[1]?.resolve() + await flushMicrotasks() + await vi.advanceTimersByTimeAsync(149_999) + expect(task).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(1) + expect(task).toHaveBeenCalledTimes(3) + }) + + it('aborts on pause and catches up after the obsolete request settles', async () => { + vi.useFakeTimers() + const calls: ReturnType[] = [] + const signals: AbortSignal[] = [] + const task = vi.fn(({ signal }: { signal: AbortSignal }) => { + const call = deferred() + calls.push(call) + signals.push(signal) + return call.promise + }) + const scheduler = createScheduler(task) + + scheduler.resumeSafety() + scheduler.pause() + expect(signals[0]?.aborted).toBe(true) + + scheduler.resumeSafety() + expect(task).toHaveBeenCalledTimes(1) + calls[0]?.resolve() + await flushMicrotasks() + + // Why: the reveal catch-up rides the paced activity lane (3s floor). + await vi.advanceTimersByTimeAsync(3000) + expect(task).toHaveBeenCalledTimes(2) + expect(signals[1]?.aborted).toBe(false) + calls[1]?.resolve() + await flushMicrotasks() + }) + + it('does not stretch catch-up pacing with an aborted slow scan duration', async () => { + vi.useFakeTimers() + const calls: ReturnType[] = [] + const task = vi.fn(() => { + const call = deferred() + calls.push(call) + return call.promise + }) + const scheduler = createScheduler(task) + + scheduler.resumeSafety() + // A long scan is aborted by pause (window hide). Its wall time must not + // become the next activity idle gap, or reveal catch-up waits tens of + // seconds for work that never applied. + await vi.advanceTimersByTimeAsync(30_000) + scheduler.pause() + scheduler.resumeSafety() + calls[0]?.resolve() + await flushMicrotasks() + + await vi.advanceTimersByTimeAsync(2999) + expect(task).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + expect(task).toHaveBeenCalledTimes(2) + calls[1]?.resolve() + await flushMicrotasks() + }) + + it('keeps huge-status signal mode active without a safety timeout', async () => { + vi.useFakeTimers() + const task = vi.fn(async () => {}) + const scheduler = createScheduler(task) + + scheduler.resumeSafety() + await flushMicrotasks() + scheduler.suspendSafety() + await vi.advanceTimersByTimeAsync(120_000) + expect(task).toHaveBeenCalledTimes(1) + + scheduler.signal() + await vi.advanceTimersByTimeAsync(125) + expect(task).toHaveBeenCalledTimes(2) + await flushMicrotasks() + await vi.advanceTimersByTimeAsync(120_000) + expect(task).toHaveBeenCalledTimes(2) + }) + + it('cleans up debounce and safety timers on dispose', async () => { + vi.useFakeTimers() + const task = vi.fn(async () => {}) + const scheduler = createScheduler(task) + + scheduler.resumeSafety() + await flushMicrotasks() + scheduler.signal() + scheduler.dispose() + await vi.advanceTimersByTimeAsync(120_000) + + expect(task).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/git-status-refresh-scheduler.ts b/src/renderer/src/components/right-sidebar/git-status-refresh-scheduler.ts new file mode 100644 index 00000000000..f33a6d7afc7 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/git-status-refresh-scheduler.ts @@ -0,0 +1,214 @@ +import { slowTaskRequiredIdleMs, type SlowTaskBackoffOptions } from './coalesced-poll-runner' + +export type GitStatusRefreshReason = 'activity' | 'safety' + +export type GitStatusRefreshScheduler = { + resumeSafety: () => void + pause: () => void + suspendSafety: () => void + signal: () => void + refreshNow: () => void + dispose: () => void +} + +type RefreshTask = (request: { + reason: GitStatusRefreshReason + signal: AbortSignal +}) => Promise + +export function createGitStatusRefreshScheduler( + task: RefreshTask, + options: { + safetyIntervalMs: number + activityDebounceMs: number + // Why: sustained signal streams (busy agent terminals, file churn) must + // never run status scans back-to-back (#7983). Activity runs keep this + // minimum idle gap since the previous run's end, scaled up by the slow + // task backoff when the previous scan itself was slow. + activityMinGapMs: number + slowTaskBackoff: SlowTaskBackoffOptions + } +): GitStatusRefreshScheduler { + let disposed = false + let safetyEnabled = false + let inFlight = false + let pendingActivity = false + let activityTimer: ReturnType | null = null + let activityTimerFiresAt = Infinity + let safetyTimer: ReturnType | null = null + let activeController: AbortController | null = null + let lastRunEndedAt = -Infinity + let lastRunDurationMs = 0 + + const clearActivityTimer = (): void => { + if (activityTimer !== null) { + clearTimeout(activityTimer) + activityTimer = null + activityTimerFiresAt = Infinity + } + } + const clearSafetyTimer = (): void => { + if (safetyTimer !== null) { + clearTimeout(safetyTimer) + safetyTimer = null + } + } + + const requiredActivityIdleMs = (): number => + slowTaskRequiredIdleMs( + lastRunDurationMs, + options.slowTaskBackoff.changeSignalMultiplier, + options.activityMinGapMs, + options.slowTaskBackoff.maxIntervalMs + ) + + const scheduleSafetyRun = (): void => { + // Why: slow scans stretch the safety horizon (idle-lane backoff) so a + // tens-of-seconds status scan never approaches a continuous git duty cycle. + const delay = Math.max( + options.safetyIntervalMs, + slowTaskRequiredIdleMs( + lastRunDurationMs, + options.slowTaskBackoff.idleMultiplier, + 0, + options.slowTaskBackoff.maxIntervalMs + ) + ) + safetyTimer = setTimeout(() => { + safetyTimer = null + startRun('safety') + }, delay) + } + + const scheduleActivityRun = (minDelayMs: number): void => { + if (disposed) { + return + } + if (inFlight) { + // Why: the trigger may describe a mutation after the running Git + // snapshot, so one trailing refresh is required for freshness. + pendingActivity = true + return + } + const now = Date.now() + const delay = Math.max(minDelayMs, lastRunEndedAt + requiredActivityIdleMs() - now) + if (delay <= 0) { + startRun('activity') + return + } + const firesAt = now + delay + if (activityTimer !== null) { + // Why: an earlier-eligible trigger may pull a pending run forward, but a + // later debounce window must never push an already-scheduled run back. + if (firesAt >= activityTimerFiresAt) { + return + } + clearActivityTimer() + } + activityTimerFiresAt = firesAt + activityTimer = setTimeout(() => { + activityTimer = null + activityTimerFiresAt = Infinity + startRun('activity') + }, delay) + } + + const startRun = (reason: GitStatusRefreshReason): void => { + if (disposed || inFlight) { + return + } + clearActivityTimer() + clearSafetyTimer() + inFlight = true + const startedAt = Date.now() + const controller = new AbortController() + activeController = controller + let result: Promise + try { + result = task({ reason, signal: controller.signal }) + } catch (error) { + result = Promise.reject(error) + } + void result + .catch(() => { + // Status refresh errors are transient; the next signal or safety run retries. + }) + .finally(() => { + lastRunEndedAt = Date.now() + // Why: cancelled scans never delivered a useful result, so their wall + // time must not stretch the next activity/safety gap. Otherwise hide → + // reveal after a slow abort waits out the aborted scan's full duration + // before the catch-up refresh can start. + lastRunDurationMs = controller.signal.aborted ? 0 : Math.max(0, lastRunEndedAt - startedAt) + if (activeController === controller) { + activeController = null + } + inFlight = false + if (disposed) { + return + } + if (pendingActivity) { + pendingActivity = false + scheduleActivityRun(0) + return + } + if (safetyEnabled) { + scheduleSafetyRun() + } + }) + } + + return { + resumeSafety: () => { + if (disposed) { + return + } + const wasEnabled = safetyEnabled + safetyEnabled = true + if (wasEnabled) { + return + } + clearSafetyTimer() + if (inFlight) { + // Why: a request aborted while hidden cannot serve as reveal catch-up; + // retain one fresh run and wait for the old transport to settle. + pendingActivity ||= activeController?.signal.aborted === true + return + } + scheduleActivityRun(0) + }, + pause: () => { + safetyEnabled = false + pendingActivity = false + clearActivityTimer() + clearSafetyTimer() + activeController?.abort() + }, + suspendSafety: () => { + safetyEnabled = false + clearSafetyTimer() + }, + signal: () => { + if (disposed) { + return + } + clearSafetyTimer() + scheduleActivityRun(options.activityDebounceMs) + }, + refreshNow: () => { + if (disposed) { + return + } + clearSafetyTimer() + scheduleActivityRun(0) + }, + dispose: () => { + disposed = true + safetyEnabled = false + pendingActivity = false + clearActivityTimer() + clearSafetyTimer() + activeController?.abort() + } + } +} diff --git a/src/renderer/src/components/right-sidebar/git-status-refresh.test.ts b/src/renderer/src/components/right-sidebar/git-status-refresh.test.ts index 5fe28efa4be..3d9427ac1a4 100644 --- a/src/renderer/src/components/right-sidebar/git-status-refresh.test.ts +++ b/src/renderer/src/components/right-sidebar/git-status-refresh.test.ts @@ -233,6 +233,97 @@ describe('refreshGitStatusForWorktree', () => { expect(deps.setUpstreamStatus).toHaveBeenCalledWith('wt-race', strictStatus.upstreamStatus) }) + it('does not let an older automatic status overwrite the latest automatic result', async () => { + const olderStatus = deferred() + const latestStatus: GitStatusResult = { + entries: [{ path: 'latest.ts', status: 'modified', area: 'unstaged' }], + conflictOperation: 'unknown', + head: 'latest' + } + const gitStatus = vi + .fn() + .mockReturnValueOnce(olderStatus.promise) + .mockResolvedValueOnce(latestStatus) + vi.stubGlobal('window', { api: { git: { status: gitStatus } } }) + const deps = makeDeps() + + const olderRefresh = refreshGitStatusForWorktree({ + worktreeId: 'wt-automatic-race', + worktreePath: '/repo', + deps + }) + await vi.waitFor(() => expect(gitStatus).toHaveBeenCalledTimes(1)) + await refreshGitStatusForWorktree({ + worktreeId: 'wt-automatic-race', + worktreePath: '/repo', + deps + }) + olderStatus.resolve({ + entries: [{ path: 'older.ts', status: 'modified', area: 'unstaged' }], + conflictOperation: 'unknown', + head: 'older' + }) + await olderRefresh + + expect(deps.setGitStatus).toHaveBeenCalledTimes(1) + expect(deps.setGitStatus).toHaveBeenCalledWith('wt-automatic-race', latestStatus) + }) + + it('applies an earlier automatic result when a later automatic refresh fails', async () => { + const olderStatus = deferred() + const gitStatus = vi + .fn() + .mockReturnValueOnce(olderStatus.promise) + .mockRejectedValueOnce(new Error('transient index.lock')) + vi.stubGlobal('window', { api: { git: { status: gitStatus } } }) + const deps = makeDeps() + + const olderRefresh = refreshGitStatusForWorktree({ + worktreeId: 'wt-failed-veto', + worktreePath: '/repo', + deps + }) + await vi.waitFor(() => expect(gitStatus).toHaveBeenCalledTimes(1)) + await expect( + refreshGitStatusForWorktree({ + worktreeId: 'wt-failed-veto', + worktreePath: '/repo', + deps + }) + ).rejects.toThrow('transient index.lock') + + const freshStatus: GitStatusResult = { + entries: [{ path: 'fresh.ts', status: 'modified', area: 'unstaged' }], + conflictOperation: 'unknown', + head: 'fresh' + } + olderStatus.resolve(freshStatus) + await olderRefresh + + expect(deps.setGitStatus).toHaveBeenCalledWith('wt-failed-veto', freshStatus) + }) + + it('does not apply a status result after its liveness guard expires', async () => { + const status: GitStatusResult = { + entries: [], + conflictOperation: 'unknown', + head: 'stale' + } + vi.stubGlobal('window', { api: { git: { status: vi.fn().mockResolvedValue(status) } } }) + const deps = makeDeps() + + await refreshGitStatusForWorktree({ + worktreeId: 'wt-stale', + worktreePath: '/repo', + deps, + request: { shouldApply: () => false } + }) + + expect(deps.setGitStatus).not.toHaveBeenCalled() + expect(deps.updateWorktreeGitIdentity).not.toHaveBeenCalled() + expect(deps.fetchUpstreamStatus).not.toHaveBeenCalled() + }) + it('does not let an older automatic explicit upstream fetch overwrite a strict result', async () => { const automaticFetch = deferred() const strictStatus: GitStatusResult = { diff --git a/src/renderer/src/components/right-sidebar/git-status-refresh.ts b/src/renderer/src/components/right-sidebar/git-status-refresh.ts index d8dd54e5764..27e8fa30c35 100644 --- a/src/renderer/src/components/right-sidebar/git-status-refresh.ts +++ b/src/renderer/src/components/right-sidebar/git-status-refresh.ts @@ -11,6 +11,15 @@ import type { GitUpstreamStatus, GlobalSettings } from '../../../../shared/types' +import { + beginAutomaticUpstreamRefresh, + beginStrictUpstreamRefresh, + claimAutomaticUpstreamRefreshApply, + clearGitStatusRefreshOrderingStateForTests, + finishAutomaticUpstreamRefresh, + shouldApplyAutomaticUpstreamRefresh, + type AutomaticRefreshOrder +} from './git-status-refresh-ordering' export type GitStatusRefreshDeps = { setGitStatus: (worktreeId: string, status: GitStatusResult) => void @@ -31,44 +40,6 @@ export type GitStatusRefreshDeps = { ) => Promise } -const MAX_REFRESH_ORDERING_WORKTREES = 1024 -const strictUpstreamRefreshGenerationByWorktree = new Map() -const automaticUpstreamRefreshInFlightByWorktree = new Map() - -function trimRefreshOrderingState(): void { - for (const worktreeId of strictUpstreamRefreshGenerationByWorktree.keys()) { - if (strictUpstreamRefreshGenerationByWorktree.size <= MAX_REFRESH_ORDERING_WORKTREES) { - break - } - if (automaticUpstreamRefreshInFlightByWorktree.has(worktreeId)) { - continue - } - strictUpstreamRefreshGenerationByWorktree.delete(worktreeId) - } -} - -function beginAutomaticUpstreamRefresh(worktreeId: string): number { - automaticUpstreamRefreshInFlightByWorktree.set( - worktreeId, - (automaticUpstreamRefreshInFlightByWorktree.get(worktreeId) ?? 0) + 1 - ) - return strictUpstreamRefreshGenerationByWorktree.get(worktreeId) ?? 0 -} - -function finishAutomaticUpstreamRefresh(worktreeId: string): void { - const count = automaticUpstreamRefreshInFlightByWorktree.get(worktreeId) ?? 0 - if (count <= 1) { - automaticUpstreamRefreshInFlightByWorktree.delete(worktreeId) - } else { - automaticUpstreamRefreshInFlightByWorktree.set(worktreeId, count - 1) - } - trimRefreshOrderingState() -} - -function shouldApplyAutomaticUpstreamRefresh(worktreeId: string, startGeneration: number): boolean { - return (strictUpstreamRefreshGenerationByWorktree.get(worktreeId) ?? 0) === startGeneration -} - async function fetchAndApplyAutomaticUpstreamStatus({ settings, worktreeId, @@ -76,7 +47,8 @@ async function fetchAndApplyAutomaticUpstreamStatus({ connectionId, pushTarget, deps, - startGeneration + order, + shouldApply }: { settings?: Pick | null worktreeId: string @@ -84,8 +56,12 @@ async function fetchAndApplyAutomaticUpstreamStatus({ connectionId?: string pushTarget?: GitPushTarget deps: GitStatusRefreshDeps - startGeneration: number + order: AutomaticRefreshOrder + shouldApply?: () => boolean }): Promise { + if (!shouldApplyAutomaticUpstreamRefresh(worktreeId, order, shouldApply)) { + return null + } const upstreamStatus = await deps.fetchUpstreamStatus( worktreeId, worktreePath, @@ -110,24 +86,15 @@ async function fetchAndApplyAutomaticUpstreamStatus({ } return null } - if (!shouldApplyAutomaticUpstreamRefresh(worktreeId, startGeneration)) { + if (!claimAutomaticUpstreamRefreshApply(worktreeId, order, shouldApply)) { return null } deps.setUpstreamStatus(worktreeId, upstreamStatus) return upstreamStatus } -function beginStrictUpstreamRefresh(worktreeId: string): void { - strictUpstreamRefreshGenerationByWorktree.set( - worktreeId, - (strictUpstreamRefreshGenerationByWorktree.get(worktreeId) ?? 0) + 1 - ) - trimRefreshOrderingState() -} - export function clearGitStatusRefreshOrderingForTests(): void { - strictUpstreamRefreshGenerationByWorktree.clear() - automaticUpstreamRefreshInFlightByWorktree.clear() + clearGitStatusRefreshOrderingStateForTests() clearAutomaticPushTargetUpstreamStatusCache() } @@ -137,7 +104,8 @@ export async function refreshGitStatusForWorktree({ worktreePath, connectionId, pushTarget, - deps + deps, + request }: { settings?: Pick | null worktreeId: string @@ -145,17 +113,30 @@ export async function refreshGitStatusForWorktree({ connectionId?: string pushTarget?: GitPushTarget deps: GitStatusRefreshDeps + request?: { + reuseLineStats?: boolean + signal?: AbortSignal + shouldApply?: () => boolean + } }): Promise { - const upstreamStartGeneration = beginAutomaticUpstreamRefresh(worktreeId) + const refreshOrder = beginAutomaticUpstreamRefresh(worktreeId) try { - const status = (await getRuntimeGitStatus({ - settings, - worktreeId, - worktreePath, - connectionId - })) as GitStatusResult + const status = (await getRuntimeGitStatus( + { + settings, + worktreeId, + worktreePath, + connectionId + }, + request + ? { + ...(request.reuseLineStats === true ? { reuseLineStats: true } : {}), + ...(request.signal ? { signal: request.signal } : {}) + } + : undefined + )) as GitStatusResult - if (!shouldApplyAutomaticUpstreamRefresh(worktreeId, upstreamStartGeneration)) { + if (!claimAutomaticUpstreamRefreshApply(worktreeId, refreshOrder, request?.shouldApply)) { return } @@ -192,7 +173,8 @@ export async function refreshGitStatusForWorktree({ connectionId, pushTarget, deps, - startGeneration: upstreamStartGeneration + order: refreshOrder, + shouldApply: request?.shouldApply }) if (upstreamStatus) { // Why: explicit publish-target comparison can spawn several git @@ -219,11 +201,14 @@ export async function refreshGitStatusForWorktree({ worktreePath, connectionId, deps, - startGeneration: upstreamStartGeneration + order: refreshOrder, + shouldApply: request?.shouldApply }) return } - deps.setUpstreamStatus(worktreeId, status.upstreamStatus) + if (claimAutomaticUpstreamRefreshApply(worktreeId, refreshOrder, request?.shouldApply)) { + deps.setUpstreamStatus(worktreeId, status.upstreamStatus) + } return } await fetchAndApplyAutomaticUpstreamStatus({ @@ -233,7 +218,8 @@ export async function refreshGitStatusForWorktree({ connectionId, pushTarget, deps, - startGeneration: upstreamStartGeneration + order: refreshOrder, + shouldApply: request?.shouldApply }) } finally { finishAutomaticUpstreamRefresh(worktreeId) diff --git a/src/renderer/src/components/right-sidebar/stale-conflict-operation-poll.ts b/src/renderer/src/components/right-sidebar/stale-conflict-operation-poll.ts new file mode 100644 index 00000000000..eaf61d70be7 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/stale-conflict-operation-poll.ts @@ -0,0 +1,121 @@ +import { useEffect, useMemo } from 'react' +import type { GitConflictOperation, Repo } from '../../../../shared/types' +import { isGitRepoKind } from '../../../../shared/repo-kind' +import { getConnectionId } from '@/lib/connection-context' +import { getRuntimeGitConflictOperation } from '@/runtime/runtime-git-client' +import { createCoalescedPollRunner, type SlowTaskBackoffOptions } from './coalesced-poll-runner' +import { installWindowVisibilityInterval, isWindowVisible } from '@/lib/window-visibility-interval' +import { getRightSidebarWorktreeRuntimeSettings } from './file-explorer-runtime-owner' + +const CONFLICT_POLL_INTERVAL_MS = 3000 + +/** + * Polls the conflict operation of non-active worktrees whose sidebar badge + * still shows merge/rebase/cherry-pick, so the badge clears when the operation + * finishes. This is a lightweight fs-only check (no git status), so it stays + * cheap even with many worktrees. + */ +export function useStaleConflictOperationPolling(args: { + enabled: boolean + activeWorktreeId: string | null + allWorktrees: { id: string; path: string; repoId: string }[] + repoMap: Map> + conflictOperationByWorktree: Record + setConflictOperation: (worktreeId: string, operation: GitConflictOperation) => void + isConnectionReady: (connectionId: string | null | undefined) => boolean + slowTaskBackoff: SlowTaskBackoffOptions +}): void { + const { + enabled, + activeWorktreeId, + allWorktrees, + repoMap, + conflictOperationByWorktree, + setConflictOperation, + isConnectionReady, + slowTaskBackoff + } = args + // Why: only non-active worktrees with a known conflict operation need this + // probe — the full git status refresh already covers the active worktree. + const staleConflictWorktrees = useMemo(() => { + const result: { id: string; path: string }[] = [] + for (const [worktreeId, op] of Object.entries(conflictOperationByWorktree)) { + if (worktreeId === activeWorktreeId || op === 'unknown') { + continue + } + const worktree = allWorktrees.find((entry) => entry.id === worktreeId) + if (worktree) { + const repo = repoMap.get(worktree.repoId) + if (repo && !isGitRepoKind(repo)) { + continue + } + result.push({ id: worktree.id, path: worktree.path }) + } + } + return result + }, [allWorktrees, conflictOperationByWorktree, activeWorktreeId, repoMap]) + useEffect(() => { + if (!enabled) { + return + } + if (staleConflictWorktrees.length === 0) { + return + } + + // Why: dispose() cannot interrupt an in-flight probe; without this guard a + // request resolving after cleanup would overwrite newer conflict state. + let active = true + + const pollStale = async (): Promise => { + // Why: a backoff-deferred run can fire long after the window hides; skip + // the probe instead of running SSH/RPC work nobody can see. The + // becoming-visible run catches up via the change-signal lane. + if (!isWindowVisible()) { + return + } + for (const { id, path } of staleConflictWorktrees) { + try { + const connectionId = getConnectionId(id) ?? undefined + // Why: after explicit SSH disconnect the provider is intentionally + // gone; keep remote polling quiet until the target reconnects. + if (!isConnectionReady(connectionId)) { + continue + } + const op = (await getRuntimeGitConflictOperation({ + settings: getRightSidebarWorktreeRuntimeSettings(id), + worktreeId: id, + worktreePath: path, + connectionId + })) as GitConflictOperation + if (!active) { + return + } + setConflictOperation(id, op) + } catch { + // ignore — worktree may have been removed + } + } + } + + // Why: remote conflict probes can exceed the 3s interval. Keep one poll in + // flight, coalesce skipped ticks into one trailing pass, and back off after + // slow probe chains so stale badges catch up without stacking SSH/RPC work. + const pollRunner = createCoalescedPollRunner(pollStale, { + slowTaskBackoff + }) + // Why: conflict badges are visible sidebar state; keep them fresh in + // visible unfocused windows, but do not poll disconnected hidden windows. + // The becoming-visible run rides the short-backoff lane so badges catch + // up promptly after a hidden stretch. + const stopVisiblePoll = installWindowVisibilityInterval({ + run: () => pollRunner.run(), + runOnVisible: () => pollRunner.run({ changeSignal: true }), + intervalMs: CONFLICT_POLL_INTERVAL_MS + }) + return () => { + active = false + pollRunner.dispose() + stopVisiblePoll() + } + }, [enabled, staleConflictWorktrees, setConflictOperation, isConnectionReady, slowTaskBackoff]) +} diff --git a/src/renderer/src/components/right-sidebar/use-persisted-ai-vault-view-options.test.tsx b/src/renderer/src/components/right-sidebar/use-persisted-ai-vault-view-options.test.tsx new file mode 100644 index 00000000000..ba824696c9a --- /dev/null +++ b/src/renderer/src/components/right-sidebar/use-persisted-ai-vault-view-options.test.tsx @@ -0,0 +1,86 @@ +// @vitest-environment happy-dom + +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AI_VAULT_AGENTS } from '../../../../shared/ai-vault-types' +import { AI_VAULT_VIEW_OPTIONS_STORAGE_KEY } from './ai-vault-view-options-persistence' +import { usePersistedAiVaultViewOptions } from './use-persisted-ai-vault-view-options' + +beforeEach(() => { + window.localStorage.removeItem(AI_VAULT_VIEW_OPTIONS_STORAGE_KEY) +}) + +afterEach(() => { + vi.restoreAllMocks() + window.localStorage.removeItem(AI_VAULT_VIEW_OPTIONS_STORAGE_KEY) +}) + +describe('usePersistedAiVaultViewOptions', () => { + it('restores view options when the panel remounts', () => { + const first = renderHook(() => usePersistedAiVaultViewOptions()) + + act(() => { + first.result.current.setAgentEnabled('codex', false) + first.result.current.setSort('created') + first.result.current.setGroup('folder') + first.result.current.setHideEmptySessions(true) + }) + first.unmount() + + const restored = renderHook(() => usePersistedAiVaultViewOptions()) + expect(restored.result.current.agents).not.toContain('codex') + expect(restored.result.current.sort).toBe('created') + expect(restored.result.current.group).toBe('folder') + expect(restored.result.current.hideEmptySessions).toBe(true) + }) + + it('keeps at least one agent enabled', () => { + const hook = renderHook(() => usePersistedAiVaultViewOptions()) + const lastEnabled = AI_VAULT_AGENTS[0] + + act(() => { + for (const agent of AI_VAULT_AGENTS.slice(1)) { + hook.result.current.setAgentEnabled(agent, false) + } + }) + expect(hook.result.current.agents).toEqual([lastEnabled]) + + act(() => hook.result.current.setAgentEnabled(lastEnabled, false)) + expect(hook.result.current.agents).toEqual([lastEnabled]) + }) + + it('keeps in-memory options usable when persistence fails', () => { + const setItem = vi.spyOn(window.localStorage, 'setItem').mockImplementation(() => { + throw new Error('quota exceeded') + }) + const hook = renderHook(() => usePersistedAiVaultViewOptions()) + + act(() => hook.result.current.setSort('created')) + + expect(setItem).toHaveBeenCalled() + expect(hook.result.current.sort).toBe('created') + }) + + it('resets every persisted option to its default', () => { + const hook = renderHook(() => usePersistedAiVaultViewOptions()) + act(() => { + hook.result.current.setAgentEnabled('codex', false) + hook.result.current.setSort('created') + hook.result.current.setGroup('agent') + hook.result.current.setHideEmptySessions(true) + hook.result.current.resetViewOptions() + }) + + expect(hook.result.current.agents).toEqual([...AI_VAULT_AGENTS]) + expect(hook.result.current.sort).toBe('updated') + expect(hook.result.current.group).toBe('project') + expect(hook.result.current.hideEmptySessions).toBe(false) + + hook.unmount() + const restored = renderHook(() => usePersistedAiVaultViewOptions()) + expect(restored.result.current.agents).toEqual([...AI_VAULT_AGENTS]) + expect(restored.result.current.sort).toBe('updated') + expect(restored.result.current.group).toBe('project') + expect(restored.result.current.hideEmptySessions).toBe(false) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/use-persisted-ai-vault-view-options.ts b/src/renderer/src/components/right-sidebar/use-persisted-ai-vault-view-options.ts new file mode 100644 index 00000000000..ffd6816d4a6 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/use-persisted-ai-vault-view-options.ts @@ -0,0 +1,99 @@ +import { useCallback, useMemo, useRef, useState } from 'react' +import type { AiVaultAgent, AiVaultGroup, AiVaultSort } from '../../../../shared/ai-vault-types' +import { + createDefaultAiVaultViewOptions, + enabledAiVaultAgents, + readAiVaultViewOptions, + writeAiVaultViewOptions, + type AiVaultViewOptions +} from './ai-vault-view-options-persistence' + +type AiVaultViewOptionsUpdate = (current: AiVaultViewOptions) => AiVaultViewOptions + +export function usePersistedAiVaultViewOptions(): { + agents: AiVaultAgent[] + sort: AiVaultSort + group: AiVaultGroup + hideEmptySessions: boolean + setSort: (sort: AiVaultSort) => void + setGroup: (group: AiVaultGroup) => void + setHideEmptySessions: (hide: boolean) => void + setAgentEnabled: (agent: AiVaultAgent, enabled: boolean) => void + resetViewOptions: () => void +} { + const [options, setOptions] = useState(() => readAiVaultViewOptions()) + // Why: menu actions may batch before a render, so every persistence write must build on + // the immediately preceding action instead of the last rendered options. + const optionsRef = useRef(options) + + const updateOptions = useCallback((update: AiVaultViewOptionsUpdate) => { + const current = optionsRef.current + const candidate = update(current) + if (candidate === current) { + return + } + // Why: setters map valid state to valid state, so persist the candidate directly. + // Re-normalizing here would re-allocate disabledAgents on every sort/group change and + // needlessly recompute the session filter; writeAiVaultViewOptions still normalizes what it stores. + optionsRef.current = candidate + setOptions(candidate) + writeAiVaultViewOptions(candidate) + }, []) + + const setSort = useCallback( + (sort: AiVaultSort) => + updateOptions((current) => (current.sort === sort ? current : { ...current, sort })), + [updateOptions] + ) + const setGroup = useCallback( + (group: AiVaultGroup) => + updateOptions((current) => (current.group === group ? current : { ...current, group })), + [updateOptions] + ) + const setHideEmptySessions = useCallback( + (hideEmptySessions: boolean) => + updateOptions((current) => + current.hideEmptySessions === hideEmptySessions + ? current + : { ...current, hideEmptySessions } + ), + [updateOptions] + ) + const setAgentEnabled = useCallback( + (agent: AiVaultAgent, enabled: boolean) => { + updateOptions((current) => { + const isDisabled = current.disabledAgents.includes(agent) + if (enabled === !isDisabled) { + return current + } + const disabledAgents = enabled + ? current.disabledAgents.filter((entry) => entry !== agent) + : [...current.disabledAgents, agent] + return enabledAiVaultAgents(disabledAgents).length > 0 + ? { ...current, disabledAgents } + : current + }) + }, + [updateOptions] + ) + const resetViewOptions = useCallback( + () => updateOptions(() => createDefaultAiVaultViewOptions()), + [updateOptions] + ) + + const agents = useMemo( + () => enabledAiVaultAgents(options.disabledAgents), + [options.disabledAgents] + ) + return { + agents, + sort: options.sort, + group: options.group, + hideEmptySessions: options.hideEmptySessions, + setSort, + setGroup, + setHideEmptySessions, + setAgentEnabled, + resetViewOptions + } +} diff --git a/src/renderer/src/components/right-sidebar/useGitStatusPolling.rerender.test.ts b/src/renderer/src/components/right-sidebar/useGitStatusPolling.rerender.test.ts index 1d80a6f8e4a..83595ee7600 100644 --- a/src/renderer/src/components/right-sidebar/useGitStatusPolling.rerender.test.ts +++ b/src/renderer/src/components/right-sidebar/useGitStatusPolling.rerender.test.ts @@ -10,6 +10,8 @@ import { makeOpenFile, makeWorktree, TEST_REPO } from '@/store/slices/store-test import { ORCA_WORKTREE_FILE_CHANGE_EVENT } from '@/hooks/worktree-file-change-event' import { useGitStatusPolling } from './useGitStatusPolling' +globalThis.IS_REACT_ACT_ENVIRONMENT = true + // Mock the refresh boundary so we count invocations precisely without // triggering real IPC cascades (upstream probes, store mutations, etc.). const refreshMock = vi.hoisted(() => vi.fn()) @@ -50,7 +52,7 @@ function HookProbe(): null { return null } -async function renderHook(): Promise { +async function renderHook(): Promise { const container = document.createElement('div') document.body.appendChild(container) const root = createRoot(container) @@ -58,6 +60,7 @@ async function renderHook(): Promise { await act(async () => { root.render(createElement(HookProbe)) }) + return root } async function flushMicrotasks(): Promise { @@ -99,7 +102,7 @@ describe('useGitStatusPolling rerender stability', () => { vi.useRealTimers() }) - it('keeps the poll runner stable when openFiles changes mid-cooldown', async () => { + it('keeps the refresh scheduler stable when openFiles changes mid-signal debounce', async () => { // Spy on addEventListener so we can guard that the file-watch listener // registered before emitting — otherwise the test would prove nothing. const addSpy = vi.spyOn(window, 'addEventListener') @@ -111,10 +114,8 @@ describe('useGitStatusPolling rerender stability', () => { // run() once at install time). expect(refreshMock).toHaveBeenCalledTimes(1) - // Rerender: change openFiles in the store. This gives runFetchStatus a new - // identity (it lists openFiles in its useCallback deps). With the old - // useMemo([runFetchStatus]) the runner would be recreated here, resetting - // lastRunEndedAt to -Infinity and bypassing the cooldown. + // Rerender: change openFiles in the store while the file-watch/scheduler + // debounce is pending. The active-worktree scheduler must survive it. await act(async () => { useAppStore.setState({ openFiles: [makeOpenFile({ id: `${WORKTREE_PATH}/a.ts`, worktreeId: WORKTREE_ID })] @@ -139,18 +140,15 @@ describe('useGitStatusPolling rerender stability', () => { }) ) - // Advance past the 125 ms file-watch debounce. fetchStatus is called, but - // the runner's cooldown (POLL_INTERVAL_MS = 3000 ms from first run end) - // should suppress the second refresh. + // Advance past the 125 ms file-watch debounce. The scheduler coalesces the + // signal and paces it behind the 3 s anti-churn floor from the mount run. await vi.advanceTimersByTimeAsync(200) expect(refreshMock).toHaveBeenCalledTimes(1) - // Mid-cooldown: still no second refresh. - await vi.advanceTimersByTimeAsync(1300) + await vi.advanceTimersByTimeAsync(2799) expect(refreshMock).toHaveBeenCalledTimes(1) - // After the full 3 s cooldown the trailing refresh fires. - await vi.advanceTimersByTimeAsync(1500) + await vi.advanceTimersByTimeAsync(1) await flushMicrotasks() expect(refreshMock).toHaveBeenCalledTimes(2) @@ -175,4 +173,123 @@ describe('useGitStatusPolling rerender stability', () => { // without having to wait for the 3000ms timer. expect(refreshMock).toHaveBeenCalledTimes(2) }) + + it('refreshes immediately when Source Control becomes visible', async () => { + useAppStore.setState({ rightSidebarOpen: false }) + await renderHook() + await flushMicrotasks() + expect(refreshMock).toHaveBeenCalledTimes(1) + + // Let the 3 s anti-churn floor from the mount refresh elapse first. + await vi.advanceTimersByTimeAsync(3000) + await act(async () => { + useAppStore.setState({ rightSidebarOpen: true }) + }) + await flushMicrotasks() + + expect(refreshMock).toHaveBeenCalledTimes(2) + }) + + it('refreshes immediately when the Files tab becomes visible', async () => { + useAppStore.setState({ rightSidebarOpen: false, rightSidebarTab: 'explorer' }) + await renderHook() + await flushMicrotasks() + expect(refreshMock).toHaveBeenCalledTimes(1) + + // Let the 3 s anti-churn floor from the mount refresh elapse first. + await vi.advanceTimersByTimeAsync(3000) + await act(async () => { + useAppStore.setState({ rightSidebarOpen: true }) + }) + await flushMicrotasks() + + expect(refreshMock).toHaveBeenCalledTimes(2) + }) + + it('refreshes immediately when an SSH execution host reconnects', async () => { + useAppStore.setState({ + repos: [{ ...repo, connectionId: 'ssh-1' }], + sshConnectionStates: new Map([ + ['ssh-1', { status: 'disconnected', error: null, reconnectAttempt: 0 }] + ]) + } as Partial) + await renderHook() + await flushMicrotasks() + expect(refreshMock).not.toHaveBeenCalled() + + await act(async () => { + useAppStore.setState({ + sshConnectionStates: new Map([ + ['ssh-1', { status: 'connected', error: null, reconnectAttempt: 0 }] + ]) + } as Partial) + }) + await flushMicrotasks() + + expect(refreshMock).toHaveBeenCalledTimes(1) + }) + + it('marks only the 60-second safety refresh for line-stat reuse', async () => { + await renderHook() + await flushMicrotasks() + + expect(refreshMock.mock.calls[0]?.[0].request.reuseLineStats).toBeUndefined() + await vi.advanceTimersByTimeAsync(60_000) + await flushMicrotasks() + + expect(refreshMock).toHaveBeenCalledTimes(2) + expect(refreshMock.mock.calls[1]?.[0].request.reuseLineStats).toBe(true) + }) + + it('aborts and rejects stale work when the execution host changes', async () => { + let resolveFirst!: () => void + const firstRefresh = new Promise((resolve) => { + resolveFirst = resolve + }) + let firstRequest!: { signal: AbortSignal; shouldApply: () => boolean } + refreshMock.mockImplementationOnce( + (args: { request: { signal: AbortSignal; shouldApply: () => boolean } }) => { + firstRequest = args.request + return firstRefresh + } + ) + await renderHook() + expect(refreshMock).toHaveBeenCalledTimes(1) + + await act(async () => { + useAppStore.setState({ + settings: { activeRuntimeEnvironmentId: 'env-2' } as AppState['settings'] + }) + }) + await flushMicrotasks() + + expect(firstRequest.signal.aborted).toBe(true) + expect(firstRequest.shouldApply()).toBe(false) + expect(refreshMock).toHaveBeenCalledTimes(2) + resolveFirst() + await flushMicrotasks() + }) + + it('aborts and rejects stale work on unmount', async () => { + let resolveFirst!: () => void + const firstRefresh = new Promise((resolve) => { + resolveFirst = resolve + }) + let firstRequest!: { signal: AbortSignal; shouldApply: () => boolean } + refreshMock.mockImplementationOnce( + (args: { request: { signal: AbortSignal; shouldApply: () => boolean } }) => { + firstRequest = args.request + return firstRefresh + } + ) + const root = await renderHook() + + act(() => root.unmount()) + roots.splice(roots.indexOf(root), 1) + + expect(firstRequest.signal.aborted).toBe(true) + expect(firstRequest.shouldApply()).toBe(false) + resolveFirst() + await flushMicrotasks() + }) }) diff --git a/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts b/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts index 44efe18be8b..9b226fb012a 100644 --- a/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts +++ b/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts @@ -238,7 +238,7 @@ describe('useGitStatusPolling', () => { expect(globalThis.setInterval).not.toHaveBeenCalled() }) - it('uses a slower git status cadence when only terminal branch detection needs polling', async () => { + it('uses the safety scheduler instead of an interval for terminal-only branch detection', async () => { const { gitStatus } = await usePollingOnce( { entries: [], @@ -255,7 +255,7 @@ describe('useGitStatusPolling', () => { ) expect(gitStatus).toHaveBeenCalledTimes(1) - expect(globalThis.setInterval).toHaveBeenCalledWith(expect.any(Function), 30_000) + expect(globalThis.setInterval).not.toHaveBeenCalled() }) it('does not install the visible git status poll while disabled', async () => { @@ -411,6 +411,7 @@ describe('useGitStatusPolling', () => { await vi.advanceTimersByTimeAsync(125) expect(gitStatus).toHaveBeenCalledTimes(1) + // Why: evidence refreshes keep the 3s anti-churn floor from the last run. await vi.advanceTimersByTimeAsync(2875) await vi.waitFor(() => expect(gitStatus).toHaveBeenCalledTimes(2)) expect(window.api.fs.watchWorktree).not.toHaveBeenCalled() @@ -652,6 +653,7 @@ describe('useGitStatusPolling', () => { await vi.advanceTimersByTimeAsync(65) expect(gitStatus).toHaveBeenCalledTimes(callsBeforeDebounceFires) + // Why: evidence refreshes keep the 3s anti-churn floor from the last run. await vi.advanceTimersByTimeAsync(2875) await vi.waitFor(() => expect(gitStatus).toHaveBeenCalledTimes(callsBeforeDebounceFires + 1)) @@ -728,7 +730,6 @@ describe('useGitStatusPolling', () => { it('does not overlap slow visible git status polls and runs one trailing refresh', async () => { vi.resetModules() vi.useFakeTimers() - let intervalCallback: (() => void) | null = null let resolveFirst!: (value: GitStatusResult) => void const firstStatus = new Promise((resolve) => { resolveFirst = resolve @@ -804,27 +805,22 @@ describe('useGitStatusPolling', () => { addEventListener: vi.fn(), removeEventListener: vi.fn() }) - vi.stubGlobal( - 'setInterval', - vi.fn((callback: () => void) => { - intervalCallback = callback - return 1 - }) - ) + vi.stubGlobal('setInterval', vi.fn()) vi.stubGlobal('clearInterval', vi.fn()) const { useGitStatusPolling: runPolling } = await import('./useGitStatusPolling') GitStatusPollingHarness({ runPolling }) await vi.waitFor(() => expect(gitStatus).toHaveBeenCalledTimes(1)) - expect(intervalCallback).toBeTypeOf('function') - const tick = intervalCallback as unknown as () => void - tick() - tick() + const onChanged = window.api.worktrees.onChanged as ReturnType + const handleRepoSignal = onChanged.mock.calls[0][0] as (payload: { repoId: string }) => void + handleRepoSignal({ repoId: repo.id }) + handleRepoSignal({ repoId: repo.id }) expect(gitStatus).toHaveBeenCalledTimes(1) resolveFirst(status) await vi.waitFor(() => expect(state.setGitStatus).toHaveBeenCalledTimes(1)) + // Why: the trailing refresh respects the 3s anti-churn floor after settle. await vi.advanceTimersByTimeAsync(3000) await vi.waitFor(() => expect(gitStatus).toHaveBeenCalledTimes(2)) await vi.waitFor(() => expect(state.setGitStatus).toHaveBeenCalledTimes(2)) diff --git a/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts b/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts index acb8d464cbb..8511d796630 100644 --- a/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts +++ b/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts @@ -1,13 +1,11 @@ -import { useCallback, useEffect, useMemo, useRef } from 'react' +import { useCallback, useEffect, useRef } from 'react' import { useAppStore } from '@/store' import { useAllWorktrees, useRepoById, useRepoMap, useWorktreeById } from '@/store/selectors' -import type { GitConflictOperation } from '../../../../shared/types' import { isGitRepoKind } from '../../../../shared/repo-kind' import { getConnectionId } from '@/lib/connection-context' -import { getRuntimeGitConflictOperation } from '@/runtime/runtime-git-client' +import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' import { refreshGitStatusForWorktree } from './git-status-refresh' -import { type CoalescedPollRunner, createCoalescedPollRunner } from './coalesced-poll-runner' -import { installWindowVisibilityInterval, isWindowVisible } from '@/lib/window-visibility-interval' +import { isWindowVisible } from '@/lib/window-visibility-interval' import { hasInteractiveActiveGitStatusConsumer, shouldPollActiveGitStatus @@ -15,18 +13,20 @@ import { import { getRightSidebarWorktreeRuntimeSettings } from './file-explorer-runtime-owner' import { useGitStatusFileWatchRefresh } from './git-status-file-watch-refresh' import { useGitStatusPushSignalRefresh } from './git-status-push-signal-refresh' +import { useStaleConflictOperationPolling } from './stale-conflict-operation-poll' +import { + createGitStatusRefreshScheduler, + type GitStatusRefreshReason, + type GitStatusRefreshScheduler +} from './git-status-refresh-scheduler' -const MIN_STATUS_REFRESH_INTERVAL_MS = 3000 -const INTERACTIVE_STATUS_POLL_INTERVAL_MS = MIN_STATUS_REFRESH_INTERVAL_MS -// Why: file-watch refreshes cover content changes and push signals (repo -// metadata watch, shell command completion) cover branch switches; the -// terminal-only poll is a last-resort backstop for shells without either. -const TERMINAL_ONLY_STATUS_POLL_INTERVAL_MS = 30_000 -// Why: on a large monorepo one status refresh can take tens of seconds, so the -// fixed 3s gap kept a git process running almost continuously while the -// workspace was idle (#7983). Evidence-free ticks wait 5x the previous poll -// duration (~1/6 duty cycle); change signals wait only 1x so real changes in a -// slow repo still surface promptly; the cap bounds worst-case staleness. +const STATUS_SAFETY_INTERVAL_MS = 60_000 +const STATUS_ACTIVITY_DEBOUNCE_MS = 125 +// Why: evidence-driven status refreshes must keep the pre-scheduler floor so +// sustained terminal/file signals can never run git back-to-back (#7983). +const STATUS_ACTIVITY_MIN_GAP_MS = 3000 +// Why: status scans and remote conflict probes can take longer than their +// timers; duration-aware spacing prevents a slow task from running nonstop. const SLOW_GIT_POLL_BACKOFF = { idleMultiplier: 5, changeSignalMultiplier: 1, @@ -37,6 +37,9 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void { const enabled = options.enabled ?? true const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) const activeWorktree = useWorktreeById(activeWorktreeId) + const activeExecutionHostId = useAppStore((s) => + getExecutionHostIdForWorktree(s, activeWorktreeId) + ) const allWorktrees = useAllWorktrees() const updateWorktreeGitIdentity = useAppStore((s) => s.updateWorktreeGitIdentity) const setGitStatus = useAppStore((s) => s.setGitStatus) @@ -87,143 +90,170 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void { canFetchActiveWorktreeGitStatus && !!activeWorktreeId && !gitStatusHugeByWorktree?.[activeWorktreeId] - const activeStatusPollIntervalMs = hasInteractiveActiveGitStatusConsumer( + const activeStatusPollScope = shouldPollActiveWorktreeGitStatus + ? `${activeExecutionHostId}\0${activeWorktreeId}\0${worktreePath}` + : null + // Why: opening any git-status consumer (Source Control, Files, Checks, or an + // editor file) must refresh promptly, matching the pre-scheduler behavior + // where the interactive interval flip re-ran an immediate poll. + const interactiveConsumerVisible = hasInteractiveActiveGitStatusConsumer( activeGitStatusPollingArgs ) - ? INTERACTIVE_STATUS_POLL_INTERVAL_MS - : TERMINAL_ONLY_STATUS_POLL_INTERVAL_MS - const activeStatusPollScope = shouldPollActiveWorktreeGitStatus ? activeWorktreeId : null - // Why: build a list of non-active worktrees that still have a known conflict - // operation (merge/rebase/cherry-pick). These need lightweight polling so - // their sidebar badges clear when the operation finishes — the full git status - // poll only covers the active worktree. - const staleConflictWorktrees = useMemo(() => { - const result: { id: string; path: string }[] = [] - for (const [worktreeId, op] of Object.entries(conflictOperationByWorktree)) { - if (worktreeId === activeWorktreeId || op === 'unknown') { - continue + const runFetchStatus = useCallback( + async (request: { + reason: GitStatusRefreshReason + signal: AbortSignal + shouldApply: () => boolean + }) => { + // Why: eligibility can change between timer dispatch and task start. + // Avoid launching work that its liveness guard would have to discard. + if ( + request.signal.aborted || + !isWindowVisible() || + !canFetchActiveWorktreeGitStatus || + !activeWorktreeId || + !worktreePath + ) { + return } - const worktree = allWorktrees.find((entry) => entry.id === worktreeId) - if (worktree) { - const repo = repoMap.get(worktree.repoId) - if (repo && !isGitRepoKind(repo)) { - continue - } - result.push({ id: worktree.id, path: worktree.path }) + try { + const connectionId = getConnectionId(activeWorktreeId) ?? undefined + await refreshGitStatusForWorktree({ + settings: getRightSidebarWorktreeRuntimeSettings(activeWorktreeId), + worktreeId: activeWorktreeId, + worktreePath, + connectionId, + pushTarget: activePushTarget, + deps: { + setGitStatus, + updateWorktreeGitIdentity, + setUpstreamStatus, + fetchUpstreamStatus + }, + request: { + ...(request.reason === 'safety' ? { reuseLineStats: true } : {}), + signal: request.signal, + shouldApply: request.shouldApply + } + }) + } catch { + // ignore } - } - return result - }, [allWorktrees, conflictOperationByWorktree, activeWorktreeId, repoMap]) + }, + [ + activePushTarget, + activeWorktreeId, + fetchUpstreamStatus, + canFetchActiveWorktreeGitStatus, + worktreePath, + setGitStatus, + setUpstreamStatus, + updateWorktreeGitIdentity + ] + ) - const runFetchStatus = useCallback(async () => { - // Why: a backoff-deferred run can fire long after the window hides; skip - // the scan instead of running tens of seconds of git work nobody can see. - // The becoming-visible run catches up via the change-signal lane. - if (!isWindowVisible()) { - return - } - if (!canFetchActiveWorktreeGitStatus || !activeWorktreeId || !worktreePath) { - return - } - try { - const connectionId = getConnectionId(activeWorktreeId) ?? undefined - await refreshGitStatusForWorktree({ - settings: getRightSidebarWorktreeRuntimeSettings(activeWorktreeId), - worktreeId: activeWorktreeId, - worktreePath, - connectionId, - pushTarget: activePushTarget, - deps: { - setGitStatus, - updateWorktreeGitIdentity, - setUpstreamStatus, - fetchUpstreamStatus - } - }) - } catch { - // ignore - } - }, [ - activePushTarget, - activeWorktreeId, - fetchUpstreamStatus, - canFetchActiveWorktreeGitStatus, - worktreePath, - setGitStatus, - setUpstreamStatus, - updateWorktreeGitIdentity - ]) - - // Why: the runner must survive rerenders so `lastRunEndedAt` and `inFlight` - // are never reset by a UI-state change mid-burst (e.g. openFiles update while - // git is still running). A ref keeps one runner per active-worktree lifetime; - // `runFetchStatusRef` lets the runner always call the latest closure without - // being recreated. The runner is disposed and replaced only when the active - // worktree changes or the hook unmounts. + // Why: the scheduler must survive harmless store rerenders so its in-flight, + // trailing-signal, and safety-horizon state remain authoritative. const runFetchStatusRef = useRef(runFetchStatus) runFetchStatusRef.current = runFetchStatus + const canApplyScheduledStatusRef = useRef(canFetchActiveWorktreeGitStatus) + canApplyScheduledStatusRef.current = canFetchActiveWorktreeGitStatus + const statusRefreshGenerationRef = useRef(0) - const statusPollRunnerRef = useRef(null) + const statusSchedulerRef = useRef(null) useEffect(() => { - const runner = createCoalescedPollRunner(() => runFetchStatusRef.current(), { - minIntervalMs: MIN_STATUS_REFRESH_INTERVAL_MS, - slowTaskBackoff: SLOW_GIT_POLL_BACKOFF - }) - statusPollRunnerRef.current = runner + const generation = ++statusRefreshGenerationRef.current + const scheduler = createGitStatusRefreshScheduler( + ({ reason, signal }) => + runFetchStatusRef.current({ + reason, + signal, + shouldApply: () => + statusRefreshGenerationRef.current === generation && + canApplyScheduledStatusRef.current && + !signal.aborted && + isWindowVisible() + }), + { + safetyIntervalMs: STATUS_SAFETY_INTERVAL_MS, + activityDebounceMs: STATUS_ACTIVITY_DEBOUNCE_MS, + activityMinGapMs: STATUS_ACTIVITY_MIN_GAP_MS, + slowTaskBackoff: SLOW_GIT_POLL_BACKOFF + } + ) + statusSchedulerRef.current = scheduler return () => { - runner.dispose() - statusPollRunnerRef.current = null - } - }, [activeWorktreeId]) - - const fetchStatus = useCallback(() => { - statusPollRunnerRef.current?.run() - }, []) - - // Why: file-watch and push signals carry evidence something changed, so they - // take the runner's short-backoff lane instead of evidence-free tick pacing. - const fetchStatusOnChangeSignal = useCallback(() => { - statusPollRunnerRef.current?.run({ changeSignal: true }) - }, []) - - // Why: while huge, the visibility interval below is not installed, and push - // signals are dropped while hidden — e.g. an agent committing behind a - // minimized window. Catch up on reveal so the huge flag can still clear. - const hugeStatusPauseActive = canFetchActiveWorktreeGitStatus && !activeStatusPollScope - useEffect(() => { - if ( - !hugeStatusPauseActive || - typeof document === 'undefined' || - typeof document.addEventListener !== 'function' - ) { - return - } - const catchUpOnReveal = (): void => { - if (isWindowVisible()) { - fetchStatusOnChangeSignal() + statusRefreshGenerationRef.current += 1 + scheduler.dispose() + if (statusSchedulerRef.current === scheduler) { + statusSchedulerRef.current = null } } - document.addEventListener('visibilitychange', catchUpOnReveal) - return () => { - document.removeEventListener('visibilitychange', catchUpOnReveal) - } - }, [hugeStatusPauseActive, fetchStatusOnChangeSignal]) + // Why: push-target changes must bump the generation so an in-flight refresh + // captured against the old remote/branch can't apply or cache stale upstream + // status for the new one. + }, [activeExecutionHostId, activePushTarget, activeWorktreeId, worktreePath]) useEffect(() => { - if (!activeStatusPollScope) { + const reconcile = (catchUp: boolean): void => { + const scheduler = statusSchedulerRef.current + if (!scheduler) { + return + } + if (!canFetchActiveWorktreeGitStatus || !isWindowVisible()) { + scheduler.pause() + return + } + if (activeStatusPollScope) { + scheduler.resumeSafety() + return + } + scheduler.suspendSafety() + if (catchUp) { + scheduler.refreshNow() + } + } + reconcile(false) + if (typeof document === 'undefined' || typeof document.addEventListener !== 'function') { return } - // Why: this root-level poll should pause while hidden, but visible - // unfocused windows still need fresh status for second-display workflows. - // Change signals are dropped while hidden, so the becoming-visible run - // rides the short-backoff lane to catch up on anything that was missed. - return installWindowVisibilityInterval({ - run: fetchStatus, - runOnVisible: fetchStatusOnChangeSignal, - intervalMs: activeStatusPollIntervalMs - }) - }, [activeStatusPollIntervalMs, activeStatusPollScope, fetchStatus, fetchStatusOnChangeSignal]) + const handleVisibilityChange = (): void => reconcile(isWindowVisible()) + document.addEventListener('visibilitychange', handleVisibilityChange) + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange) + } + }, [activeStatusPollScope, canFetchActiveWorktreeGitStatus]) + + const previousConsumerVisibilityRef = useRef({ + worktreeId: activeWorktreeId, + visible: interactiveConsumerVisible, + canFetch: canFetchActiveWorktreeGitStatus + }) + useEffect(() => { + const previous = previousConsumerVisibilityRef.current + if ( + interactiveConsumerVisible && + previous.worktreeId === activeWorktreeId && + !previous.visible && + previous.canFetch && + canFetchActiveWorktreeGitStatus && + isWindowVisible() + ) { + statusSchedulerRef.current?.refreshNow() + } + previousConsumerVisibilityRef.current = { + worktreeId: activeWorktreeId, + visible: interactiveConsumerVisible, + canFetch: canFetchActiveWorktreeGitStatus + } + }, [activeWorktreeId, canFetchActiveWorktreeGitStatus, interactiveConsumerVisible]) + + // Why: file, terminal, and metadata evidence share one scheduler window so + // a single Git operation cannot fan out into several status subprocesses. + const fetchStatusOnChangeSignal = useCallback(() => { + statusSchedulerRef.current?.signal() + }, []) useGitStatusFileWatchRefresh({ activeConnectionId, @@ -247,63 +277,14 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void { fetchStatus: fetchStatusOnChangeSignal }) - // Why: poll conflict operation for non-active worktrees that have a stale - // non-unknown operation. This is a lightweight fs-only check (no git status) - // so it won't cause performance issues even with many worktrees. - useEffect(() => { - if (!enabled) { - return - } - if (staleConflictWorktrees.length === 0) { - return - } - - const pollStale = async (): Promise => { - // Why: a backoff-deferred run can fire long after the window hides; skip - // the probe instead of running SSH/RPC work nobody can see. The - // becoming-visible run catches up via the change-signal lane. - if (!isWindowVisible()) { - return - } - for (const { id, path } of staleConflictWorktrees) { - try { - const connectionId = getConnectionId(id) ?? undefined - // Why: after explicit SSH disconnect the provider is intentionally - // gone; keep remote polling quiet until the target reconnects. - if (!isConnectionReady(connectionId)) { - continue - } - const op = (await getRuntimeGitConflictOperation({ - settings: getRightSidebarWorktreeRuntimeSettings(id), - worktreeId: id, - worktreePath: path, - connectionId - })) as GitConflictOperation - setConflictOperation(id, op) - } catch { - // ignore — worktree may have been removed - } - } - } - - // Why: remote conflict probes can exceed the 3s interval. Keep one poll in - // flight, coalesce skipped ticks into one trailing pass, and back off after - // slow probe chains so stale badges catch up without stacking SSH/RPC work. - const pollRunner = createCoalescedPollRunner(pollStale, { - slowTaskBackoff: SLOW_GIT_POLL_BACKOFF - }) - // Why: conflict badges are visible sidebar state; keep them fresh in - // visible unfocused windows, but do not poll disconnected hidden windows. - // The becoming-visible run rides the short-backoff lane so badges catch - // up promptly after a hidden stretch. - const stopVisiblePoll = installWindowVisibilityInterval({ - run: () => pollRunner.run(), - runOnVisible: () => pollRunner.run({ changeSignal: true }), - intervalMs: MIN_STATUS_REFRESH_INTERVAL_MS - }) - return () => { - pollRunner.dispose() - stopVisiblePoll() - } - }, [enabled, staleConflictWorktrees, setConflictOperation, isConnectionReady]) + useStaleConflictOperationPolling({ + enabled, + activeWorktreeId, + allWorktrees, + repoMap, + conflictOperationByWorktree, + setConflictOperation, + isConnectionReady, + slowTaskBackoff: SLOW_GIT_POLL_BACKOFF + }) } diff --git a/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx b/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx index d7f4da5b619..4e986a2686f 100644 --- a/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx +++ b/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState, type ReactNode } from 'react' import { Copy, Loader2, RefreshCw, Terminal } from 'lucide-react' import { toast } from 'sonner' import { IntegrationStatusPill } from '../integration-status-pill' +import { SkillFreshnessStatusPill } from '../skills/SkillFreshnessStatusPill' import { OnboardingInlineCommandTerminal } from '../onboarding/OnboardingInlineCommandTerminal' import { Button } from '../ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' @@ -47,6 +48,10 @@ type AgentSkillSetupPanelProps = { openingHint?: ReactNode footer?: ReactNode onRecheck: () => void | Promise + // Why: when set, the installed pill reflects skill freshness and Re-check also + // refreshes the freshness inventory. Callers omit it for non-local runtimes, + // which the local-host-only freshness scan cannot vouch for. + freshnessSkillName?: string } export function AgentSkillSetupPanel({ @@ -79,7 +84,8 @@ export function AgentSkillSetupPanel({ actionHint, openingHint, footer, - onRecheck + onRecheck, + freshnessSkillName }: AgentSkillSetupPanelProps): React.JSX.Element { const [terminalOpen, setTerminalOpen] = useState(false) const [terminalCommand, setTerminalCommand] = useState(null) @@ -211,7 +217,12 @@ export function AgentSkillSetupPanel({ variant="ghost" size="sm" className="gap-1.5" - onClick={() => void onRecheck()} + onClick={() => { + void onRecheck() + if (freshnessSkillName) { + notifyInstalledAgentSkillsChanged() + } + }} disabled={loading} > @@ -264,12 +275,16 @@ export function AgentSkillSetupPanel({ )} ) : installed ? ( - - {translate( - 'auto.components.settings.AgentSkillSetupPanel.9fcebceb2a', - 'Installed' - )} - + freshnessSkillName ? ( + + ) : ( + + {translate( + 'auto.components.settings.AgentSkillSetupPanel.9fcebceb2a', + 'Installed' + )} + + ) ) : ( {translate( diff --git a/src/renderer/src/components/settings/AppearanceInterfaceSection.tsx b/src/renderer/src/components/settings/AppearanceInterfaceSection.tsx index 31afc7d6843..9d5bb1cbbab 100644 --- a/src/renderer/src/components/settings/AppearanceInterfaceSection.tsx +++ b/src/renderer/src/components/settings/AppearanceInterfaceSection.tsx @@ -17,6 +17,7 @@ import { import { DEFAULT_APP_FONT_FAMILY } from '../../../../shared/constants' import { getLanguageEntries, + getMenuBarIconEntries, getSystemTrayEntries, getThemeEntries, getTitlebarEntries, @@ -37,6 +38,7 @@ type AppearanceInterfaceSectionProps = { updateSettings: (updates: Partial) => void applyTheme: (theme: 'system' | 'dark' | 'light') => void fontSuggestions: string[] + isDesktopMac: boolean isDesktopWindows: boolean onRequestFontSuggestions?: () => void forceVisiblePrimary?: boolean @@ -47,6 +49,7 @@ export function AppearanceInterfaceSection({ updateSettings, applyTheme, fontSuggestions, + isDesktopMac, isDesktopWindows, onRequestFontSuggestions, forceVisiblePrimary = false @@ -56,6 +59,7 @@ export function AppearanceInterfaceSection({ const zoomInKeyCombos = useShortcutKeyComboDetails('zoom.in') const zoomOutKeyCombos = useShortcutKeyComboDetails('zoom.out') const languageEntry = getLanguageEntries()[0] + const menuBarIconEntry = getMenuBarIconEntries({ showMenuBarIcon: true })[0] const systemTrayEntry = getSystemTrayEntries({ showSystemTray: true })[0] const themeEntry = getThemeEntries()[0] const themeLabel = translate('auto.components.settings.AppearancePane.932ff1fbff', 'Theme') @@ -65,7 +69,8 @@ export function AppearanceInterfaceSection({ const advancedEntries = [ ...(SHOW_UI_LANGUAGE_SETTING ? getLanguageEntries() : []), ...getTitlebarEntries(), - ...getSystemTrayEntries({ showSystemTray: isDesktopWindows }) + ...getSystemTrayEntries({ showSystemTray: isDesktopWindows }), + ...getMenuBarIconEntries({ showMenuBarIcon: isDesktopMac }) ] const showAdvanced = !isSearching || matchesSettingsSearch(searchQuery, advancedEntries) @@ -236,6 +241,28 @@ export function AppearanceInterfaceSection({ /> ) : null} + + {isDesktopMac ? ( + + + updateSettings({ showMenuBarIcon: settings.showMenuBarIcon === false }) + } + /> + + ) : null}

) : null} diff --git a/src/renderer/src/components/settings/AppearancePane.test.tsx b/src/renderer/src/components/settings/AppearancePane.test.tsx index ca68ff7e47c..0550e9704a6 100644 --- a/src/renderer/src/components/settings/AppearancePane.test.tsx +++ b/src/renderer/src/components/settings/AppearancePane.test.tsx @@ -10,6 +10,7 @@ import type { GlobalSettings, StatusBarItem } from '../../../../shared/types' const mocks = vi.hoisted(() => ({ state: { + appPlatform: 'linux' as NodeJS.Platform, availableStatusBarToggles: [] as { description: string id: StatusBarItem @@ -179,12 +180,16 @@ describe('AppearancePane', () => { beforeEach(() => { vi.clearAllMocks() mocks.state.availableStatusBarToggles = [] + mocks.state.appPlatform = 'linux' mocks.state.settingsSearchQuery = 'automations' mocks.state.usagePercentageDisplay = 'used' // UIZoomControl reads window.api.ui on mount; the inline-expansion pane can // render the full Interface section, so provide a minimal renderer bridge // without clobbering happy-dom's window.location. ;(window as unknown as { api: unknown }).api = { + platform: { + get: () => ({ platform: mocks.state.appPlatform }) + }, ui: { getZoomLevel: () => 0, onTerminalZoom: () => () => {}, @@ -359,6 +364,23 @@ describe('AppearancePane', () => { ).not.toBeNull() }) + it('shows and updates the menu bar icon preference only on desktop macOS', async () => { + mocks.state.appPlatform = 'darwin' + mocks.state.settingsSearchQuery = 'menu bar' + const updateSettings = vi.fn() + const container = await renderAppearancePane(getDefaultSettings('/tmp'), updateSettings) + const toggle = container.querySelector( + 'button[role="switch"][aria-label="Show Menu Bar Icon"]' + ) + + expect(toggle).not.toBeNull() + expect(container.textContent).not.toContain('Minimize to Tray on Close') + await act(async () => { + toggle?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + expect(updateSettings).toHaveBeenCalledWith({ showMenuBarIcon: false }) + }) + it('keeps description-only search matches visible after helper text is hidden', async () => { mocks.state.settingsSearchQuery = 'app window' const container = await renderAppearancePane(getDefaultSettings('/tmp')) diff --git a/src/renderer/src/components/settings/AppearancePane.tsx b/src/renderer/src/components/settings/AppearancePane.tsx index 631059d6d3a..58954d32565 100644 --- a/src/renderer/src/components/settings/AppearancePane.tsx +++ b/src/renderer/src/components/settings/AppearancePane.tsx @@ -16,6 +16,7 @@ import { getAppearancePaneSearchEntries, getLanguageEntries, getLayoutEntries, + getMenuBarIconEntries, getSidebarEntries, getStatusBarEntries, getSystemTrayEntries, @@ -85,6 +86,7 @@ export function AppearancePane({ // Why: the system tray behavior is desktop-Electron Windows-only; a Windows // browser web client has no local tray to control. const isDesktopWindows = getRendererAppPlatform() === 'win32' && !isWebClient + const isDesktopMac = getRendererAppPlatform() === 'darwin' && !isWebClient const [manuallyOpenSection, setManuallyOpenSection] = useState( 'interface' @@ -134,7 +136,8 @@ export function AppearancePane({ ...getTypographyEntries(), ...(SHOW_UI_LANGUAGE_SETTING ? getLanguageEntries() : []), ...getTitlebarEntries(), - ...getSystemTrayEntries({ showSystemTray: isDesktopWindows }) + ...getSystemTrayEntries({ showSystemTray: isDesktopWindows }), + ...getMenuBarIconEntries({ showMenuBarIcon: isDesktopMac }) ] const terminalSearchEntries = [ { title: terminalTitle }, @@ -207,6 +210,7 @@ export function AppearancePane({ applyTheme={applyTheme} fontSuggestions={fontSuggestions} onRequestFontSuggestions={onRequestFontSuggestions} + isDesktopMac={isDesktopMac} isDesktopWindows={isDesktopWindows} forceVisiblePrimary={interfaceLabelMatches} /> diff --git a/src/renderer/src/components/settings/CliSection.tsx b/src/renderer/src/components/settings/CliSection.tsx index 20380332009..eae2ce2a76b 100644 --- a/src/renderer/src/components/settings/CliSection.tsx +++ b/src/renderer/src/components/settings/CliSection.tsx @@ -390,6 +390,7 @@ export function CliSection({ })) }} onRecheck={refreshCliSkill} + freshnessSkillName={agentRuntime.runtime === 'host' ? ORCA_CLI_SKILL_NAME : undefined} />
) : null} diff --git a/src/renderer/src/components/settings/ComputerUseSkillSetupPanel.tsx b/src/renderer/src/components/settings/ComputerUseSkillSetupPanel.tsx index 270e1785aef..0ffc0a2b526 100644 --- a/src/renderer/src/components/settings/ComputerUseSkillSetupPanel.tsx +++ b/src/renderer/src/components/settings/ComputerUseSkillSetupPanel.tsx @@ -79,6 +79,9 @@ export function ComputerUseSkillSetupPanel(): React.JSX.Element { : ensureOrcaCliAvailableForAgentSkillTerminal()) }} onRecheck={refreshComputerUseSkill} + freshnessSkillName={ + activeSkillRuntime.agentRuntime?.runtime === 'wsl' ? undefined : COMPUTER_USE_SKILL_NAME + } /> ) } diff --git a/src/renderer/src/components/settings/EphemeralVmsPane.tsx b/src/renderer/src/components/settings/EphemeralVmsPane.tsx index 85b27f12ed7..f1a34a0396f 100644 --- a/src/renderer/src/components/settings/EphemeralVmsPane.tsx +++ b/src/renderer/src/components/settings/EphemeralVmsPane.tsx @@ -163,6 +163,9 @@ export function EphemeralVmsPane(): React.JSX.Element { : ensureOrcaCliAvailableForAgentSkillTerminal()) }} onRecheck={refreshSkill} + freshnessSkillName={ + activeSkillRuntime.agentRuntime?.runtime === 'wsl' ? undefined : EPHEMERAL_VMS_SKILL_NAME + } />
diff --git a/src/renderer/src/components/settings/GrokAccountsSection.test.tsx b/src/renderer/src/components/settings/GrokAccountsSection.test.tsx new file mode 100644 index 00000000000..cd418597719 --- /dev/null +++ b/src/renderer/src/components/settings/GrokAccountsSection.test.tsx @@ -0,0 +1,69 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import React from 'react' +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getStatus: vi.fn(), + refreshGrokRateLimits: vi.fn() +})) + +vi.mock('@/lib/agent-catalog', () => ({ + AgentIcon: () => React.createElement('span', { 'data-testid': 'grok-icon' }) +})) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, values?: Record) => { + let result = fallback + for (const [key, value] of Object.entries(values ?? {})) { + result = result.replace(`{{${key}}}`, value) + } + return result + } +})) + +vi.mock('../../store', () => ({ + useAppStore: (selector: (state: Record) => unknown) => + selector({ + refreshGrokRateLimits: mocks.refreshGrokRateLimits, + rateLimits: { grok: null } + }) +})) + +import { GrokAccountsSection } from './GrokAccountsSection' + +describe('GrokAccountsSection', () => { + beforeEach(() => { + mocks.getStatus.mockResolvedValue({ + signedIn: true, + email: 'dev@example.com', + teamId: null, + tokenFresh: false, + error: null + }) + mocks.refreshGrokRateLimits.mockResolvedValue(undefined) + Object.defineProperty(window, 'api', { + configurable: true, + value: { grokAccounts: { getStatus: mocks.getStatus } } + }) + }) + + afterEach(() => { + cleanup() + vi.clearAllMocks() + }) + + it('explains the host-scoped Grok recovery flow without requiring a chat message', async () => { + render() + + expect( + await screen.findByText( + 'Session expired — run grok on the computer running Orca and wait for it to start. If prompted, complete sign-in, then click Refresh usage. No chat message is needed.' + ) + ).toBeInTheDocument() + expect(screen.queryByText(/grok login/i)).not.toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/components/settings/GrokAccountsSection.tsx b/src/renderer/src/components/settings/GrokAccountsSection.tsx index 474c4c1ee66..8df194dbd2d 100644 --- a/src/renderer/src/components/settings/GrokAccountsSection.tsx +++ b/src/renderer/src/components/settings/GrokAccountsSection.tsx @@ -109,12 +109,12 @@ export function GrokAccountsSection(): React.JSX.Element {

{tokenFresh ? translate( - 'auto.components.settings.GrokAccountsSection.c3d4e5f6a7', - 'Signed in. Orca only reads that file on disk — run grok login again if usage fails.' + 'auto.components.settings.GrokAccountsSection.b36fa2c908', + 'Signed in. Orca reads the Grok CLI session stored on disk.' ) : translate( - 'auto.components.settings.GrokAccountsSection.d4e5f6a7b8', - 'Session expired — run grok login in a terminal to refresh.' + 'auto.components.settings.GrokAccountsSection.f08c41de73', + 'Session expired — run grok on the computer running Orca and wait for it to start. If prompted, complete sign-in, then click Refresh usage. No chat message is needed.' )}

diff --git a/src/renderer/src/components/settings/OrchestrationSetupCard.tsx b/src/renderer/src/components/settings/OrchestrationSetupCard.tsx index b757261bba6..d597895d580 100644 --- a/src/renderer/src/components/settings/OrchestrationSetupCard.tsx +++ b/src/renderer/src/components/settings/OrchestrationSetupCard.tsx @@ -7,6 +7,7 @@ import { ORCHESTRATION_SKILL_INSTALL_COMMAND, ORCHESTRATION_SKILL_UPDATE_COMMAND } from '@/lib/orchestration-install-command' +import { ORCHESTRATION_SKILL_NAME } from '@/lib/agent-feature-install-commands' import type { InstalledAgentSkillState } from '@/hooks/useInstalledAgentSkills' import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime' import { AgentSkillSetupPanel } from './AgentSkillSetupPanel' @@ -75,6 +76,9 @@ export function OrchestrationSetupCard(props: { : ensureOrcaCliAvailableForAgentSkillTerminal()) }} onRecheck={skill.refresh} + freshnessSkillName={ + activeSkillRuntime.agentRuntime?.runtime === 'wsl' ? undefined : ORCHESTRATION_SKILL_NAME + } /> ) diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index 2e0d2baa003..0d4c2d5d537 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -100,6 +100,7 @@ import { useInstalledAgentSkill } from '@/hooks/useInstalledAgentSkills' import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime' +import { useSkillFreshness } from '@/hooks/useSkillFreshness' import { deriveNeededSectionIds, getInitialMountedSectionIds } from './settings-load-performance' import { translate } from '@/i18n/i18n' import { getProjectHostSetupProjectionFromState } from '../../store/selectors' @@ -207,11 +208,15 @@ function getSettingsNavGroupDefinitionsForSearch( function getSkillNavInstallStatus(skill: { installed: boolean loading: boolean + updateAvailable?: boolean }): SettingsNavInstallStatus { if (skill.loading) { return 'checking' } - return skill.installed ? 'installed' : 'install' + if (!skill.installed) { + return 'install' + } + return skill.updateAvailable ? 'update-available' : 'installed' } function hasReadyVoiceModel( @@ -346,6 +351,10 @@ function Settings(): React.JSX.Element { discoveryTarget: activeSkillRuntime.discoveryTarget, sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS }) + // Why: mirror the setup cards — freshness only speaks for the validated global + // rail, which doesn't run under WSL, so the nav pill stays presence-only there. + const { inventory: skillFreshnessInventory } = useSkillFreshness() + const skillFreshnessApplies = activeSkillRuntime.agentRuntime?.runtime !== 'wsl' const [voiceModelStatesLoading, setVoiceModelStatesLoading] = useState(showDesktopOnlySettings) // Why: the Terminal settings section shares one search index with the // sidebar. We trim platform-only entries on other platforms so search never @@ -724,13 +733,16 @@ function Settings(): React.JSX.Element { orchestrationSkill const { installed: computerUseSkillInstalled, loading: computerUseSkillLoading } = computerUseSkill + const eligibleUpdateSkillNames = skillFreshnessInventory?.eligibleUpdateNames const capabilityInstallStatusBySectionId = useMemo(() => { + const eligibleUpdates = new Set(skillFreshnessApplies ? (eligibleUpdateSkillNames ?? []) : []) const next = new Map([ [ 'orchestration', getSkillNavInstallStatus({ installed: orchestrationSkillInstalled, - loading: orchestrationSkillLoading + loading: orchestrationSkillLoading, + updateAvailable: eligibleUpdates.has(ORCHESTRATION_SKILL_NAME) }) ] ]) @@ -739,7 +751,8 @@ function Settings(): React.JSX.Element { 'computer-use', getSkillNavInstallStatus({ installed: computerUseSkillInstalled, - loading: computerUseSkillLoading + loading: computerUseSkillLoading, + updateAvailable: eligibleUpdates.has(COMPUTER_USE_SKILL_NAME) }) ) if (settings) { @@ -757,11 +770,13 @@ function Settings(): React.JSX.Element { }, [ computerUseSkillInstalled, computerUseSkillLoading, + eligibleUpdateSkillNames, modelStates, orchestrationSkillInstalled, orchestrationSkillLoading, settings, showDesktopOnlySettings, + skillFreshnessApplies, voiceModelStatesLoading ]) const navSections = useMemo( diff --git a/src/renderer/src/components/settings/SettingsSidebar.tsx b/src/renderer/src/components/settings/SettingsSidebar.tsx index 7bbcea65371..8029eabda1a 100644 --- a/src/renderer/src/components/settings/SettingsSidebar.tsx +++ b/src/renderer/src/components/settings/SettingsSidebar.tsx @@ -155,6 +155,11 @@ export function SettingsSidebar({ ) case 'installed': return translate('auto.components.settings.AgentSkillSetupPanel.9fcebceb2a', 'Installed') + case 'update-available': + return translate( + 'auto.components.skills.SkillFreshnessStatusPill.updateAvailable', + 'Update available' + ) case 'checking': return translate('auto.components.settings.AgentSkillSetupPanel.68a468752e', 'Checking...') } @@ -164,9 +169,11 @@ export function SettingsSidebar({ 'ml-auto shrink-0 rounded-full border px-1.5 py-0.5 text-[10px] font-medium leading-none', status === 'installed' ? 'border-status-success-border bg-status-success-background text-status-success' - : status === 'install' - ? 'border-foreground/15 bg-foreground/10 text-foreground' - : 'border-border/50 bg-muted/30 text-muted-foreground' + : status === 'update-available' + ? 'border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300' + : status === 'install' + ? 'border-foreground/15 bg-foreground/10 text-foreground' + : 'border-border/50 bg-muted/30 text-muted-foreground' ) return ( diff --git a/src/renderer/src/components/settings/appearance-search.ts b/src/renderer/src/components/settings/appearance-search.ts index 3ceaf412d13..3e15d266221 100644 --- a/src/renderer/src/components/settings/appearance-search.ts +++ b/src/renderer/src/components/settings/appearance-search.ts @@ -2,15 +2,19 @@ import type { SettingsSearchEntry } from './settings-search' import { getTerminalAppearanceSearchEntries } from './terminal-search' import { getLeftSidebarAppearanceEntry, getSidebarEntries } from './appearance-sidebar-search' import { createLocalizedCatalog } from '@/i18n/localized-catalog' -import { getRendererAppPlatform } from '@/lib/renderer-app-platform' -import { isWebClientLocation } from '@/lib/web-client-location' import { translate } from '@/i18n/i18n' import { translateSearchKeyword } from './settings-search-keywords' import { SHOW_UI_LANGUAGE_SETTING } from '@/i18n/supported-languages' import { getStatusBarToggles } from './appearance-status-bar-search' import { getUsagePercentageDisplayEntry } from './appearance-usage-percentage-search' +import { getMenuBarIconEntries, getSystemTrayEntries } from './appearance-system-presence-search' -export { getStatusBarToggles, getUsagePercentageDisplayEntry } +export { + getMenuBarIconEntries, + getStatusBarToggles, + getSystemTrayEntries, + getUsagePercentageDisplayEntry +} export const getThemeEntries = createLocalizedCatalog((): SettingsSearchEntry[] => [ { @@ -188,65 +192,6 @@ export const getAppIconEntries = createLocalizedCatalog((): SettingsSearchEntry[ } ]) -const getSystemTrayEntryCatalog = createLocalizedCatalog((): SettingsSearchEntry[] => [ - { - title: translate( - 'auto.components.settings.appearance.search.9a115966d3', - 'Minimize to Tray on Close' - ), - description: translate( - 'auto.components.settings.appearance.search.4d5b9427b5', - 'When enabled, closing the window keeps Orca running in the system tray instead of quitting.' - ), - keywords: [ - ...translateSearchKeyword('auto.components.settings.appearance.search.tray.tray', 'tray', { - englishOnly: true - }), - ...translateSearchKeyword( - 'auto.components.settings.appearance.search.tray.system', - 'system tray', - { englishOnly: true } - ), - ...translateSearchKeyword( - 'auto.components.settings.appearance.search.tray.minimize', - 'minimize', - { englishOnly: true } - ), - ...translateSearchKeyword('auto.components.settings.appearance.search.tray.close', 'close', { - englishOnly: true - }), - ...translateSearchKeyword('auto.components.settings.appearance.search.e5bc35d59e', 'window'), - ...translateSearchKeyword( - 'auto.components.settings.appearance.search.tray.notification', - 'notification area', - { englishOnly: true } - ), - ...translateSearchKeyword( - 'auto.components.settings.appearance.search.tray.background', - 'background', - { englishOnly: true } - ) - ] - } -]) - -type SystemTraySearchOptions = { - showSystemTray?: boolean -} - -function shouldShowSystemTrayEntries(options: SystemTraySearchOptions): boolean { - return ( - options.showSystemTray ?? - // Why: this setting controls Electron's Windows tray only. A Windows web - // browser can report win32, but it has no local tray to affect. - (getRendererAppPlatform() === 'win32' && !isWebClientLocation()) - ) -} - -export function getSystemTrayEntries(options: SystemTraySearchOptions = {}): SettingsSearchEntry[] { - return shouldShowSystemTrayEntries(options) ? getSystemTrayEntryCatalog() : [] -} - const getAppearanceSectionEntries = createLocalizedCatalog((): SettingsSearchEntry[] => [ { title: translate('auto.components.settings.AppearancePane.interfaceTitle', 'Interface') @@ -269,6 +214,7 @@ const getAppearanceSectionEntries = createLocalizedCatalog((): SettingsSearchEnt type AppearancePaneSearchOptions = { showWarpImport?: boolean showSystemTray?: boolean + showMenuBarIcon?: boolean } function buildAppearancePaneSearchEntries( @@ -286,7 +232,8 @@ function buildAppearancePaneSearchEntries( ...getStatusBarEntries(), ...getSidebarEntries(), ...getAppIconEntries(), - ...getSystemTrayEntries(options) + ...getSystemTrayEntries(options), + ...getMenuBarIconEntries(options) ] } @@ -295,6 +242,7 @@ export function getAppearancePaneSearchEntries( ): SettingsSearchEntry[] { return buildAppearancePaneSearchEntries({ showWarpImport: options.showWarpImport ?? true, - showSystemTray: options.showSystemTray + showSystemTray: options.showSystemTray, + showMenuBarIcon: options.showMenuBarIcon }) } diff --git a/src/renderer/src/components/settings/appearance-system-presence-search.ts b/src/renderer/src/components/settings/appearance-system-presence-search.ts new file mode 100644 index 00000000000..f392da6c271 --- /dev/null +++ b/src/renderer/src/components/settings/appearance-system-presence-search.ts @@ -0,0 +1,103 @@ +import { translate } from '@/i18n/i18n' +import { createLocalizedCatalog } from '@/i18n/localized-catalog' +import { getRendererAppPlatform } from '@/lib/renderer-app-platform' +import { isWebClientLocation } from '@/lib/web-client-location' +import type { SettingsSearchEntry } from './settings-search' +import { translateSearchKeyword } from './settings-search-keywords' + +const getSystemTrayEntryCatalog = createLocalizedCatalog((): SettingsSearchEntry[] => [ + { + title: translate( + 'auto.components.settings.appearance.search.9a115966d3', + 'Minimize to Tray on Close' + ), + description: translate( + 'auto.components.settings.appearance.search.4d5b9427b5', + 'When enabled, closing the window keeps Orca running in the system tray instead of quitting.' + ), + keywords: [ + ...translateSearchKeyword('auto.components.settings.appearance.search.tray.tray', 'tray', { + englishOnly: true + }), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.tray.system', + 'system tray', + { englishOnly: true } + ), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.tray.minimize', + 'minimize', + { englishOnly: true } + ), + ...translateSearchKeyword('auto.components.settings.appearance.search.tray.close', 'close', { + englishOnly: true + }), + ...translateSearchKeyword('auto.components.settings.appearance.search.e5bc35d59e', 'window'), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.tray.notification', + 'notification area', + { englishOnly: true } + ), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.tray.background', + 'background', + { englishOnly: true } + ) + ] + } +]) + +const getMenuBarIconEntryCatalog = createLocalizedCatalog((): SettingsSearchEntry[] => [ + { + title: translate('settings.appearance.menuBarIcon.title', 'Show Menu Bar Icon'), + description: translate( + 'settings.appearance.menuBarIcon.description', + 'Keep an Orca shortcut and activity indicator in the macOS menu bar.' + ), + keywords: [ + ...translateSearchKeyword('settings.appearance.menuBarIcon.keyword.menuBar', 'menu bar', { + englishOnly: true + }), + ...translateSearchKeyword('auto.components.settings.appearance.search.tray.tray', 'tray', { + englishOnly: true + }), + ...translateSearchKeyword('auto.components.settings.appearance.search.1f2880a9d5', 'orca'), + ...translateSearchKeyword( + 'settings.appearance.menuBarIcon.keyword.statusItem', + 'status item', + { englishOnly: true } + ), + ...translateSearchKeyword('settings.appearance.menuBarIcon.keyword.activity', 'activity', { + englishOnly: true + }), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.tray.background', + 'background', + { englishOnly: true } + ) + ] + } +]) + +type SystemPresenceSearchOptions = { + showMenuBarIcon?: boolean + showSystemTray?: boolean +} + +export function getSystemTrayEntries( + options: SystemPresenceSearchOptions = {} +): SettingsSearchEntry[] { + const show = + options.showSystemTray ?? + // Why: a Windows web client can report win32, but it has no local tray. + (getRendererAppPlatform() === 'win32' && !isWebClientLocation()) + return show ? getSystemTrayEntryCatalog() : [] +} + +export function getMenuBarIconEntries( + options: SystemPresenceSearchOptions = {} +): SettingsSearchEntry[] { + const show = + options.showMenuBarIcon ?? (getRendererAppPlatform() === 'darwin' && !isWebClientLocation()) + return show ? getMenuBarIconEntryCatalog() : [] +} diff --git a/src/renderer/src/components/settings/jira-integration-card.tsx b/src/renderer/src/components/settings/jira-integration-card.tsx index 0501d05faf1..8427e6f4cf7 100644 --- a/src/renderer/src/components/settings/jira-integration-card.tsx +++ b/src/renderer/src/components/settings/jira-integration-card.tsx @@ -38,8 +38,14 @@ export function JiraIntegrationCard(): React.JSX.Element { const siteCount = sites.length || (connected ? 1 : 0) const accountScope = getProviderAccountScope(settings) const credentialCopy = hasRemoteProviderRuntime(settings) - ? 'Connect a Jira Cloud site with your Atlassian email and an API token. Credentials are sent to the selected remote runtime and stored there with runtime-supported encryption.' - : 'Connect a Jira Cloud site with your Atlassian email and an API token. Credentials are stored locally and encrypted when local runtime storage supports it.' + ? translate( + 'auto.components.settings.task.tracker.integration.cards.2d60ec7921', + 'Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are sent to the selected remote runtime and stored there with runtime-supported encryption.' + ) + : translate( + 'auto.components.settings.task.tracker.integration.cards.977e360b71', + 'Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are stored locally and encrypted when local runtime storage supports it.' + ) const subordinateRowClass = useIntegrationSubordinateRowClass('flex items-center gap-3') const accountScopeRowClass = useIntegrationSubordinateRowClass('text-xs') diff --git a/src/renderer/src/components/settings/terminal-search.test.ts b/src/renderer/src/components/settings/terminal-search.test.ts index 5811f5dcf02..bf642e83735 100644 --- a/src/renderer/src/components/settings/terminal-search.test.ts +++ b/src/renderer/src/components/settings/terminal-search.test.ts @@ -172,6 +172,15 @@ describe('getTerminalPaneSearchEntries', () => { expect(matchesSettingsSearch('tray', webEntries)).toBe(false) }) + it('includes the macOS menu bar entry only when its desktop control is shown', () => { + const macEntries = getAppearancePaneSearchEntries({ showMenuBarIcon: true }) + const otherEntries = getAppearancePaneSearchEntries({ showMenuBarIcon: false }) + + expect(macEntries.some((entry) => entry.title === 'Show Menu Bar Icon')).toBe(true) + expect(otherEntries.some((entry) => entry.title === 'Show Menu Bar Icon')).toBe(false) + expect(matchesSettingsSearch('status item', macEntries)).toBe(true) + }) + it('keeps sidebar shortcut restore settings in the Appearance search index', () => { const automationsEntry = getSidebarEntries().find( (entry) => entry.title === 'Show Automations Button' diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 73726d09ae0..81671c20616 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -142,7 +142,10 @@ import { type ScrollToCurrentWorkspaceRevealRequestDetail } from '@/lib/scroll-to-current-workspace-status' import { isRepoHeaderActionTarget, useRepoHeaderDrag } from './project-header-drag' -import { getSidebarOrderedRepoHeaderIdsByBucket } from './project-header-drop' +import { + getLogicalRepoOrderRankById, + getSidebarOrderedRepoHeaderIdsByBucket +} from './project-header-drop' import { useProjectGroupHeaderDrag } from './project-group-header-drag' import { getSidebarOrderedProjectGroupHeaderIdsByBucket } from './project-group-header-drop' import { @@ -269,7 +272,7 @@ import { buildSidebarHostOptions } from './sidebar-host-options' import { HostSectionHeaderMenu } from './HostSectionHeaderMenu' import { ProjectHeaderActions } from './ProjectHeaderActions' import { translate } from '@/i18n/i18n' -import { folderWorkspaceKey, parseWorkspaceKey } from '../../../../shared/workspace-scope' +import { folderWorkspaceKey, getActiveSidebarWorkspaceId } from '../../../../shared/workspace-scope' import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides' import { isConfirmedStaleFolderPathStatus, @@ -407,22 +410,6 @@ function getWorktreeOptionId(rowKey: string): string { return `worktree-list-option-${encodeURIComponent(rowKey)}` } -// Why: folder workspaces are tracked by the scoped active key, while older -// worktree-only paths still read activeWorktreeId. -function getActiveSidebarWorkspaceId( - activeWorkspaceKey: string | null, - activeWorktreeId: string | null -): string | null { - const scope = activeWorkspaceKey ? parseWorkspaceKey(activeWorkspaceKey) : null - if (scope?.type === 'folder') { - return folderWorkspaceKey(scope.folderWorkspaceId) - } - if (scope?.type === 'worktree') { - return scope.worktreeId - } - return activeWorktreeId -} - function getMountedWorktreeOptions(worktreeId: string, root?: ParentNode | null): HTMLElement[] { const scope = root ?? document const result: HTMLElement[] = [] @@ -5686,9 +5673,7 @@ const WorktreeList = React.memo(function WorktreeList({ }) }, [defaultHostId, folderWorkspaces, projectGroups, visibleHostIdSet]) const repoOrder = useMemo(() => { - const map = new Map() - repos.forEach((r, i) => map.set(r.id, i)) - return map + return getLogicalRepoOrderRankById(repos.map((repo) => repo.id)) }, [repos]) const [importedWorktreeCardActionState, setImportedWorktreeCardActionState] = useState< Map diff --git a/src/renderer/src/components/sidebar/project-header-drag-commit.test.ts b/src/renderer/src/components/sidebar/project-header-drag-commit.test.ts index 70a40d6d250..7b5a72def55 100644 --- a/src/renderer/src/components/sidebar/project-header-drag-commit.test.ts +++ b/src/renderer/src/components/sidebar/project-header-drag-commit.test.ts @@ -53,6 +53,42 @@ describe('commitProjectHeaderDragDrop', () => { expect(onCommitRepoOrder).toHaveBeenCalledWith(['c', 'a', 'b']) }) + it('moves a merged paired-host header upward as one stable block', () => { + const onCommitRepoOrder = vi.fn() + const repos = [makeRepo('b'), makeRepo('same'), makeRepo('c')] + const repoById = new Map(repos.map((repo) => [repo.id, repo])) + + commitProjectHeaderDragDrop({ + session: makeSession('same', ['b', 'same', 'c']), + sidebarDropIndex: 0, + orderedRepoIds: ['b', 'same', 'c', 'same'], + repoById, + usesProjectGroupOrdering: false, + onCommitRepoOrder, + onCommitProjectGroupOrder: vi.fn() + }) + + expect(onCommitRepoOrder).toHaveBeenCalledWith(['same', 'same', 'b', 'c']) + }) + + it('does not reorder host occurrences when a merged header stays in place', () => { + const onCommitRepoOrder = vi.fn() + const repos = [makeRepo('b'), makeRepo('same'), makeRepo('c')] + const repoById = new Map(repos.map((repo) => [repo.id, repo])) + + commitProjectHeaderDragDrop({ + session: makeSession('same', ['b', 'same', 'c']), + sidebarDropIndex: 2, + orderedRepoIds: ['b', 'same', 'c', 'same'], + repoById, + usesProjectGroupOrdering: false, + onCommitRepoOrder, + onCommitProjectGroupOrder: vi.fn() + }) + + expect(onCommitRepoOrder).not.toHaveBeenCalled() + }) + it('commits projectGroupOrder when project groups are present', () => { const onCommitProjectGroupOrder = vi.fn() const repos = [ diff --git a/src/renderer/src/components/sidebar/project-header-drag-commit.ts b/src/renderer/src/components/sidebar/project-header-drag-commit.ts index 43c513905a6..2ab63663d71 100644 --- a/src/renderer/src/components/sidebar/project-header-drag-commit.ts +++ b/src/renderer/src/components/sidebar/project-header-drag-commit.ts @@ -1,5 +1,6 @@ import { applyAllRepoInsertAt, + getLogicalRepoOrderRankById, getProjectGroupOrderForSidebarDrop, mapSidebarProjectHeaderDropIndexToSiblingInsertIndex, mapSidebarRepoDropIndexToAllRepoInsertAt @@ -23,7 +24,13 @@ export function commitProjectHeaderDragDrop(args: { const sidebarRepoHeaderIds = args.session.sidebarRepoHeaderIds const sourceIndex = sidebarRepoHeaderIds.indexOf(args.session.repoId) - if (args.sidebarDropIndex === sourceIndex) { + // Why: both slots bordering the dragged header are visual no-ops. In + // particular, do not compact paired-host occurrences on an unchanged drop. + if ( + sourceIndex === -1 || + args.sidebarDropIndex === sourceIndex || + args.sidebarDropIndex === sourceIndex + 1 + ) { return } @@ -46,9 +53,7 @@ export function commitProjectHeaderDragDrop(args: { if (siblingDropIndex === sourceIndexInSiblings) { return } - const repoOrderRankById = new Map( - args.orderedRepoIds.map((repoId, index) => [repoId, index] as const) - ) + const repoOrderRankById = getLogicalRepoOrderRankById(args.orderedRepoIds) const order = getProjectGroupOrderForSidebarDrop({ siblings, dropIndex: siblingDropIndex, diff --git a/src/renderer/src/components/sidebar/project-header-drop.test.ts b/src/renderer/src/components/sidebar/project-header-drop.test.ts index dee5572e02f..c0382a5f162 100644 --- a/src/renderer/src/components/sidebar/project-header-drop.test.ts +++ b/src/renderer/src/components/sidebar/project-header-drop.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest' import { applyAllRepoInsertAt, computeProjectHeaderDropPreview, + getLogicalRepoOrderRankById, getProjectGroupOrderForSidebarDrop, getProjectHeaderDragBucketKey, getSidebarOrderedRepoHeaderIdsByBucket, @@ -53,6 +54,20 @@ describe('getSidebarOrderedRepoHeaderIdsByBucket', () => { }) }) +describe('getLogicalRepoOrderRankById', () => { + it('anchors a merged paired-host header to its first persisted occurrence', () => { + const rankById = getLogicalRepoOrderRankById(['b', 'same', 'c', 'same']) + + expect(rankById).toEqual( + new Map([ + ['b', 0], + ['same', 1], + ['c', 2] + ]) + ) + }) +}) + describe('mapSidebarRepoDropIndexToAllRepoInsertAt', () => { const sidebar = ['a', 'b', 'c'] @@ -320,6 +335,15 @@ describe('applyAllRepoInsertAt', () => { ]) }) + it('moves duplicate host occurrences as one stable logical-project block', () => { + expect(applyAllRepoInsertAt(['b', 'same', 'c', 'same'], 'same', 0)).toEqual([ + 'same', + 'same', + 'b', + 'c' + ]) + }) + it('returns null for no-op reorders', () => { expect(applyAllRepoInsertAt(['a', 'b', 'c'], 'b', 2)).toBeNull() }) diff --git a/src/renderer/src/components/sidebar/project-header-drop.ts b/src/renderer/src/components/sidebar/project-header-drop.ts index 00b7169b97e..be012eb72ce 100644 --- a/src/renderer/src/components/sidebar/project-header-drop.ts +++ b/src/renderer/src/components/sidebar/project-header-drop.ts @@ -54,6 +54,20 @@ export function getSidebarOrderedRepoHeaderIdsByBucket( return buckets } +export function getLogicalRepoOrderRankById( + orderedRepoIds: readonly string[] +): Map { + const rankById = new Map() + orderedRepoIds.forEach((repoId, index) => { + // Why: paired hosts can contribute the same logical project ID; its merged + // header must anchor to the first occurrence instead of whichever host loaded last. + if (!rankById.has(repoId)) { + rankById.set(repoId, index) + } + }) + return rankById +} + export function getProjectGroupOrderForSidebarDrop(args: { siblings: readonly Repo[] dropIndex: number @@ -203,16 +217,20 @@ export function applyAllRepoInsertAt( draggedRepoId: string, insertAt: number ): string[] | null { - const fromIndex = allRepoIds.indexOf(draggedRepoId) - if (fromIndex === -1 || insertAt < 0 || insertAt > allRepoIds.length) { + if (!allRepoIds.includes(draggedRepoId) || insertAt < 0 || insertAt > allRepoIds.length) { return null } - const next = allRepoIds.slice() - next.splice(fromIndex, 1) - const adjustedInsertAt = insertAt > fromIndex ? insertAt - 1 : insertAt - if (adjustedInsertAt === fromIndex) { + // Why: one merged header controls every host-qualified occurrence; moving + // them together prevents a drag from persisting a cross-host split project. + const draggedBlock = allRepoIds.filter((repoId) => repoId === draggedRepoId) + const removedBeforeInsert = allRepoIds + .slice(0, insertAt) + .filter((repoId) => repoId === draggedRepoId).length + const adjustedInsertAt = insertAt - removedBeforeInsert + const next = allRepoIds.filter((repoId) => repoId !== draggedRepoId) + next.splice(adjustedInsertAt, 0, ...draggedBlock) + if (next.every((repoId, index) => repoId === allRepoIds[index])) { return null } - next.splice(adjustedInsertAt, 0, draggedRepoId) return next } diff --git a/src/renderer/src/components/skills/SkillFreshnessNudge.test.tsx b/src/renderer/src/components/skills/SkillFreshnessNudge.test.tsx new file mode 100644 index 00000000000..ca0090a6c68 --- /dev/null +++ b/src/renderer/src/components/skills/SkillFreshnessNudge.test.tsx @@ -0,0 +1,230 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + SkillFreshnessInstallation, + SkillFreshnessInventory +} from '../../../../shared/skill-freshness' +import { SkillFreshnessNudge } from './SkillFreshnessNudge' + +const mocks = vi.hoisted(() => ({ + dismissed: [] as string[], + updateSettings: vi.fn(), + toastInfo: vi.fn(), + toastDismiss: vi.fn(), + requestDialog: vi.fn(), + settingsLoaded: true, + inventory: null as SkillFreshnessInventory | null, + error: null as string | null +})) + +function placement( + overrides: Partial = {} +): SkillFreshnessInstallation { + return { + id: 'orca-cli', + name: 'orca-cli', + rootId: 'home-agents', + providers: ['agent-skills'], + sourceKind: 'home', + sourceLabel: 'Agent skills home', + unresolvedPath: '/home/.agents/skills/orca-cli', + resolvedPath: '/home/.agents/skills/orca-cli', + physicalIdentity: 'physical-orca-cli', + topology: 'canonical-copy', + status: 'outdated', + installedReleaseRevision: 1, + installedAppVersion: '1.0.0', + currentReleaseRevision: 2, + currentPackageDigest: 'current', + currentAppVersion: '2.0.0', + observedPackageDigest: 'old', + errorCategory: null, + ...overrides + } +} + +function eligibleInventory(): SkillFreshnessInventory { + return { + schemaVersion: 1, + installations: [placement()], + eligibleUpdateNames: ['orca-cli'], + scannedAt: 1 + } +} + +vi.mock('@/hooks/useSkillFreshness', () => ({ + useSkillFreshness: () => ({ + inventory: mocks.inventory, + loading: false, + error: mocks.error, + refresh: vi.fn() + }) +})) + +vi.mock('sonner', () => ({ + toast: { info: mocks.toastInfo, dismiss: mocks.toastDismiss } +})) + +vi.mock('./skill-freshness-update-dialog', () => ({ + requestSkillFreshnessUpdateDialog: mocks.requestDialog +})) + +vi.mock('@/store', () => { + const state = () => ({ + settings: mocks.settingsLoaded ? { dismissedSkillFreshnessNudges: mocks.dismissed } : null, + updateSettings: mocks.updateSettings + }) + const useAppStore = (selector: (value: ReturnType) => unknown) => selector(state()) + useAppStore.getState = state + return { useAppStore } +}) + +let root: Root | null = null +let container: HTMLDivElement | null = null + +async function renderNudge(): Promise { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + await act(async () => { + root?.render() + }) +} + +async function rerenderNudge(): Promise { + await act(async () => { + root?.render() + }) +} + +const DISMISSAL_KEY = ['physical-orca-cli', 'orca-cli', '2'].join('\0') + +describe('SkillFreshnessNudge', () => { + beforeEach(() => { + mocks.dismissed = [] + mocks.settingsLoaded = true + mocks.inventory = eligibleInventory() + mocks.error = null + mocks.updateSettings.mockReset() + mocks.updateSettings.mockResolvedValue(undefined) + mocks.toastInfo.mockReset() + mocks.toastInfo.mockReturnValue('freshness-toast') + mocks.toastDismiss.mockReset() + mocks.requestDialog.mockReset() + }) + + afterEach(async () => { + if (root) { + await act(async () => root?.unmount()) + } + root = null + container?.remove() + container = null + }) + + it('lingers without an auto-close timer or auto-close dismissal write', async () => { + await renderNudge() + + expect(mocks.toastInfo).toHaveBeenCalledTimes(1) + const options = mocks.toastInfo.mock.calls[0]?.[1] + expect(options.duration).toBe(Number.POSITIVE_INFINITY) + expect(options.onAutoClose).toBeUndefined() + expect(mocks.updateSettings).not.toHaveBeenCalled() + }) + + it('opens the update dialog on action click without persisting a dismissal', async () => { + await renderNudge() + + const options = mocks.toastInfo.mock.calls[0]?.[1] + options.action.onClick() + options.onDismiss() + + expect(mocks.requestDialog).toHaveBeenCalledTimes(1) + expect(mocks.updateSettings).not.toHaveBeenCalled() + }) + + it('retracts a resolved nudge without recording a dismissal', async () => { + await renderNudge() + const options = mocks.toastInfo.mock.calls[0]?.[1] + + mocks.inventory = { + schemaVersion: 1, + installations: [placement({ status: 'current', observedPackageDigest: 'current' })], + eligibleUpdateNames: [], + scannedAt: 2 + } + await rerenderNudge() + // Sonner invokes onDismiss for programmatic dismissals on its next render. + options.onDismiss() + + expect(mocks.toastDismiss).toHaveBeenCalledWith('freshness-toast') + expect(mocks.updateSettings).not.toHaveBeenCalled() + }) + + it('retracts a stale nudge when re-inventory fails', async () => { + await renderNudge() + const options = mocks.toastInfo.mock.calls[0]?.[1] + + mocks.inventory = null + mocks.error = 'scan failed' + await rerenderNudge() + options.onDismiss() + + expect(mocks.toastDismiss).toHaveBeenCalledWith('freshness-toast') + expect(mocks.updateSettings).not.toHaveBeenCalled() + }) + + it('persists the exact placement/revision key once on explicit dismissal', async () => { + await renderNudge() + + const options = mocks.toastInfo.mock.calls[0]?.[1] + options.onDismiss() + options.onDismiss() + + expect(mocks.updateSettings).toHaveBeenCalledTimes(1) + expect(mocks.updateSettings).toHaveBeenCalledWith({ + dismissedSkillFreshnessNudges: [DISMISSAL_KEY] + }) + }) + + it('does not repeat the same nudge within a session', async () => { + await renderNudge() + // A fresh inventory object with identical content re-runs the effect. + mocks.inventory = eligibleInventory() + await rerenderNudge() + + expect(mocks.toastInfo).toHaveBeenCalledTimes(1) + }) + + it('does not repeat a nudge for an already dismissed exact tuple', async () => { + mocks.dismissed = [DISMISSAL_KEY] + + await renderNudge() + + expect(mocks.toastInfo).not.toHaveBeenCalled() + }) + + it('does not nudge for a poisoned name with no eligible update', async () => { + mocks.inventory = { + schemaVersion: 1, + installations: [placement(), placement({ id: 'repo-copy', topology: 'repo-scope' })], + eligibleUpdateNames: [], + scannedAt: 1 + } + + await renderNudge() + + expect(mocks.toastInfo).not.toHaveBeenCalled() + }) + + it('waits for persisted settings before deciding whether to nudge', async () => { + mocks.settingsLoaded = false + + await renderNudge() + + expect(mocks.toastInfo).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/skills/SkillFreshnessNudge.tsx b/src/renderer/src/components/skills/SkillFreshnessNudge.tsx new file mode 100644 index 00000000000..135d860656b --- /dev/null +++ b/src/renderer/src/components/skills/SkillFreshnessNudge.tsx @@ -0,0 +1,183 @@ +import { useEffect, useRef } from 'react' +import { Terminal } from 'lucide-react' +import { toast } from 'sonner' +import { useSkillFreshness } from '@/hooks/useSkillFreshness' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import { requestSkillFreshnessUpdateDialog } from './skill-freshness-update-dialog' + +const MAX_DISMISSED_FRESHNESS_NUDGES = 512 +const NO_DISMISSED_FRESHNESS_NUDGES: string[] = [] + +type ActiveFreshnessNudge = { + id: string | number + fingerprint: string + persistDismissal: boolean +} + +function candidateKey(args: { + physicalIdentity: string + name: string + currentReleaseRevision: number +}): string { + return [args.physicalIdentity, args.name, args.currentReleaseRevision].join('\0') +} + +export function SkillFreshnessNudge(): null { + const state = useSkillFreshness() + const settingsLoaded = useAppStore((store) => store.settings !== null) + const dismissed = useAppStore( + (store) => store.settings?.dismissedSkillFreshnessNudges ?? NO_DISMISSED_FRESHNESS_NUDGES + ) + const updateSettings = useAppStore((store) => store.updateSettings) + const shownFingerprints = useRef(new Set()) + const persistedFingerprints = useRef(new Set()) + const activeNudgeRef = useRef(null) + + useEffect(() => { + const inventory = state.inventory + if (!settingsLoaded) { + return + } + if (!inventory) { + const active = activeNudgeRef.current + if (state.error && active) { + // Why: a failed re-check cannot keep advertising authority derived from + // old bytes; retract without turning the scan failure into a dismissal. + active.persistDismissal = false + activeNudgeRef.current = null + toast.dismiss(active.id) + } + return + } + const eligibleNames = new Set(inventory.eligibleUpdateNames) + const candidates = inventory.installations.flatMap((installation) => + installation.status === 'outdated' && + eligibleNames.has(installation.name) && + installation.physicalIdentity + ? [ + { + key: candidateKey({ + physicalIdentity: installation.physicalIdentity, + name: installation.name, + currentReleaseRevision: installation.currentReleaseRevision + }), + name: installation.name + } + ] + : [] + ) + const dismissedKeys = new Set(dismissed) + const unseen = candidates.filter((candidate) => !dismissedKeys.has(candidate.key)) + if (unseen.length === 0) { + const active = activeNudgeRef.current + if (active) { + // Why: a resolved/replaced nudge is stale presentation, not an explicit + // user dismissal, so retract it without persisting its tuple keys. + active.persistDismissal = false + activeNudgeRef.current = null + toast.dismiss(active.id) + } + return + } + const fingerprint = unseen + .map((candidate) => candidate.key) + .sort((left, right) => left.localeCompare(right, 'en')) + .join('\n') + const active = activeNudgeRef.current + if (active?.fingerprint === fingerprint) { + return + } + if (active) { + active.persistDismissal = false + activeNudgeRef.current = null + toast.dismiss(active.id) + } + if (shownFingerprints.current.has(fingerprint)) { + return + } + shownFingerprints.current.add(fingerprint) + + const persistDismissal = (): void => { + if (persistedFingerprints.current.has(fingerprint)) { + return + } + persistedFingerprints.current.add(fingerprint) + const current = useAppStore.getState().settings?.dismissedSkillFreshnessNudges ?? [] + const next = [...new Set([...current, ...unseen.map((candidate) => candidate.key)])].slice( + -MAX_DISMISSED_FRESHNESS_NUDGES + ) + void updateSettings({ dismissedSkillFreshnessNudges: next }).catch(() => { + persistedFingerprints.current.delete(fingerprint) + }) + } + const names = new Set(unseen.map((candidate) => candidate.name)) + // Why: name the outdated skills so the nudge is actionable without opening + // the modal; the sentence is translatable but the identifiers interpolate as-is. + const outdatedNames = [...names] + .sort((left, right) => left.localeCompare(right, 'en')) + .join(', ') + const nextActive: ActiveFreshnessNudge = { + id: '', + fingerprint, + persistDismissal: true + } + nextActive.id = toast.info( + names.size === 1 + ? translate( + 'auto.components.skills.SkillFreshnessNudge.titleOne', + 'An installed Orca skill is out of date' + ) + : translate( + 'auto.components.skills.SkillFreshnessNudge.titleMany', + '{{value0}} installed Orca skills are out of date', + { value0: names.size } + ), + { + description: translate( + 'auto.components.skills.SkillFreshnessNudge.description', + 'Update {{value0}} so agents follow the current instructions for this version of Orca.', + { value0: outdatedNames } + ), + // Why: the nudge lingers until the user acts. Ignoring it (app quit) + // records nothing, so a still-outdated skill may prompt once next launch. + duration: Number.POSITIVE_INFINITY, + // Why: only an explicit dismissal (the close button) records the keys; + // opening the review dialog is engagement, not a decision to hide it. + onDismiss: () => { + if (nextActive.persistDismissal) { + persistDismissal() + } + if (activeNudgeRef.current === nextActive) { + activeNudgeRef.current = null + } + }, + action: { + label: ( + + + {names.size === 1 + ? translate('auto.components.skills.SkillFreshnessNudge.updateOne', 'Update skill') + : translate( + 'auto.components.skills.SkillFreshnessNudge.updateMany', + 'Update skills' + )} + + ), + onClick: () => { + // Sonner closes action toasts without onDismiss; clear ownership so + // a later inventory cannot treat the already-closed toast as active. + nextActive.persistDismissal = false + if (activeNudgeRef.current === nextActive) { + activeNudgeRef.current = null + } + requestSkillFreshnessUpdateDialog() + } + } + } + ) + activeNudgeRef.current = nextActive + }, [dismissed, settingsLoaded, state.error, state.inventory, updateSettings]) + + return null +} diff --git a/src/renderer/src/components/skills/SkillFreshnessStatusPill.test.tsx b/src/renderer/src/components/skills/SkillFreshnessStatusPill.test.tsx new file mode 100644 index 00000000000..785b67aaf07 --- /dev/null +++ b/src/renderer/src/components/skills/SkillFreshnessStatusPill.test.tsx @@ -0,0 +1,107 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { SkillFreshnessInventory } from '../../../../shared/skill-freshness' +import { SkillFreshnessStatusPill } from './SkillFreshnessStatusPill' + +const mocks = vi.hoisted(() => ({ + inventory: null as SkillFreshnessInventory | null +})) + +vi.mock('@/hooks/useSkillFreshness', () => ({ + useSkillFreshness: () => ({ + inventory: mocks.inventory, + loading: false, + error: null, + refresh: vi.fn() + }) +})) + +function inventory( + entries: { name: string; status: 'current' | 'outdated' | 'unrecognized' }[], + eligibleUpdateNames: string[] +): SkillFreshnessInventory { + return { + schemaVersion: 1, + installations: entries.map((entry, index) => ({ + id: `${entry.name}-${index}`, + name: entry.name, + rootId: 'home-agents', + providers: ['agent-skills'], + sourceKind: 'home', + sourceLabel: 'Agent skills home', + unresolvedPath: `/home/.agents/skills/${entry.name}`, + resolvedPath: `/home/.agents/skills/${entry.name}`, + physicalIdentity: `physical-${entry.name}-${index}`, + topology: 'canonical-copy', + status: entry.status, + installedReleaseRevision: 1, + installedAppVersion: '1.0.0', + currentReleaseRevision: 2, + currentPackageDigest: 'current', + currentAppVersion: '2.0.0', + observedPackageDigest: 'old', + errorCategory: null + })), + eligibleUpdateNames, + scannedAt: 1 + } +} + +let root: Root | null = null +let container: HTMLDivElement | null = null + +async function renderPill(skillName: string): Promise { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + await act(async () => { + root?.render() + }) + return container +} + +describe('SkillFreshnessStatusPill', () => { + beforeEach(() => { + mocks.inventory = null + }) + + afterEach(async () => { + if (root) { + await act(async () => root?.unmount()) + } + root = null + container?.remove() + container = null + }) + + it('shows Update available for an eligible outdated skill', async () => { + mocks.inventory = inventory([{ name: 'orca-cli', status: 'outdated' }], ['orca-cli']) + + expect((await renderPill('orca-cli')).textContent).toBe('Update available') + }) + + it('shows Up to date when every placement is current', async () => { + mocks.inventory = inventory([{ name: 'orca-cli', status: 'current' }], []) + + expect((await renderPill('orca-cli')).textContent).toBe('Up to date') + }) + + it('falls back to Installed for a blocked outdated placement', async () => { + mocks.inventory = inventory( + [ + { name: 'orca-cli', status: 'outdated' }, + { name: 'orca-cli', status: 'unrecognized' } + ], + [] + ) + + expect((await renderPill('orca-cli')).textContent).toBe('Installed') + }) + + it('falls back to Installed before the inventory loads', async () => { + expect((await renderPill('orca-cli')).textContent).toBe('Installed') + }) +}) diff --git a/src/renderer/src/components/skills/SkillFreshnessStatusPill.tsx b/src/renderer/src/components/skills/SkillFreshnessStatusPill.tsx new file mode 100644 index 00000000000..3c858686591 --- /dev/null +++ b/src/renderer/src/components/skills/SkillFreshnessStatusPill.tsx @@ -0,0 +1,40 @@ +import { useSkillFreshness } from '@/hooks/useSkillFreshness' +import { translate } from '@/i18n/i18n' +import { IntegrationStatusPill } from '@/components/integration-status-pill' + +// Why: the setup rails' Installed pill is presence-only; when freshness knows a +// safe update exists (or that every copy is current) the pill should say so. +// Falls back to plain Installed for blocked/unrecognized copies so an unsafe +// placement is never advertised as updatable here. +export function SkillFreshnessStatusPill({ skillName }: { skillName: string }): React.JSX.Element { + const { inventory } = useSkillFreshness() + if (inventory?.eligibleUpdateNames.includes(skillName)) { + return ( + + {translate( + 'auto.components.skills.SkillFreshnessStatusPill.updateAvailable', + 'Update available' + )} + + ) + } + const placements = inventory?.installations.filter( + (installation) => installation.name === skillName + ) + if ( + placements && + placements.length > 0 && + placements.every((installation) => installation.status === 'current') + ) { + return ( + + {translate('auto.components.skills.SkillFreshnessStatusPill.upToDate', 'Up to date')} + + ) + } + return ( + + {translate('auto.components.skills.SkillFreshnessStatusPill.installed', 'Installed')} + + ) +} diff --git a/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx b/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx new file mode 100644 index 00000000000..a810ae06cc6 --- /dev/null +++ b/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx @@ -0,0 +1,371 @@ +// @vitest-environment happy-dom + +import { act, useState, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + SkillFreshnessInstallation, + SkillFreshnessInventory +} from '../../../../shared/skill-freshness' +import { SkillFreshnessUpdateDialog } from './SkillFreshnessUpdateDialog' +import { + consumeSkillFreshnessUpdateDialogRequest, + requestSkillFreshnessUpdateDialog +} from './skill-freshness-update-dialog' + +const mocks = vi.hoisted(() => ({ + inventory: null as SkillFreshnessInventory | null, + loading: false, + error: null as string | null, + refresh: vi.fn(), + terminalProps: [] as { + command: string + description: string + onInteracted?: (method: 'keyboard' | 'pointer', event?: { key?: string }) => void + onTerminalExit?: () => void + }[], + notifyChanged: vi.fn() +})) + +vi.mock('@/hooks/useSkillFreshness', () => ({ + useSkillFreshness: () => ({ + inventory: mocks.inventory, + loading: mocks.loading, + error: mocks.error, + refresh: mocks.refresh + }) +})) + +vi.mock('@/hooks/useInstalledAgentSkills', () => ({ + notifyInstalledAgentSkillsChanged: mocks.notifyChanged +})) + +vi.mock('@/components/onboarding/OnboardingInlineCommandTerminal', () => ({ + OnboardingInlineCommandTerminal: (props: (typeof mocks.terminalProps)[number]) => { + mocks.terminalProps.push(props) + return
{props.command}
+ } +})) + +// Radix Dialog/Collapsible internals (portal, focus-scope) are exercised in +// Electron QA; here the content logic is what matters, so use plain wrappers. +vi.mock('@/components/ui/dialog', () => ({ + Dialog: ({ open, children }: { open: boolean; children?: ReactNode }) => + open ?
{children}
: null, + DialogContent: ({ children }: { children?: ReactNode }) =>
{children}
, + DialogDescription: ({ children }: { children?: ReactNode }) =>

{children}

, + DialogFooter: ({ children }: { children?: ReactNode }) =>
{children}
, + DialogHeader: ({ children }: { children?: ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children?: ReactNode }) =>

{children}

+})) + +vi.mock('@/components/ui/collapsible', () => ({ + Collapsible: ({ + children, + defaultOpen = false + }: { + children?: ReactNode + defaultOpen?: boolean + }) => { + // Model Radix's uncontrolled default: changing defaultOpen only matters + // when the keyed disclosure remounts. + const [open] = useState(defaultOpen) + return
{children}
+ }, + CollapsibleTrigger: ({ children }: { children?: ReactNode }) =>
{children}
, + CollapsibleContent: ({ children }: { children?: ReactNode }) =>
{children}
+})) + +function placement( + name: string, + overrides: Partial = {} +): SkillFreshnessInstallation { + return { + id: `${name}-${overrides.rootId ?? 'home-agents'}`, + name, + rootId: 'home-agents', + providers: ['agent-skills'], + sourceKind: 'home', + sourceLabel: 'Agent skills home', + unresolvedPath: `/home/.agents/skills/${name}`, + resolvedPath: `/home/.agents/skills/${name}`, + physicalIdentity: `physical-${name}`, + topology: 'canonical-copy', + status: 'outdated', + installedReleaseRevision: 1, + installedAppVersion: '1.0.0', + currentReleaseRevision: 2, + currentPackageDigest: 'current', + currentAppVersion: '2.0.0', + observedPackageDigest: 'old', + errorCategory: null, + ...overrides + } +} + +function eligibleInventory(): SkillFreshnessInventory { + return { + schemaVersion: 1, + installations: [placement('orca-cli')], + eligibleUpdateNames: ['orca-cli'], + scannedAt: 1 + } +} + +let root: Root | null = null +let container: HTMLDivElement | null = null + +async function renderDialog(): Promise { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + await act(async () => { + root?.render() + }) +} + +async function rerender(): Promise { + await act(async () => { + root?.render() + }) +} + +async function openViaRequest(): Promise { + await act(async () => { + requestSkillFreshnessUpdateDialog() + }) +} + +async function clickButton(label: string): Promise { + const button = Array.from(container?.querySelectorAll('button') ?? []).find( + (candidate) => candidate.textContent?.trim() === label + ) + expect(button).toBeDefined() + await act(async () => { + button?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) +} + +describe('SkillFreshnessUpdateDialog', () => { + beforeEach(() => { + consumeSkillFreshnessUpdateDialogRequest() + mocks.inventory = eligibleInventory() + mocks.loading = false + mocks.error = null + mocks.refresh.mockReset() + mocks.notifyChanged.mockReset() + mocks.terminalProps.length = 0 + }) + + afterEach(async () => { + if (root) { + await act(async () => root?.unmount()) + } + root = null + container?.remove() + container = null + }) + + it('stays closed until an open request arrives', async () => { + await renderDialog() + expect(container?.querySelector('[data-dialog-open]')).toBeNull() + }) + + it('shows the eligible summary and the exact pre-filled draft command', async () => { + await renderDialog() + await openViaRequest() + + expect(container?.textContent).toContain('Update skills') + expect(container?.textContent).toContain('1 skill can be updated safely') + expect(mocks.terminalProps.at(-1)).toMatchObject({ + command: 'npx skills update orca-cli --global', + description: 'Review the pre-filled command, then press Enter to run it.' + }) + }) + + it('resolves a request made before inventory loads once a safe command arrives', async () => { + mocks.inventory = null + await openViaRequest() + await renderDialog() + + expect(container?.querySelector('[data-dialog-open]')).not.toBeNull() + expect(mocks.terminalProps).toEqual([]) + + mocks.inventory = eligibleInventory() + await rerender() + + expect(mocks.terminalProps.at(-1)?.command).toBe('npx skills update orca-cli --global') + }) + + it('shows the up-to-date state once every installation is current', async () => { + await renderDialog() + await openViaRequest() + + mocks.inventory = { + schemaVersion: 1, + installations: [ + placement('orca-cli', { status: 'current', observedPackageDigest: 'current' }) + ], + eligibleUpdateNames: [], + scannedAt: 2 + } + await rerender() + + expect(container?.textContent).toContain('All installed Orca skills are up to date.') + expect(container?.querySelector('[data-testid="update-terminal"]')).toBeNull() + }) + + it('auto-expands Details when a rescan changes the result to blocked', async () => { + await renderDialog() + await openViaRequest() + expect(container?.querySelector('[data-collapsible-open="false"]')).not.toBeNull() + + mocks.inventory = { + schemaVersion: 1, + installations: [placement('orca-cli', { topology: 'read-only' })], + eligibleUpdateNames: [], + scannedAt: 2 + } + await rerender() + + expect(container?.querySelector('[data-collapsible-open="true"]')).not.toBeNull() + }) + + it('does not replace or tear down a submitted command during a rescan', async () => { + await renderDialog() + await openViaRequest() + + await act(async () => { + mocks.terminalProps.at(-1)?.onInteracted?.('keyboard', { key: 'Enter' }) + }) + mocks.inventory = null + mocks.loading = true + await rerender() + + expect(container?.querySelector('[data-testid="update-terminal"]')?.textContent).toBe( + 'npx skills update orca-cli --global' + ) + + mocks.inventory = { + schemaVersion: 1, + installations: [ + placement('orca-cli', { status: 'current', observedPackageDigest: 'current' }) + ], + eligibleUpdateNames: [], + scannedAt: 2 + } + mocks.loading = false + await rerender() + + expect(container?.textContent).toContain('All installed Orca skills are up to date.') + expect(container?.querySelector('[data-testid="update-terminal"]')?.textContent).toBe( + 'npx skills update orca-cli --global' + ) + }) + + it('removes an unsubmitted draft as soon as its inventory is invalidated', async () => { + await renderDialog() + await openViaRequest() + expect(container?.querySelector('[data-testid="update-terminal"]')).not.toBeNull() + + mocks.inventory = null + mocks.loading = true + await rerender() + + expect(container?.textContent).toContain('Checking installed Orca skills') + expect(container?.querySelector('[data-testid="update-terminal"]')).toBeNull() + }) + + it('shows a failed scan as an error instead of indefinite progress', async () => { + mocks.inventory = null + mocks.error = 'Could not inspect installed skills.' + await renderDialog() + await openViaRequest() + + expect(container?.textContent).toContain('Could not inspect installed skills.') + expect(container?.textContent).not.toContain('Checking installed Orca skills') + expect(container?.querySelector('[data-testid="update-terminal"]')).toBeNull() + }) + + it('creates a fresh draft after a terminal exits and the rescan still finds an update', async () => { + await renderDialog() + await openViaRequest() + const firstTerminal = mocks.terminalProps.at(-1) + + await act(async () => { + firstTerminal?.onTerminalExit?.() + }) + expect(mocks.notifyChanged).toHaveBeenCalledTimes(1) + expect(container?.querySelector('[data-testid="update-terminal"]')).toBeNull() + + mocks.inventory = { ...eligibleInventory(), scannedAt: 2 } + await rerender() + + expect(container?.querySelector('[data-testid="update-terminal"]')?.textContent).toBe( + 'npx skills update orca-cli --global' + ) + expect(mocks.terminalProps.at(-1)).not.toBe(firstTerminal) + }) + + it('explains a poisoned sibling in Details without offering a command', async () => { + mocks.inventory = { + schemaVersion: 1, + installations: [ + placement('orca-cli'), + placement('orca-cli', { + id: 'repo-copy', + rootId: 'repo', + sourceKind: 'repo', + topology: 'repo-scope', + status: 'unrecognized', + unresolvedPath: '/repo/.agents/skills/orca-cli' + }) + ], + eligibleUpdateNames: [], + scannedAt: 1 + } + await renderDialog() + await openViaRequest() + + expect(container?.textContent).toContain( + 'it may be modified, or a different skill with the same name' + ) + expect(container?.textContent).toContain('Unrecognized') + expect(container?.querySelector('[data-testid="update-terminal"]')).toBeNull() + }) + + it('explains a self-blocked read-only placement without blaming a sibling', async () => { + mocks.inventory = { + schemaVersion: 1, + installations: [placement('orca-cli', { topology: 'read-only' })], + eligibleUpdateNames: [], + scannedAt: 1 + } + await renderDialog() + await openViaRequest() + + expect(container?.textContent).toContain( + 'read-only location, so Orca left it out of the update' + ) + expect(container?.textContent).toContain('Read only') + }) + + it('re-inventories installed skills when the dialog closes', async () => { + await renderDialog() + await openViaRequest() + + await clickButton('Close') + + expect(mocks.notifyChanged).toHaveBeenCalledTimes(1) + expect(container?.querySelector('[data-dialog-open]')).toBeNull() + }) + + it('forces a re-check from the Re-check affordance', async () => { + await renderDialog() + await openViaRequest() + + await clickButton('Re-check') + + expect(mocks.refresh).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.tsx b/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.tsx new file mode 100644 index 00000000000..0a0e342c467 --- /dev/null +++ b/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.tsx @@ -0,0 +1,310 @@ +import { + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, + type KeyboardEvent +} from 'react' +import { AlertTriangle, CheckCircle2, ChevronDown, Loader2, RefreshCw } from 'lucide-react' +import type { SkillFreshnessInventory } from '../../../../shared/skill-freshness' +import { buildTargetedSkillUpdateCommand } from '../../../../shared/skill-freshness' +import { useSkillFreshness } from '@/hooks/useSkillFreshness' +import { notifyInstalledAgentSkillsChanged } from '@/hooks/useInstalledAgentSkills' +import { translate } from '@/i18n/i18n' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' +import { OnboardingInlineCommandTerminal } from '@/components/onboarding/OnboardingInlineCommandTerminal' +import { TooltipProvider } from '@/components/ui/tooltip' +import { groupSkillFreshness } from './skill-freshness-grouping' +import { SkillFreshnessGroup } from './skill-freshness-group' +import { + consumeSkillFreshnessUpdateDialogRequest, + getSkillFreshnessUpdateDialogRequest, + subscribeSkillFreshnessUpdateDialog +} from './skill-freshness-update-dialog' + +type FreshnessSummaryKind = 'loading' | 'empty' | 'eligible' | 'current' | 'attention' + +function summarizeInventory( + inventory: SkillFreshnessInventory | null, + hasBlockedGroup: boolean +): FreshnessSummaryKind { + if (!inventory) { + return 'loading' + } + if (inventory.installations.length === 0) { + return 'empty' + } + if (inventory.eligibleUpdateNames.length > 0) { + return 'eligible' + } + // Why: with nothing eligible, the modal is either genuinely all-clear or has + // out-of-date skills it can't safely update; the group filter already dropped + // the up-to-date and unrecognized-only noise, so a blocked group is the signal. + return hasBlockedGroup ? 'attention' : 'current' +} + +function SummaryHeadline({ + kind, + eligibleCount +}: { + kind: FreshnessSummaryKind + eligibleCount: number +}): React.JSX.Element { + if (kind === 'loading') { + return ( +
+ + {translate( + 'auto.components.skills.SkillFreshnessUpdateDialog.checking', + 'Checking installed Orca skills…' + )} +
+ ) + } + if (kind === 'empty') { + return ( +

+ {translate( + 'auto.components.skills.SkillFreshnessUpdateDialog.none', + 'No installed Orca skills found.' + )} +

+ ) + } + if (kind === 'current') { + return ( +
+ + {translate( + 'auto.components.skills.SkillFreshnessUpdateDialog.success', + 'All installed Orca skills are up to date.' + )} +
+ ) + } + if (kind === 'attention') { + return ( +
+
+ + {translate( + 'auto.components.skills.SkillFreshnessUpdateDialog.attention', + 'Some installed Orca skills were left out of the update.' + )} +
+

+ {translate( + 'auto.components.skills.SkillFreshnessUpdateDialog.attentionDescription', + 'Open Update details to see why each one was skipped.' + )} +

+
+ ) + } + return ( +
+

+ {eligibleCount === 1 + ? translate( + 'auto.components.skills.SkillFreshnessUpdateDialog.updateOne', + '1 skill can be updated safely' + ) + : translate( + 'auto.components.skills.SkillFreshnessUpdateDialog.updateMany', + '{{value0}} skills can be updated safely', + { value0: eligibleCount } + )} +

+
+ ) +} + +export function SkillFreshnessUpdateDialog(): React.JSX.Element { + const state = useSkillFreshness() + const open = useSyncExternalStore( + subscribeSkillFreshnessUpdateDialog, + getSkillFreshnessUpdateDialogRequest, + getSkillFreshnessUpdateDialogRequest + ) + const [terminalCommand, setTerminalCommand] = useState(null) + const [awaitingExitRefresh, setAwaitingExitRefresh] = useState(false) + const terminalSubmittedRef = useRef(false) + const inventoryAtTerminalExitRef = useRef(null) + const inventory = state.inventory + const eligibleNames = useMemo(() => inventory?.eligibleUpdateNames ?? [], [inventory]) + const groups = useMemo( + () => + inventory ? groupSkillFreshness(inventory.installations, inventory.eligibleUpdateNames) : [], + [inventory] + ) + const hasBlockedGroup = groups.some((group) => group.status === 'cannot-update') + const updateCommand = buildTargetedSkillUpdateCommand(eligibleNames) + const summaryKind = summarizeInventory(inventory, hasBlockedGroup) + + useEffect(() => { + if (!open) { + return + } + if (state.loading || state.error || !inventory) { + // Why: a scan invalidates the authorization behind an unsubmitted draft. + // A running command keeps its PTY until exit, but stale drafts fail closed. + if (!terminalSubmittedRef.current && terminalCommand !== null) { + setTerminalCommand(null) + } + return + } + if (awaitingExitRefresh) { + if (inventory === inventoryAtTerminalExitRef.current) { + return + } + inventoryAtTerminalExitRef.current = null + setAwaitingExitRefresh(false) + return + } + if (terminalSubmittedRef.current || terminalCommand === updateCommand) { + return + } + // Why: changing the shared onboarding terminal's command pastes it again. + // Replace only an unsubmitted draft; a submitted command owns its PTY until exit. + setTerminalCommand(updateCommand) + }, [ + awaitingExitRefresh, + inventory, + open, + state.error, + state.loading, + terminalCommand, + updateCommand + ]) + + const handleOpenChange = (next: boolean): void => { + // Why: closing is the natural point to re-observe bytes so a completed update + // clears the state and the lingering nudge does not fire again. + if (!next) { + consumeSkillFreshnessUpdateDialogRequest() + terminalSubmittedRef.current = false + inventoryAtTerminalExitRef.current = null + setAwaitingExitRefresh(false) + setTerminalCommand(null) + notifyInstalledAgentSkillsChanged() + } + } + + const handleTerminalInteraction = ( + method: 'keyboard' | 'pointer', + event?: KeyboardEvent + ): void => { + if (method === 'keyboard' && event?.key === 'Enter') { + terminalSubmittedRef.current = true + } + } + + const handleTerminalExit = (): void => { + terminalSubmittedRef.current = false + inventoryAtTerminalExitRef.current = inventory + setAwaitingExitRefresh(true) + setTerminalCommand(null) + notifyInstalledAgentSkillsChanged() + } + + const hasVisibleGroups = groups.length > 0 + + return ( + + + + + {translate('auto.components.skills.SkillFreshnessUpdateDialog.title', 'Update skills')} + + + + {state.error ? ( +

{state.error}

+ ) : ( + + )} + + {terminalCommand ? ( + + ) : null} + + {/* Why: Radix reads defaultOpen only on mount. Remount when a scan + becomes blocked so the required diagnostic details actually open. */} + {hasVisibleGroups ? ( + + + + + + + {groups.map((group) => ( + + ))} + + + + ) : null} + + + + + +
+
+ ) +} diff --git a/src/renderer/src/components/skills/skill-freshness-group.tsx b/src/renderer/src/components/skills/skill-freshness-group.tsx new file mode 100644 index 00000000000..c6dac96479b --- /dev/null +++ b/src/renderer/src/components/skills/skill-freshness-group.tsx @@ -0,0 +1,203 @@ +import type { + SkillFreshnessGroupModel, + SkillLocationChip, + SkillLocationRow +} from './skill-freshness-grouping' +import { translate } from '@/i18n/i18n' +import { Badge } from '@/components/ui/badge' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' + +function chipLabel(chip: SkillLocationChip): string { + switch (chip) { + case 'current': + return translate('auto.components.skills.SkillFreshnessRow.chipCurrent', 'Current') + case 'unrecognized': + return translate('auto.components.skills.SkillFreshnessRow.chipUnrecognized', 'Unrecognized') + case 'inaccessible': + return translate('auto.components.skills.SkillFreshnessRow.chipInaccessible', 'Inaccessible') + case 'duplicate': + return translate('auto.components.skills.SkillFreshnessRow.chipDuplicate', 'Duplicate') + case 'external-link': + return translate('auto.components.skills.SkillFreshnessRow.chipExternalLink', 'External link') + case 'broken-link': + return translate('auto.components.skills.SkillFreshnessRow.chipBrokenLink', 'Broken link') + case 'read-only': + return translate('auto.components.skills.SkillFreshnessRow.chipReadOnly', 'Read only') + case 'in-a-repo': + return translate('auto.components.skills.SkillFreshnessRow.chipInRepo', 'In a repo') + case 'plugin-cache': + return translate('auto.components.skills.SkillFreshnessRow.chipPluginCache', 'Plugin cache') + } +} + +// Why: chips describe only what a location *is*; the effect on the update +// command lives in the per-skill sentence, so the two never say it twice. +function chipTooltip(chip: SkillLocationChip): string { + switch (chip) { + case 'current': + return translate( + 'auto.components.skills.SkillFreshnessRow.tipCurrent', + 'This copy matches the current official version.' + ) + case 'unrecognized': + return translate( + 'auto.components.skills.SkillFreshnessRow.tipUnrecognized', + 'This copy doesn’t match any official version — it may be modified, or a different skill with the same name.' + ) + case 'inaccessible': + return translate( + 'auto.components.skills.SkillFreshnessRow.tipInaccessible', + 'Orca couldn’t read this copy (a permissions or file error).' + ) + case 'duplicate': + return translate( + 'auto.components.skills.SkillFreshnessRow.tipDuplicate', + 'A separate copy of this skill, installed apart from the main one.' + ) + case 'external-link': + return translate( + 'auto.components.skills.SkillFreshnessRow.tipExternalLink', + 'A shortcut pointing outside Orca’s skill folders.' + ) + case 'broken-link': + return translate( + 'auto.components.skills.SkillFreshnessRow.tipBrokenLink', + 'A shortcut to something that no longer exists.' + ) + case 'read-only': + return translate( + 'auto.components.skills.SkillFreshnessRow.tipReadOnly', + 'This copy is in a read-only location.' + ) + case 'in-a-repo': + return translate( + 'auto.components.skills.SkillFreshnessRow.tipInRepo', + 'This copy lives inside a project, not your global skills.' + ) + case 'plugin-cache': + return translate( + 'auto.components.skills.SkillFreshnessRow.tipPluginCache', + 'This copy is managed by a plugin.' + ) + } +} + +// Why: a skill is skipped for one concrete reason; lead with the highest-priority +// blocking placement so the sentence explains the real cause (an edited copy is +// more useful to surface than a downstream symptom). +const SKIPPED_REASON_PRIORITY: SkillLocationChip[] = [ + 'unrecognized', + 'read-only', + 'inaccessible', + 'in-a-repo', + 'plugin-cache', + 'external-link', + 'broken-link' +] + +function skippedReason(locations: readonly SkillLocationRow[]): string { + const present = new Set(locations.map((location) => location.chip)) + const chip = SKIPPED_REASON_PRIORITY.find((candidate) => present.has(candidate)) + switch (chip) { + case 'unrecognized': + return translate( + 'auto.components.skills.SkillFreshnessRow.skippedReasonUnrecognized', + 'The copy here doesn’t match the official version — it may be modified, or a different skill with the same name. Orca left it out of the update so it won’t overwrite it. Remove it if you want Orca to update this skill.' + ) + case 'read-only': + return translate( + 'auto.components.skills.SkillFreshnessRow.skippedReasonReadOnly', + 'This copy is in a read-only location, so Orca left it out of the update. Change its permissions to let Orca update it.' + ) + case 'inaccessible': + return translate( + 'auto.components.skills.SkillFreshnessRow.skippedReasonInaccessible', + 'Orca couldn’t read this copy, so it left the skill out of the update.' + ) + case 'in-a-repo': + return translate( + 'auto.components.skills.SkillFreshnessRow.skippedReasonInRepo', + 'This is a project skill, not a global one — Orca only updates your global skills, so it left this out of the update.' + ) + case 'plugin-cache': + return translate( + 'auto.components.skills.SkillFreshnessRow.skippedReasonPluginCache', + 'A plugin manages this skill, so Orca left it out of the update — update the plugin instead.' + ) + case 'external-link': + return translate( + 'auto.components.skills.SkillFreshnessRow.skippedReasonExternalLink', + 'This copy is a shortcut pointing outside Orca’s skill folders, so Orca left it out of the update.' + ) + case 'broken-link': + return translate( + 'auto.components.skills.SkillFreshnessRow.skippedReasonBrokenLink', + 'This copy is a shortcut to something that no longer exists, so Orca left it out — you can safely delete it.' + ) + default: + return translate( + 'auto.components.skills.SkillFreshnessRow.cantUpdateReason', + 'Orca left this skill out of the update command.' + ) + } +} + +export function SkillFreshnessGroup({ + group +}: { + group: SkillFreshnessGroupModel +}): React.JSX.Element { + const isBlocked = group.status === 'cannot-update' + return ( +
+
+ {group.name} + {isBlocked ? ( + + {translate('auto.components.skills.SkillFreshnessRow.statusCantUpdate', 'Skipped')} + + ) : ( + + {translate( + 'auto.components.skills.SkillFreshnessRow.statusUpdateAvailable', + 'Update available' + )} + + )} +
+ {isBlocked ? ( +

{skippedReason(group.locations)}

+ ) : null} +
+ {group.locations.map((location) => ( +
+ + {location.path} + + {location.chip ? ( + + + + {chipLabel(location.chip)} + + + + {chipTooltip(location.chip)} + + + ) : null} +
+ ))} +
+
+ ) +} diff --git a/src/renderer/src/components/skills/skill-freshness-grouping.test.ts b/src/renderer/src/components/skills/skill-freshness-grouping.test.ts new file mode 100644 index 00000000000..03368ec07bf --- /dev/null +++ b/src/renderer/src/components/skills/skill-freshness-grouping.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest' +import type { SkillFreshnessInstallation } from '../../../../shared/skill-freshness' +import { groupSkillFreshness } from './skill-freshness-grouping' + +function placement( + name: string, + overrides: Partial = {} +): SkillFreshnessInstallation { + return { + id: `${name}-${overrides.rootId ?? 'home-agents'}-${overrides.unresolvedPath ?? 'a'}`, + name, + rootId: 'home-agents', + providers: ['agent-skills'], + sourceKind: 'home', + sourceLabel: 'Agent skills home', + unresolvedPath: `/home/.agents/skills/${name}`, + resolvedPath: `/home/.agents/skills/${name}`, + physicalIdentity: `physical-${name}`, + topology: 'canonical-copy', + status: 'outdated', + installedReleaseRevision: 1, + installedAppVersion: '1.0.0', + currentReleaseRevision: 2, + currentPackageDigest: 'current', + currentAppVersion: '2.0.0', + observedPackageDigest: 'old', + errorCategory: null, + ...overrides + } +} + +describe('groupSkillFreshness', () => { + it('marks an eligible outdated skill as update-available with one location', () => { + const groups = groupSkillFreshness([placement('orca-cli')], ['orca-cli']) + expect(groups).toHaveLength(1) + expect(groups[0]).toMatchObject({ name: 'orca-cli', status: 'update-available' }) + expect(groups[0]?.locations).toEqual([ + { id: expect.any(String), path: '/home/.agents/skills/orca-cli', chip: null } + ]) + }) + + it('hides skills with nothing out of date (current, unrecognized-only, unreadable-only)', () => { + const groups = groupSkillFreshness( + [ + placement('orca-cli', { status: 'current' }), + placement('dataviz', { status: 'unrecognized', topology: 'independent-copy' }), + placement('linear-tickets', { status: 'inaccessible' }) + ], + [] + ) + expect(groups).toEqual([]) + }) + + it('groups a blocked skill and flags the culprit location, not the main copy', () => { + const groups = groupSkillFreshness( + [ + placement('orchestration'), + placement('orchestration', { + rootId: 'home-claude', + unresolvedPath: '/home/.claude/skills/orchestration', + status: 'unrecognized', + topology: 'independent-copy' + }) + ], + [] + ) + expect(groups).toHaveLength(1) + expect(groups[0]?.status).toBe('cannot-update') + // Why: the out-of-date main copy is bare; only the poisoning copy carries a chip. + expect(groups[0]?.locations).toEqual([ + { id: expect.any(String), path: '/home/.agents/skills/orchestration', chip: null }, + { id: expect.any(String), path: '/home/.claude/skills/orchestration', chip: 'unrecognized' } + ]) + }) + + it('prefers a location status over its topology and maps every topology to a chip', () => { + const chipFor = (overrides: Partial): string | null => + groupSkillFreshness( + [placement('s', { status: 'outdated' }), placement('s', overrides)], + ['s'] + )[0]?.locations.find((location) => location.path.includes('culprit'))?.chip ?? null + const at = (path: string, rest: Partial) => ({ + unresolvedPath: `/culprit/${path}`, + ...rest + }) + expect(chipFor(at('a', { status: 'unrecognized', topology: 'independent-copy' }))).toBe( + 'unrecognized' + ) + expect(chipFor(at('b', { status: 'inaccessible', topology: 'read-only' }))).toBe('inaccessible') + expect(chipFor(at('c', { topology: 'independent-copy' }))).toBe('duplicate') + expect(chipFor(at('d', { topology: 'external-link' }))).toBe('external-link') + expect(chipFor(at('e', { topology: 'broken-link' }))).toBe('broken-link') + expect(chipFor(at('f', { topology: 'read-only' }))).toBe('read-only') + expect(chipFor(at('g', { topology: 'repo-scope' }))).toBe('in-a-repo') + expect(chipFor(at('h', { topology: 'plugin-cache' }))).toBe('plugin-cache') + expect(chipFor(at('i', { status: 'current', topology: 'provider-alias' }))).toBe('current') + }) +}) diff --git a/src/renderer/src/components/skills/skill-freshness-grouping.ts b/src/renderer/src/components/skills/skill-freshness-grouping.ts new file mode 100644 index 00000000000..db76800fdfb --- /dev/null +++ b/src/renderer/src/components/skills/skill-freshness-grouping.ts @@ -0,0 +1,90 @@ +import type { SkillFreshnessInstallation } from '../../../../shared/skill-freshness' + +export type SkillGroupStatus = 'update-available' | 'cannot-update' + +export type SkillLocationChip = + | 'current' + | 'unrecognized' + | 'inaccessible' + | 'duplicate' + | 'external-link' + | 'broken-link' + | 'read-only' + | 'in-a-repo' + | 'plugin-cache' + +export type SkillLocationRow = { + id: string + path: string + chip: SkillLocationChip | null +} + +export type SkillFreshnessGroupModel = { + name: string + status: SkillGroupStatus + locations: SkillLocationRow[] +} + +function locationChip(installation: SkillFreshnessInstallation): SkillLocationChip | null { + // Why: a location's own status wins over its topology — "the contents don't + // match" is more useful to the user than "it's a duplicate". + if (installation.status === 'unrecognized') { + return 'unrecognized' + } + if (installation.status === 'inaccessible') { + return 'inaccessible' + } + switch (installation.topology) { + case 'independent-copy': + return 'duplicate' + case 'external-link': + return 'external-link' + case 'broken-link': + return 'broken-link' + case 'read-only': + return 'read-only' + case 'repo-scope': + return 'in-a-repo' + case 'plugin-cache': + return 'plugin-cache' + case 'canonical-copy': + case 'provider-alias': + // Why: a supported location only needs a chip when it's already up to date, + // to explain why the update won't touch it; the out-of-date main copy is bare. + return installation.status === 'current' ? 'current' : null + } +} + +/** + * Groups installations by skill for the update modal and derives each skill's + * update disposition. Only skills with an out-of-date official copy are returned — + * up-to-date, unrecognized-only, and unreadable-only skills have nothing to change + * here, so they are omitted entirely. + */ +export function groupSkillFreshness( + installations: readonly SkillFreshnessInstallation[], + eligibleUpdateNames: readonly string[] +): SkillFreshnessGroupModel[] { + const eligible = new Set(eligibleUpdateNames) + const byName = new Map() + for (const installation of installations) { + const entries = byName.get(installation.name) ?? [] + entries.push(installation) + byName.set(installation.name, entries) + } + const groups: SkillFreshnessGroupModel[] = [] + for (const [name, entries] of byName) { + if (!entries.some((entry) => entry.status === 'outdated')) { + continue + } + const locations = entries + .map((entry) => ({ id: entry.id, path: entry.unresolvedPath, chip: locationChip(entry) })) + .sort((left, right) => left.path.localeCompare(right.path, 'en')) + groups.push({ + name, + status: eligible.has(name) ? 'update-available' : 'cannot-update', + locations + }) + } + return groups.sort((left, right) => left.name.localeCompare(right.name, 'en')) +} diff --git a/src/renderer/src/components/skills/skill-freshness-update-dialog.ts b/src/renderer/src/components/skills/skill-freshness-update-dialog.ts new file mode 100644 index 00000000000..14a84fa4ac8 --- /dev/null +++ b/src/renderer/src/components/skills/skill-freshness-update-dialog.ts @@ -0,0 +1,31 @@ +let pendingOpen = false +const listeners = new Set<() => void>() + +// Why: the nudge action can fire before the dialog subscribes. Keeping the +// request as an external snapshot prevents mount ordering from losing it. +export function requestSkillFreshnessUpdateDialog(): void { + pendingOpen = true + for (const listener of listeners) { + listener() + } +} + +export function consumeSkillFreshnessUpdateDialogRequest(): boolean { + const requested = pendingOpen + pendingOpen = false + if (requested) { + for (const listener of listeners) { + listener() + } + } + return requested +} + +export function getSkillFreshnessUpdateDialogRequest(): boolean { + return pendingOpen +} + +export function subscribeSkillFreshnessUpdateDialog(listener: () => void): () => void { + listeners.add(listener) + return () => listeners.delete(listener) +} diff --git a/src/renderer/src/components/status-bar/tooltip.test.ts b/src/renderer/src/components/status-bar/tooltip.test.ts index 87e12d66048..ed80c7b9ab8 100644 --- a/src/renderer/src/components/status-bar/tooltip.test.ts +++ b/src/renderer/src/components/status-bar/tooltip.test.ts @@ -135,18 +135,36 @@ describe('provider usage error copy', () => { ) }) - it('keeps the reworded Grok expired-token error classified as an auth failure (#8497)', () => { - // Why: the fix (grok-fetcher.ts) dropped the "run grok login" wording that - // used to trigger auth classification; this pins that the new copy still - // resolves to the softer refresh message instead of leaking the raw string. + it('shows the exact Grok CLI recovery flow for an expired refreshable session (#8497)', () => { const grok = provider({ provider: 'grok', - error: 'Grok access token expired — Grok CLI will refresh it on next use' + error: + 'Grok sign-in expired — run grok on the computer running Orca; sign in if prompted. No chat message is needed.', + usageMetadata: { + failureKind: 'delegated-refresh-required', + source: 'oauth' + } }) - expect(getProviderUsageStatusLabel(grok)).toBe('Refresh failed') + expect(getProviderUsageStatusLabel(grok)).toBe('Run Grok to refresh') expect(getProviderUsageErrorMessage(grok)).toBe( - 'Grok usage could not be refreshed. Agent sessions may still be signed in.' + 'Run grok in a terminal on the computer running Orca and wait for it to start. If prompted, complete sign-in, then retry usage. You do not need to send a chat message.' + ) + }) + + it('shows the exact Kimi CLI recovery flow for an expired read-only session', () => { + const kimi = provider({ + provider: 'kimi', + error: 'Kimi session expired — run kimi on the computer running Orca, then retry usage.', + usageMetadata: { + failureKind: 'delegated-refresh-required', + source: 'oauth' + } + }) + + expect(getProviderUsageStatusLabel(kimi)).toBe('Run Kimi to refresh') + expect(getProviderUsageErrorMessage(kimi)).toBe( + 'Run kimi in a terminal on the computer running Orca and wait for it to start, then retry usage.' ) }) diff --git a/src/renderer/src/components/status-bar/usage-error-copy.ts b/src/renderer/src/components/status-bar/usage-error-copy.ts index 0b0c3bc2eb6..39032ab5e2c 100644 --- a/src/renderer/src/components/status-bar/usage-error-copy.ts +++ b/src/renderer/src/components/status-bar/usage-error-copy.ts @@ -64,7 +64,25 @@ function isUsageAuthError(message: string | null): boolean { return Boolean(message && USAGE_AUTH_ERROR_PATTERNS.some((pattern) => pattern.test(message))) } +function getDelegatedCliRefreshProvider( + p: ProviderRateLimits +): Extract | null { + if (p.usageMetadata?.failureKind !== 'delegated-refresh-required') { + return null + } + // Why: only these providers require a user-run CLI to rotate the read-only + // session Orca consumes; Claude handles the same failure kind in-app. + return p.provider === 'grok' || p.provider === 'kimi' ? p.provider : null +} + export function getProviderUsageStatusLabel(p: ProviderRateLimits): string { + const delegatedCliProvider = getDelegatedCliRefreshProvider(p) + if (delegatedCliProvider === 'grok') { + return translate('auto.components.status.bar.tooltip.e2c6a4f917', 'Run Grok to refresh') + } + if (delegatedCliProvider === 'kimi') { + return translate('auto.components.status.bar.tooltip.f90b3d7a16', 'Run Kimi to refresh') + } if (p.provider === 'claude') { switch (p.usageMetadata?.failureKind) { case 'deferred-by-live-session': @@ -107,6 +125,19 @@ export function getProviderUsageErrorMessage(p: ProviderRateLimits): string { if (!p.error) { return fallback } + const delegatedCliProvider = getDelegatedCliRefreshProvider(p) + if (delegatedCliProvider === 'grok') { + return translate( + 'auto.components.status.bar.tooltip.d1b7f509ac', + 'Run grok in a terminal on the computer running Orca and wait for it to start. If prompted, complete sign-in, then retry usage. You do not need to send a chat message.' + ) + } + if (delegatedCliProvider === 'kimi') { + return translate( + 'auto.components.status.bar.tooltip.a37e8c15d4', + 'Run kimi in a terminal on the computer running Orca and wait for it to start, then retry usage.' + ) + } if (p.provider === 'claude') { switch (p.usageMetadata?.failureKind) { case 'deferred-by-live-session': diff --git a/src/renderer/src/components/task-page-cache-selectors.test.ts b/src/renderer/src/components/task-page-cache-selectors.test.ts index 98703dd2339..71b85b8adae 100644 --- a/src/renderer/src/components/task-page-cache-selectors.test.ts +++ b/src/renderer/src/components/task-page-cache-selectors.test.ts @@ -146,8 +146,8 @@ describe('task page cache selectors', () => { entry([patched]) ]) - expect(nextPages[0][0]).toBe(patched) - expect(nextPages[0][1]).toBe(otherRepoSameId) + expect(nextPages[0]?.[0]).toBe(patched) + expect(nextPages[0]?.[1]).toBe(otherRepoSameId) }) it('merges landing refresh status changes without reordering GitHub rows', () => { diff --git a/src/renderer/src/components/task-page-cache-selectors.ts b/src/renderer/src/components/task-page-cache-selectors.ts index 490db04d916..67b3a7c1c8c 100644 --- a/src/renderer/src/components/task-page-cache-selectors.ts +++ b/src/renderer/src/components/task-page-cache-selectors.ts @@ -37,6 +37,7 @@ type WorkItemsCache = Record> type LinearIssueCache = Record> type LinearSearchCache = Record> type LinearListCache = Record>> +export type TaskPageWorkItemPages = readonly (GitHubWorkItem[] | null)[] export function deriveTaskPageGitHubWorkItemsFetchOptions( forcedFetch: boolean, @@ -83,9 +84,9 @@ function taskPageWorkItemCacheKey(item: GitHubWorkItem): string { } export function reconcileTaskPagePagesWithWorkItemsCache( - pages: readonly GitHubWorkItem[][], + pages: TaskPageWorkItemPages, entries: readonly (CacheEntry | undefined)[] -): GitHubWorkItem[][] { +): (GitHubWorkItem[] | null)[] { const cachedItems = new Map() for (const entry of entries) { for (const item of entry?.data ?? []) { @@ -95,6 +96,9 @@ export function reconcileTaskPagePagesWithWorkItemsCache( let changed = false const nextPages = pages.map((page) => { + if (!page) { + return null + } let pageChanged = false const nextPage = page.map((item) => { const cached = cachedItems.get(taskPageWorkItemCacheKey(item)) @@ -108,7 +112,7 @@ export function reconcileTaskPagePagesWithWorkItemsCache( return pageChanged ? nextPage : page }) - return changed ? nextPages : (pages as GitHubWorkItem[][]) + return changed ? nextPages : (pages as (GitHubWorkItem[] | null)[]) } function taskPageWorkItemKey(item: GitHubWorkItem): string { @@ -221,16 +225,16 @@ export function shouldResetTaskPagePaginationAfterLandingRefresh( } export function reconcileTaskPagePagesAfterLandingRefresh( - pages: readonly GitHubWorkItem[][], + pages: TaskPageWorkItemPages, refreshedItems: readonly GitHubWorkItem[] -): GitHubWorkItem[][] { +): (GitHubWorkItem[] | null)[] { const firstPage = pages[0] ?? [] if (shouldResetTaskPagePaginationAfterLandingRefresh(firstPage, refreshedItems)) { return [[...refreshedItems]] } const nextFirstPage = reconcileTaskPageItemsAfterLandingRefresh(firstPage, refreshedItems) if (nextFirstPage === firstPage) { - return pages as GitHubWorkItem[][] + return pages as (GitHubWorkItem[] | null)[] } return [nextFirstPage, ...pages.slice(1)] } diff --git a/src/renderer/src/components/task-page-work-item-pagination.test.ts b/src/renderer/src/components/task-page-work-item-pagination.test.ts index b3bc3301d94..22ad6bf6cde 100644 --- a/src/renderer/src/components/task-page-work-item-pagination.test.ts +++ b/src/renderer/src/components/task-page-work-item-pagination.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it, vi } from 'vitest' import type { GitHubWorkItem } from '../../../shared/types' -import { accumulateWorkItemPages, workItemIdentity } from './task-page-work-item-pagination' +import { + accumulateWorkItemPages, + getTaskPagePerRepoLimit, + taskPageToGitHubApiPage, + workItemIdentity +} from './task-page-work-item-pagination' function item(repoId: string, id: string, updatedAt: string): GitHubWorkItem { return { @@ -39,6 +44,21 @@ describe('workItemIdentity', () => { }) }) +describe('numbered GitHub pagination', () => { + it('converts zero-indexed task pages to one-indexed API pages', () => { + expect(taskPageToGitHubApiPage(0)).toBe(1) + expect(taskPageToGitHubApiPage(1)).toBe(2) + expect(taskPageToGitHubApiPage(15)).toBe(16) + }) + + it('divides the display budget before per-repo fetches can overflow it', () => { + expect(getTaskPagePerRepoLimit(1, 36, 100)).toBe(36) + expect(getTaskPagePerRepoLimit(2, 36, 100)).toBe(36) + expect(getTaskPagePerRepoLimit(3, 36, 100)).toBe(33) + expect(getTaskPagePerRepoLimit(90, 36, 100)).toBe(1) + }) +}) + describe('accumulateWorkItemPages', () => { it('drops the re-fetched boundary row that shares the previous page cursor', async () => { const boundary = item('r', 'issue:2', '2026-07-02') diff --git a/src/renderer/src/components/task-page-work-item-pagination.ts b/src/renderer/src/components/task-page-work-item-pagination.ts index a332fe675e2..e8f0a8749e3 100644 --- a/src/renderer/src/components/task-page-work-item-pagination.ts +++ b/src/renderer/src/components/task-page-work-item-pagination.ts @@ -31,6 +31,21 @@ export function workItemIdentity(item: Pick): s return `${item.repoId}:${item.id}` } +export function taskPageToGitHubApiPage(taskPage: number): number { + return Math.max(0, Math.floor(taskPage)) + 1 +} + +// Why: provider pages cannot spill truncated rows into the next page. Divide +// the display budget up front so every fetched row remains reachable. +export function getTaskPagePerRepoLimit( + repoCount: number, + maxPerRepo: number, + displayLimit: number +): number { + const normalizedRepoCount = Math.max(1, Math.floor(repoCount)) + return Math.max(1, Math.min(maxPerRepo, Math.floor(displayLimit / normalizedRepoCount))) +} + export type WorkItemPageFetchResult = { items: GitHubWorkItem[] } export type AccumulateWorkItemPagesArgs = { diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index f9a4e1fc40f..975aad10f94 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -107,7 +107,9 @@ import { } from '../native-chat/native-chat-leaf-routing' import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { resolvePaneKeyForManager } from '@/lib/pane-manager/pane-key-resolution' -import { safeFit } from '@/lib/pane-manager/pane-tree-ops' +import { safeFit, safeFitAndThen } from '@/lib/pane-manager/pane-tree-ops' +import { applyDesktopFitFallbackAfterReplay } from './desktop-fit-fallback' +import { clearTerminalScrollbackAndFollowOutput } from '@/lib/pane-manager/terminal-scrollback-clear' import { captureTerminalShutdownLayout } from './terminal-shutdown-layout-capture' import { getOverrideAffectedPanes, getPanesNeedingOverrideFit } from './override-affected-panes' import { @@ -517,15 +519,12 @@ export default function TerminalPane({ if (rect.width === 0 || rect.height === 0) { continue } - safeFit(pane) - const stuckAtMobile = - event.priorCols != null && - event.priorRows != null && - pane.terminal.cols === event.priorCols && - pane.terminal.rows === event.priorRows - if (stuckAtMobile && event.cols > 0 && event.rows > 0) { - pane.terminal.resize(event.cols, event.rows) - } + applyDesktopFitFallbackAfterReplay(pane, { + ...event, + // Why: the timeout/replay queue can outlive this pane binding; + // never apply old server dimensions to a replacement PTY. + shouldApply: () => getAffectedPanes().includes(pane) + }) } }) } @@ -1130,7 +1129,7 @@ export default function TerminalPane({ const clearPaneScrollback = useCallback( (pane: ManagedPane): void => { clearedScrollbackLeafIdsRef.current.add(pane.leafId) - pane.terminal.clear() + clearTerminalScrollbackAndFollowOutput(pane.terminal) // Why: also clear the host buffer for remote-server panes, or the next // host snapshot replays the scrollback we just cleared locally. const ptyId = paneTransportsRef.current.get(pane.id)?.getPtyId() ?? null @@ -1884,26 +1883,27 @@ export default function TerminalPane({ return } for (const pane of manager.getPanes()) { - safeFit(pane) - const transport = paneTransportsRef.current.get(pane.id) - if (!transport?.isConnected()) { - continue - } - const ptyId = transport.getPtyId() - if (!ptyId) { - continue - } - // Why: match pty-connection resize guards so web refit retries do not - // forward SIGWINCH while mobile-lock or phone-fit overrides are active. - if (getFitOverrideForPty(ptyId) || isPtyLocked(ptyId)) { - continue - } - // Why: skip forwarding a stale near-zero fit to the host PTY while the - // overlay is still settling after a worktree switch. - if (pane.terminal.cols < 8 || pane.terminal.rows < 4) { - continue - } - transport.resize(pane.terminal.cols, pane.terminal.rows) + safeFitAndThen(pane, 'web-client-pty-resize', () => { + const transport = paneTransportsRef.current.get(pane.id) + if (!transport?.isConnected()) { + return + } + const ptyId = transport.getPtyId() + if (!ptyId) { + return + } + // Why: match pty-connection resize guards so web refit retries do not + // forward SIGWINCH while mobile-lock or phone-fit overrides are active. + if (getFitOverrideForPty(ptyId) || isPtyLocked(ptyId)) { + return + } + // Why: skip forwarding a stale near-zero fit to the host PTY while the + // overlay is still settling after a worktree switch. + if (pane.terminal.cols < 8 || pane.terminal.rows < 4) { + return + } + transport.resize(pane.terminal.cols, pane.terminal.rows) + }) } } const scheduleFrame = (): void => { diff --git a/src/renderer/src/components/terminal-pane/desktop-fit-fallback.test.ts b/src/renderer/src/components/terminal-pane/desktop-fit-fallback.test.ts new file mode 100644 index 00000000000..3fef752e5ea --- /dev/null +++ b/src/renderer/src/components/terminal-pane/desktop-fit-fallback.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from 'vitest' +import { applyDesktopFitFallbackAfterReplay } from './desktop-fit-fallback' +import { + beginTerminalScrollIntentBufferRebuild, + endTerminalScrollIntentBufferRebuild +} from '@/lib/pane-manager/terminal-scroll-intent-rebuild' + +function createPane() { + const terminal = { + cols: 49, + rows: 20, + buffer: { active: { type: 'normal', viewportY: 0, baseY: 0 } }, + resize: vi.fn((cols: number, rows: number) => { + terminal.cols = cols + terminal.rows = rows + }) + } + return { + terminal, + container: { + dataset: {}, + getBoundingClientRect: () => ({ width: 800, height: 600 }) + }, + fitAddon: { + proposeDimensions: vi.fn(() => null), + fit: vi.fn() + } + } +} + +describe('desktop fit fallback', () => { + it('waits until structural replay completes before direct resize', async () => { + const pane = createPane() + beginTerminalScrollIntentBufferRebuild(pane.terminal) + + applyDesktopFitFallbackAfterReplay(pane as never, { + cols: 120, + rows: 40, + priorCols: 49, + priorRows: 20 + }) + expect(pane.terminal.resize).not.toHaveBeenCalled() + + endTerminalScrollIntentBufferRebuild(pane.terminal) + await Promise.resolve() + expect(pane.terminal.resize).toHaveBeenCalledWith(120, 40) + }) + + it('drops deferred dimensions when the pane binding becomes stale', async () => { + const pane = createPane() + let isCurrent = true + beginTerminalScrollIntentBufferRebuild(pane.terminal) + applyDesktopFitFallbackAfterReplay(pane as never, { + cols: 120, + rows: 40, + priorCols: 49, + priorRows: 20, + shouldApply: () => isCurrent + }) + + isCurrent = false + endTerminalScrollIntentBufferRebuild(pane.terminal) + await Promise.resolve() + expect(pane.terminal.resize).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/desktop-fit-fallback.ts b/src/renderer/src/components/terminal-pane/desktop-fit-fallback.ts new file mode 100644 index 00000000000..7bb5da4f44d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/desktop-fit-fallback.ts @@ -0,0 +1,42 @@ +import type { ManagedPane } from '@/lib/pane-manager/pane-manager-types' +import { safeFit } from '@/lib/pane-manager/pane-fit' +import { deferTerminalGeometryMutationDuringRebuild } from '@/lib/pane-manager/terminal-scroll-intent-rebuild' + +type DesktopFitFallbackDimensions = { + cols: number + rows: number + priorCols?: number | null + priorRows?: number | null + shouldApply?: () => boolean +} + +export function applyDesktopFitFallbackAfterReplay( + pane: ManagedPane, + dimensions: DesktopFitFallbackDimensions +): void { + const applyFallback = (): void => { + if (dimensions.shouldApply?.() === false) { + return + } + safeFit(pane) + const stuckAtPriorGrid = + dimensions.priorCols != null && + dimensions.priorRows != null && + pane.terminal.cols === dimensions.priorCols && + pane.terminal.rows === dimensions.priorRows + if (stuckAtPriorGrid && dimensions.cols > 0 && dimensions.rows > 0) { + pane.terminal.resize(dimensions.cols, dimensions.rows) + } + } + // Why: the server dimensions are only a fallback; source-dimension replay + // must parse and restore its viewport before this can reflow xterm. + if ( + !deferTerminalGeometryMutationDuringRebuild( + pane.terminal, + 'desktop-fit-fallback', + applyFallback + ) + ) { + applyFallback() + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-buffer-serializer.test.ts b/src/renderer/src/components/terminal-pane/pty-buffer-serializer.test.ts index 2907e2b3341..436982b061b 100644 --- a/src/renderer/src/components/terminal-pane/pty-buffer-serializer.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-buffer-serializer.test.ts @@ -78,4 +78,25 @@ describe('pty buffer serializer registry', () => { expect(window.api.pty.sendSerializedBuffer).toHaveBeenCalledWith('request-1', null) }) + + it('preserves the renderer-parsed output sequence in the response', async () => { + const { registerPtySerializer } = await import('./pty-buffer-serializer') + registerPtySerializer('pty-sequenced', () => ({ + data: 'parsed output', + cols: 80, + rows: 24, + seq: 42 + })) + const serializeRequestHandler = vi.mocked(window.api.pty.onSerializeBufferRequest).mock + .calls[0]?.[0] + + serializeRequestHandler?.({ requestId: 'request-sequenced', ptyId: 'pty-sequenced' }) + await Promise.resolve() + await Promise.resolve() + + expect(window.api.pty.sendSerializedBuffer).toHaveBeenCalledWith( + 'request-sequenced', + expect.objectContaining({ seq: 42 }) + ) + }) }) diff --git a/src/renderer/src/components/terminal-pane/pty-buffer-serializer.ts b/src/renderer/src/components/terminal-pane/pty-buffer-serializer.ts index 2958b5aa08f..c32ea0aafaf 100644 --- a/src/renderer/src/components/terminal-pane/pty-buffer-serializer.ts +++ b/src/renderer/src/components/terminal-pane/pty-buffer-serializer.ts @@ -14,6 +14,7 @@ export type SerializedBuffer = { data: string cols: number rows: number + seq?: number lastTitle?: string } @@ -148,6 +149,9 @@ function ensureSerializerListener(): void { cols: result.cols, rows: result.rows } + if (result.seq !== undefined) { + payload.seq = result.seq + } if (lastTitle !== undefined) { payload.lastTitle = lastTitle } else if (result.lastTitle !== undefined) { 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 e144c0ced8c..18e99dac5a7 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -225,6 +225,7 @@ function resolveMockPaneWindowsShiftEnterEncoding( } type ConnectCallbacks = { + onConnect?: () => void onData?: ( data: string, meta?: { seq?: number; rawLength?: number; background?: boolean; droppedOutput?: boolean } @@ -417,57 +418,65 @@ function createPane(paneId: number) { cursorY: 0, cursorX: 0 } + const terminal = { + cols: 120, + rows: 40, + element: {}, + buffer: { + active: activeBuffer + }, + modes: { + bracketedPasteMode: false, + sendFocusMode: false + }, + options: { + scrollback: 5_000, + ignoreBracketedPasteMode: false, + theme: { + foreground: '#eeeeee', + background: '#111111' + } + }, + write: vi.fn<(data: string, callback?: () => void) => void>(function write(...args): void { + const [data, callback] = args + if (data === '' || callback?.name === 'runParsedSteps') { + callback?.() + } + }), + resize: vi.fn(), + clear: vi.fn(), + scrollToBottom: vi.fn(() => { + activeBuffer.viewportY = activeBuffer.baseY + }), + scrollToLine: vi.fn((line: number) => { + activeBuffer.viewportY = line + }), + scrollLines: vi.fn((amount: number) => { + activeBuffer.viewportY = Math.max( + 0, + Math.min(activeBuffer.baseY, activeBuffer.viewportY + amount) + ) + }), + paste: vi.fn(), + onData: vi.fn(() => ({ dispose: vi.fn() })), + onResize: vi.fn(() => ({ dispose: vi.fn() })), + onRender: vi.fn((_listener: () => void) => ({ dispose: vi.fn() })), + onTitleChange: vi.fn(() => ({ dispose: vi.fn() })), + hasSelection: vi.fn(() => false), + parser: { + registerCsiHandler: vi.fn(() => ({ dispose: vi.fn() })), + registerOscHandler: vi.fn(() => ({ dispose: vi.fn() })) + } + } return { id: paneId, leafId, stablePaneId: leafId, - terminal: { - cols: 120, - rows: 40, - element: {}, - buffer: { - active: activeBuffer - }, - modes: { - bracketedPasteMode: false, - sendFocusMode: false - }, - options: { - scrollback: 5_000, - ignoreBracketedPasteMode: false, - theme: { - foreground: '#eeeeee', - background: '#111111' - } - }, - write: vi.fn(), - resize: vi.fn(), - clear: vi.fn(), - scrollToBottom: vi.fn(() => { - activeBuffer.viewportY = activeBuffer.baseY - }), - scrollToLine: vi.fn((line: number) => { - activeBuffer.viewportY = line - }), - scrollLines: vi.fn((amount: number) => { - activeBuffer.viewportY = Math.max( - 0, - Math.min(activeBuffer.baseY, activeBuffer.viewportY + amount) - ) - }), - paste: vi.fn(), - onData: vi.fn(() => ({ dispose: vi.fn() })), - onResize: vi.fn(() => ({ dispose: vi.fn() })), - onTitleChange: vi.fn(() => ({ dispose: vi.fn() })), - hasSelection: vi.fn(() => false), - parser: { - registerCsiHandler: vi.fn(() => ({ dispose: vi.fn() })), - registerOscHandler: vi.fn(() => ({ dispose: vi.fn() })) - } - }, + terminal, container: createPaneContainer(), fitAddon: { - fit: vi.fn() + fit: vi.fn(), + proposeDimensions: vi.fn(() => ({ cols: terminal.cols, rows: terminal.rows })) } } } @@ -866,7 +875,8 @@ describe('connectPanePty', () => { sendSerializedBuffer: vi.fn(), declarePendingPaneSerializer: vi.fn().mockResolvedValue(1), settlePaneSerializer: vi.fn().mockResolvedValue(undefined), - clearPendingPaneSerializer: vi.fn().mockResolvedValue(undefined) + clearPendingPaneSerializer: vi.fn().mockResolvedValue(undefined), + reportRendererSerializerReady: vi.fn().mockResolvedValue(undefined) }, platform: { get: vi.fn(() => ({ platform: 'win32', osRelease: '10.0.26100' })) @@ -4932,7 +4942,10 @@ describe('connectPanePty', () => { const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } const transport = createMockTransport() + let currentPtyId: string | null = null + vi.mocked(transport.getPtyId).mockImplementation(() => currentPtyId) transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + currentPtyId = 'pty-local-1' capturedDataCallback.current = callbacks.onData ?? null return 'pty-local-1' }) @@ -4960,23 +4973,33 @@ describe('connectPanePty', () => { connectPanePty(pane as never, manager as never, deps as never) capturedDataCallback.current?.('\x1b]133;D;130\x07thebr ~/repo $ ') - await flushAsyncTicks(1) + await flushAsyncTicks() expect(mockStoreState.dropAgentStatus).toHaveBeenCalledWith(paneKey) expect(mockStoreState.removeAgentStatus).not.toHaveBeenCalled() }) it('clears pre-hook launch config when an Orca-started command exits', async () => { + vi.useFakeTimers({ toFake: ['setTimeout'] }) const { connectPanePty } = await import('./pty-connection') + vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('zsh') const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } const transport = createMockTransport() + let currentPtyId: string | null = null + vi.mocked(transport.getPtyId).mockImplementation(() => currentPtyId) transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + currentPtyId = 'pty-local-1' capturedDataCallback.current = callbacks.onData ?? null return 'pty-local-1' }) transportFactoryQueue.push(transport) const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, + ptyIdsByTabId: { 'tab-1': [] } + } as StoreState connectPanePty( createPane(1) as never, @@ -4989,7 +5012,8 @@ describe('connectPanePty', () => { agentEnv: {} }, launchAgent: 'codex' - } + }, + restoredPtyIdByLeafId: {} }) as never ) @@ -5002,7 +5026,9 @@ describe('connectPanePty', () => { expect.objectContaining({ agentType: 'codex' }) ) capturedDataCallback.current?.('\x1b]133;D;130\x07thebr ~/repo $ ') - await flushAsyncTicks(1) + await flushAsyncTicks() + await vi.advanceTimersByTimeAsync(350) + await flushAsyncTicks() expect(mockStoreState.clearAgentLaunchConfig).toHaveBeenCalledWith(paneKey) expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled() @@ -5082,6 +5108,134 @@ describe('connectPanePty', () => { expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('csi-u') }) + it('confirms an Orca-launched Droid fresh spawn in a no-OSC shell (Git Bash)', async () => { + // Why: PowerShell gets an OSC 133 command-start that triggers the confirming + // foreground read, but Git Bash / cmd.exe emit no command boundary and the + // launch command is injected programmatically (no typed inference). Without a + // fresh-spawn sample the pane never earns routing trust, so Shift+Enter falls + // back to Esc+CR and Droid submits instead of inserting a newline (#7620). + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + vi.mocked(window.api.pty.getForegroundProcess).mockResolvedValue('droid') + vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('droid') + const pane = createPane(1) + const ptyId = 'pty-launched-droid-no-osc' + const tabId = 'tab-launched-droid-no-osc' + const paneKey = makePaneKey(tabId, LEAF_1) + transportFactoryQueue.push(createMockTransport(ptyId)) + + connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ + tabId, + startup: { command: 'droid', launchAgent: 'droid' } + }) as never + ) + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks() + // The transport reports the fresh spawn; no OSC 133, no typed inference. + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as ((id: string) => void) | undefined + expect(onPtySpawn).toBeTypeOf('function') + onPtySpawn?.(ptyId) + // Span the bounded confirmation retry ladder while Droid boots. + await vi.advanceTimersByTimeAsync(350 + 1200 + 6000) + await flushAsyncTicks() + + expect(window.api.pty.confirmForegroundProcess).toHaveBeenCalledWith(ptyId) + expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ + agent: 'droid', + routingTrusted: true, + shellForeground: false + }) + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('csi-u') + }) + + it('trusts a launched Droid whose no-OSC boot only becomes foreground after retries', async () => { + // Why: the confirmation ladder must span Droid's boot — the shell is still + // foreground on the first read(s) before Droid takes over. + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + const foregroundResults = ['bash.exe', 'bash.exe', 'droid'] + vi.mocked(window.api.pty.confirmForegroundProcess).mockImplementation( + async () => foregroundResults.shift() ?? 'droid' + ) + const pane = createPane(1) + const ptyId = 'pty-launched-droid-slow-boot' + const tabId = 'tab-launched-droid-slow-boot' + const paneKey = makePaneKey(tabId, LEAF_1) + transportFactoryQueue.push(createMockTransport(ptyId)) + + connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ tabId, startup: { command: 'droid', launchAgent: 'droid' } }) as never + ) + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks() + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as ((id: string) => void) | undefined + onPtySpawn?.(ptyId) + await vi.advanceTimersByTimeAsync(350 + 1200 + 6000) + await flushAsyncTicks() + + expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ + agent: 'droid', + routingTrusted: true, + shellForeground: false + }) + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('csi-u') + }) + + it('stays recoverable when a launched Droid outlasts the fresh-spawn confirmation window', async () => { + // Why: if the bounded ladder misses (pathologically slow boot), the miss must + // NOT latch a known-agent shell-confirm — that would clear launch identity and + // block every later focus/reveal re-sample, permanently poisoning Shift+Enter + // for the session. A benign miss leaves the pane recoverable, so a later focus + // sample (once Droid is finally foreground) still earns routing trust. + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + let foreground = 'bash.exe' + vi.mocked(window.api.pty.confirmForegroundProcess).mockImplementation(async () => foreground) + const pane = createPane(1) + const ptyId = 'pty-launched-droid-slow' + const tabId = 'tab-launched-droid-slow' + const paneKey = makePaneKey(tabId, LEAF_1) + transportFactoryQueue.push(createMockTransport(ptyId)) + + const binding = connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ tabId, startup: { command: 'droid', launchAgent: 'droid' } }) as never + ) as unknown as { sampleForegroundAgentOnFocus: () => void } + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks() + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as ((id: string) => void) | undefined + onPtySpawn?.(ptyId) + // Whole ladder elapses while the shell is still foreground (Droid not up yet). + await vi.advanceTimersByTimeAsync(350 + 1200 + 6000) + await flushAsyncTicks() + + // Benign miss: shell never latched as foreground; encoding still safe fallback. + expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ + agent: null, + shellForeground: false + }) + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter') + + // Droid finally boots and a focus event re-samples: trust is recoverable. + foreground = 'droid' + binding.sampleForegroundAgentOnFocus() + await vi.advanceTimersByTimeAsync(350 + 1200 + 6000) + await flushAsyncTicks() + + expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ + agent: 'droid', + routingTrusted: true, + shellForeground: false + }) + expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('csi-u') + }) + it('revokes trusted Droid after accepted no-OSC exit input until shell confirmation', async () => { vi.useFakeTimers() const { connectPanePty } = await import('./pty-connection') @@ -6021,7 +6175,7 @@ describe('connectPanePty', () => { it('resets reattach renderer state after daemon snapshot replay without applying the full mode reset', async () => { const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() + const transport = createMockTransport('tab-pty') transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { if (sessionId) { return { id: sessionId, snapshot: '\x1b[?1004hrestored snapshot' } @@ -6071,7 +6225,7 @@ describe('connectPanePty', () => { // the snapshot bytes, so a remote pane whose size drifted from the daemon's // grid still repaints the exact host layout. const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() + const transport = createMockTransport('tab-pty') transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { if (sessionId) { return { @@ -6119,13 +6273,301 @@ describe('connectPanePty', () => { expect(resizeToSnapshotCall as number).toBeLessThan(snapshotWriteCall as number) }) + it('waits for a recovered destination fit before forwarding its grid or live output', async () => { + const { connectPanePty } = await import('./pty-connection') + const { safeFit } = await import('@/lib/pane-manager/pane-tree-ops') + const transport = createMockTransport('tab-pty') + let deliverLiveData = (_data: string): void => {} + transport.connect.mockImplementation( + async ({ sessionId, callbacks }: { sessionId?: string; callbacks?: ConnectCallbacks }) => { + if (callbacks?.onData) { + deliverLiveData = callbacks.onData + } + return sessionId + ? { + id: sessionId, + snapshot: 'source-grid snapshot', + snapshotCols: 80, + snapshotRows: 24 + } + : null + } + ) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] } + } as StoreState + const pane = createPane(1) + pane.fitAddon.proposeDimensions = vi.fn(() => undefined) as never + const { parseCallbacks, writes } = captureCallbackTerminalWrites(pane) + const signalPty = window.api.pty.signal as unknown as ReturnType + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(20) + transport.resize.mockClear() + signalPty.mockClear() + while (parseCallbacks.length > 0) { + parseCallbacks.shift()?.() + await flushAsyncTicks(2) + } + await flushAsyncTicks(8) + + expect(transport.resize).not.toHaveBeenCalled() + expect(signalPty).not.toHaveBeenCalledWith('tab-pty', 'SIGWINCH') + + deliverLiveData('live-after-snapshot') + await flushAsyncTicks(4) + expect(writes.join('')).not.toContain('live-after-snapshot') + + pane.fitAddon.proposeDimensions = vi.fn(() => ({ cols: 120, rows: 40 })) as never + safeFit(pane as never) + await flushAsyncTicks(12) + + expect(transport.resize).toHaveBeenCalledWith(120, 40) + expect(signalPty).toHaveBeenCalledWith('tab-pty', 'SIGWINCH') + expect(writes.join('')).toContain('live-after-snapshot') + }) + + it('restores a pinned viewport only after a same-size reattach snapshot finishes parsing', async () => { + const { connectPanePty } = await import('./pty-connection') + const { markTerminalFollowOutput, markTerminalPinnedViewport } = + await import('@/lib/pane-manager/terminal-scroll-intent') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => + sessionId + ? { + id: sessionId, + snapshot: 'same-size authoritative snapshot', + snapshotCols: 120, + snapshotRows: 40 + } + : null + ) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] } + } as StoreState + const pane = createPane(1) + pane.terminal.buffer.active.baseY = 100 + pane.terminal.buffer.active.viewportY = 80 + markTerminalPinnedViewport(pane.terminal) + const { parseCallbacks } = captureCallbackTerminalWrites(pane) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(20) + expect(pane.terminal.scrollToLine).not.toHaveBeenCalled() + + // Model xterm's native pinned state after clear + replay: the old line + // number is no longer meaningful, but the old distance from bottom is. + pane.terminal.buffer.active.baseY = 200 + pane.terminal.buffer.active.viewportY = 0 + while (parseCallbacks.length > 0) { + parseCallbacks.shift()?.() + await flushAsyncTicks(2) + } + await flushAsyncTicks(8) + + expect(pane.terminal.scrollToLine).toHaveBeenLastCalledWith(180) + markTerminalFollowOutput(pane.terminal) + }) + + it('does not apply a queued reattach snapshot after the PTY is replaced', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + const reattachResult = createDeferred<{ + id: string + snapshot: string + }>() + const replayCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ sessionId, callbacks }: { sessionId?: string; callbacks?: ConnectCallbacks }) => { + if (!sessionId) { + return null + } + replayCallback.current = callbacks?.onReplayData ?? null + return reattachResult.promise + } + ) + transportFactoryQueue.push(transport) + const pane = createPane(1) + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(8) + replayCallback.current?.('blocking replay') + await flushAsyncTicks(12) + expect(writes).toEqual(['\x1b[2J\x1b[3J\x1b[H']) + reattachResult.resolve({ id: 'tab-pty', snapshot: 'stale authoritative snapshot' }) + await flushAsyncTicks(12) + const resizeCallsBeforeReplacement = transport.resize.mock.calls.length + vi.mocked(transport.getPtyId).mockReturnValue('replacement-pty') + while (parseCallbacks.length > 0) { + parseCallbacks.shift()?.() + await flushAsyncTicks(4) + } + await flushAsyncTicks(12) + + expect(writes).not.toContain('stale authoritative snapshot') + expect(transport.resize).toHaveBeenCalledTimes(resizeCallsBeforeReplacement) + expect(window.api.pty.signal).not.toHaveBeenCalledWith('tab-pty', 'SIGWINCH') + }) + + it('does not apply a queued reattach snapshot after its PTY exits', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + const reattachResult = createDeferred<{ id: string; snapshot: string }>() + const replayCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks?: ConnectCallbacks }) => { + replayCallback.current = callbacks?.onReplayData ?? null + return reattachResult.promise + } + ) + transportFactoryQueue.push(transport) + const pane = createPane(1) + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(8) + replayCallback.current?.('blocking replay') + await flushAsyncTicks(12) + reattachResult.resolve({ id: 'tab-pty', snapshot: 'dead authoritative snapshot' }) + await flushAsyncTicks(12) + const resizeCallsBeforeExit = transport.resize.mock.calls.length + vi.mocked(transport.getPtyId).mockReturnValue(null) + while (parseCallbacks.length > 0) { + parseCallbacks.shift()?.() + await flushAsyncTicks(4) + } + await flushAsyncTicks(12) + + expect(writes).not.toContain('dead authoritative snapshot') + expect(transport.resize).toHaveBeenCalledTimes(resizeCallsBeforeExit) + expect(window.api.pty.signal).not.toHaveBeenCalledWith('tab-pty', 'SIGWINCH') + expect(transport.sendInput).not.toHaveBeenCalledWith('\x1b[I') + }) + + it('drops stale callbacks but delivers fresh replacement output after an old replay clear', async () => { + const { connectPanePty } = await import('./pty-connection') + const { getTerminalScrollIntentKind, markTerminalPinnedViewport } = + await import('@/lib/pane-manager/terminal-scroll-intent') + const transport = createMockTransport('tab-pty') + const oldResult = createDeferred() + const oldCallbacks: { current: ConnectCallbacks | null } = { current: null } + const replacementCallbacks: { current: ConnectCallbacks | null } = { current: null } + let connectCount = 0 + transport.connect.mockImplementation( + async ({ callbacks }: { sessionId?: string; callbacks?: ConnectCallbacks }) => { + connectCount += 1 + if (connectCount === 1) { + oldCallbacks.current = callbacks ?? null + return oldResult.promise + } + vi.mocked(transport.getPtyId).mockReturnValue('replacement-pty') + replacementCallbacks.current = callbacks ?? null + return 'replacement-pty' + } + ) + transportFactoryQueue.push(transport) + const pane = createPane(1) + pane.terminal.buffer.active.viewportY = 80 + pane.terminal.buffer.active.baseY = 100 + markTerminalPinnedViewport(pane.terminal) + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(8) + oldCallbacks.current?.onReplayData?.('old replay') + await flushAsyncTicks(8) + parseCallbacks.shift()?.() + await flushAsyncTicks(8) + oldCallbacks.current?.onError?.('SSH_SESSION_EXPIRED: tab-pty') + oldResult.resolve(undefined) + await flushAsyncTicks(20) + expect(replacementCallbacks.current).not.toBeNull() + + replacementCallbacks.current?.onData?.('B-PROMPT') + oldCallbacks.current?.onData?.('STALE-A') + while (parseCallbacks.length > 0) { + parseCallbacks.shift()?.() + await flushAsyncTicks(4) + } + await flushAsyncTicks(20) + + expect(writes).toContain('B-PROMPT') + expect(writes).not.toContain('STALE-A') + expect(pane.terminal.scrollToBottom).toHaveBeenCalled() + expect(getTerminalScrollIntentKind(pane.terminal)).toBe('followOutput') + }) + + it('retries a fresh-spawn native follow reset when renderer dimensions return', async () => { + const { connectPanePty } = await import('./pty-connection') + const { getTerminalScrollIntentKind } = + await import('@/lib/pane-manager/terminal-scroll-intent') + const transport = createMockTransport('fresh-pty') + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] } + } as StoreState + const pane = createPane(1) + pane.terminal.buffer.active.viewportY = 40 + pane.terminal.buffer.active.baseY = 100 + const renderListener: { current: (() => void) | null } = { current: null } + const renderDisposable = { dispose: vi.fn() } + pane.terminal.onRender = vi.fn((listener: () => void) => { + renderListener.current = listener + return renderDisposable + }) + vi.mocked(pane.terminal.scrollToBottom) + .mockImplementationOnce(() => { + throw new TypeError("Cannot read properties of undefined (reading 'dimensions')") + }) + .mockImplementation(() => { + pane.terminal.buffer.active.viewportY = pane.terminal.buffer.active.baseY + }) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks() + expect(pane.terminal.buffer.active.viewportY).toBe(40) + expect(renderListener.current).not.toBeNull() + + renderListener.current?.() + expect(pane.terminal.buffer.active.viewportY).toBe(100) + expect(pane.terminal.scrollToBottom).toHaveBeenCalledTimes(2) + expect(getTerminalScrollIntentKind(pane.terminal)).toBe('followOutput') + expect(renderDisposable.dispose).toHaveBeenCalledTimes(1) + }) + it('writes the daemon pendingEscapeTailAnsi after the reset on local reattach (#7329)', async () => { // Why: the mid-escape tail must be re-armed LAST — after the reattach reset, // whose ESC would abort it — so the racing live continuation completes it // instead of rendering literally. Covers the local daemon reattach path, // which previously dropped the field the remote path already honored. const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() + const transport = createMockTransport('tab-pty') transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { if (sessionId) { return { @@ -6267,7 +6709,7 @@ describe('connectPanePty', () => { it('preserves live modes and injects focus-in after focused agent reattach', async () => { const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() + const transport = createMockTransport('tab-pty') transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { if (sessionId) { return { id: sessionId, snapshot: '\x1b[?1004h\x1b[?25lrestored cursor snapshot' } @@ -6309,7 +6751,7 @@ describe('connectPanePty', () => { it('keeps ?25h in the live agent reattach reset when the snapshot leaves the cursor visible', async () => { const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() + const transport = createMockTransport('tab-pty') transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { if (sessionId) { // A mid-frame snapshot cut after the TUI re-showed its cursor. @@ -6342,7 +6784,7 @@ describe('connectPanePty', () => { it('does not inject focus-in after reattach when the terminal does not own DOM focus', async () => { const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() + const transport = createMockTransport('tab-pty') transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { if (sessionId) { return { id: sessionId, snapshot: '\x1b[?1004h\x1b[?25lrestored cursor snapshot' } @@ -6377,7 +6819,7 @@ describe('connectPanePty', () => { it('resets stale focus and cursor modes for a focused non-agent shell reattach', async () => { const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() + const transport = createMockTransport('tab-pty') transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { if (sessionId) { return { id: sessionId, snapshot: '\x1b[?1004h\x1b[?25lstale shell snapshot' } @@ -6414,7 +6856,7 @@ describe('connectPanePty', () => { it('does not treat persisted tab launchAgent metadata as a live agent reattach', async () => { const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() + const transport = createMockTransport('tab-pty') transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { if (sessionId) { return { id: sessionId, snapshot: '\x1b[?1004h\x1b[?25lstale shell snapshot' } @@ -6462,7 +6904,7 @@ describe('connectPanePty', () => { it('does not treat an agent-name token in a shell title as a live agent reattach', async () => { const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() + const transport = createMockTransport('tab-pty') transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { if (sessionId) { return { id: sessionId, snapshot: '\x1b[?1004h\x1b[?25lstale shell snapshot' } @@ -6501,7 +6943,7 @@ describe('connectPanePty', () => { it('does not treat ordinary shell scrollback mentioning Cursor Agent as a live agent reattach', async () => { const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() + const transport = createMockTransport('tab-pty') transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { if (sessionId) { return { @@ -6594,7 +7036,7 @@ describe('connectPanePty', () => { // authoritative source and wins by precedence. it('paints only the daemon snapshot when reattach result includes both snapshot and replay', async () => { const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() + const transport = createMockTransport('tab-pty') transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { if (sessionId) { return { @@ -6629,7 +7071,7 @@ describe('connectPanePty', () => { it('paints only relay replay when reattach result has replay and coldRestore but no snapshot', async () => { const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() + const transport = createMockTransport('tab-pty') transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { if (sessionId) { return { @@ -7870,7 +8312,7 @@ describe('connectPanePty', () => { ) transportFactoryQueue.push(transport) const pane = createPane(1) - const { writes } = captureCallbackTerminalWrites(pane) + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) const deps = createDeps({ restoredLeafId: LEAF_1, restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } @@ -7880,8 +8322,14 @@ describe('connectPanePty', () => { await flushAsyncTicks(20) const snapshotIndex = writes.indexOf('authoritative-snapshot') - const liveIndex = writes.indexOf('post-snapshot-live') expect(snapshotIndex).toBeGreaterThanOrEqual(0) + expect(writes).not.toContain('post-snapshot-live') + while (parseCallbacks.length > 0) { + parseCallbacks.shift()?.() + await flushAsyncTicks(2) + } + await flushAsyncTicks(8) + const liveIndex = writes.indexOf('post-snapshot-live') expect(liveIndex).toBeGreaterThan(snapshotIndex) }) @@ -8057,6 +8505,49 @@ describe('connectPanePty', () => { ) }) + it('holds post-snapshot live bytes until a hidden restore can fit the destination grid', async () => { + enableMainAuthority() + const { safeFit } = await import('@/lib/pane-manager/pane-tree-ops') + const deps = createDeps({ isVisibleRef: { current: false } }) + const { pane, transport, dataCallback } = await connectHiddenPane(deps) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue({ + data: 'source-grid hidden snapshot\r\n', + cols: 80, + rows: 24, + seq: 64 + }) + pane.fitAddon.proposeDimensions = vi.fn(() => undefined) as never + transport.resize.mockClear() + + dataCallback('hidden output\r\n', { seq: 16, rawLength: 16 }) + const { _dispatchPtyModelRestoreNeededForTest } = await import('./pty-model-restore-channel') + _dispatchPtyModelRestoreNeededForTest({ id: 'pty-id', reason: 'hidden-drop', markerSeq: 64 }) + ;(deps.isVisibleRef as { current: boolean }).current = true + const { requestTerminalBacklogRecovery } = + await import('@/lib/pane-manager/pane-terminal-output-scheduler') + requestTerminalBacklogRecovery(pane.terminal as never) + await flushAsyncTicks(20) + + expect(transport.resize).not.toHaveBeenCalled() + dataCallback('live-after-hidden', { seq: 81, rawLength: 17 }) + await flushAsyncTicks(6) + expect(pane.terminal.write.mock.calls.map((call) => String(call[0])).join('')).not.toContain( + 'live-after-hidden' + ) + + pane.fitAddon.proposeDimensions = vi.fn(() => ({ cols: 120, rows: 40 })) as never + safeFit(pane as never) + await flushAsyncTicks(20) + + expect(transport.resize).toHaveBeenCalledWith(120, 40) + expect(pane.terminal.write.mock.calls.map((call) => String(call[0])).join('')).toContain( + 'live-after-hidden' + ) + }) + it('kicks pane recovery when reveal finds the write pipeline certified dead', async () => { // The 2026-07-13 fossil-pane incident shape: bytes drop while hidden, // the pipeline is certified dead, and certification's own recovery @@ -8486,6 +8977,17 @@ describe('connectPanePty', () => { it('abandons the restore on queue overflow, writes the stream through, and repaints once', async () => { const { pane, dataCallback, getMainBufferSnapshot, resolveFirstSnapshot } = await startInFlightRestore() + const { markTerminalPinnedViewport } = + await import('@/lib/pane-manager/terminal-scroll-intent') + const parseCallbacks: (() => void)[] = [] + pane.terminal.write.mockImplementation((_data: string, callback?: () => void) => { + if (callback) { + parseCallbacks.push(callback) + } + }) + pane.terminal.buffer.active.viewportY = 42 + pane.terminal.buffer.active.baseY = 100 + markTerminalPinnedViewport(pane.terminal) // Flood while the snapshot is in flight: overflows the 512KB restore // queue — the live stream is outrunning snapshot fetch+replay. @@ -8499,6 +9001,15 @@ describe('connectPanePty', () => { // Cut 1: the overflow abandons the restore instead of re-fetching. expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + pane.terminal.buffer.active.viewportY = 200 + pane.terminal.buffer.active.baseY = 200 + for (const callback of parseCallbacks.splice(0)) { + callback() + } + // The post-replay fit is part of the transaction now; let its promise + // settle before asserting that overflow abandonment owns later bytes. + await flushAsyncTicks(20) + expect(pane.terminal.scrollToLine).toHaveBeenLastCalledWith(142) // Cut 2: drop sentinels and seq-gap chunks during the flood window // must not re-arm restores — the post-gap bytes write through. @@ -10313,7 +10824,11 @@ describe('connectPanePty', () => { const pane = createPane(1) pane.terminal.cols = 80 pane.terminal.rows = 8 - const { writes } = captureCallbackTerminalWrites(pane) + const writes: string[] = [] + pane.terminal.write = vi.fn((data: string, callback?: () => void) => { + writes.push(data) + callback?.() + }) const manager = createManager(1) const deps = createDeps({ isVisibleRef: { current: false }, @@ -10399,7 +10914,11 @@ describe('connectPanePty', () => { const pane = createPane(1) pane.terminal.cols = 80 pane.terminal.rows = 8 - const { writes } = captureCallbackTerminalWrites(pane) + const writes: string[] = [] + pane.terminal.write = vi.fn((data: string, callback?: () => void) => { + writes.push(data) + callback?.() + }) const manager = createManager(1) const deps = createDeps({ isVisibleRef: { current: false }, @@ -10475,7 +10994,11 @@ describe('connectPanePty', () => { const pane = createPane(1) pane.terminal.cols = 80 pane.terminal.rows = 8 - const { writes } = captureCallbackTerminalWrites(pane) + const writes: string[] = [] + pane.terminal.write = vi.fn((data: string, callback?: () => void) => { + writes.push(data) + callback?.() + }) const manager = createManager(1) const deps = createDeps({ isVisibleRef: { current: true }, @@ -11733,6 +12256,120 @@ describe('connectPanePty', () => { disposable.dispose() }) + it('cancels a delayed snapshot scroll restore when the pane binding is disposed', async () => { + const { connectPanePty } = await import('./pty-connection') + const { isTerminalScrollIntentRebuildInFlight } = + await import('@/lib/pane-manager/terminal-scroll-intent-rebuild') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: ((data: string, meta?: { seq?: number; rawLength?: number }) => 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 hidden = 'x'.repeat(2 * 1024 * 1024 + 1) + const live = 'visible-after\r\n' + getMainBufferSnapshot.mockResolvedValue({ + data: 'snapshot-state\r\n', + cols: 100, + rows: 30, + seq: hidden.length + live.length + }) + + const pane = createPane(1) + pane.terminal.buffer.active.viewportY = 42 + pane.terminal.buffer.active.baseY = 100 + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + const deps = createDeps({ isVisibleRef: { current: false } }) + const disposable = connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.(live, { + seq: hidden.length + live.length, + rawLength: live.length + }) + await flushAsyncTicks(20) + + expect(writes).toContain('snapshot-state\r\n') + expect(isTerminalScrollIntentRebuildInFlight(pane.terminal)).toBe(true) + pane.terminal.scrollToLine.mockClear() + disposable.dispose() + expect(isTerminalScrollIntentRebuildInFlight(pane.terminal)).toBe(true) + + pane.terminal.buffer.active.baseY = 200 + pane.terminal.buffer.active.viewportY = 200 + for (const callback of parseCallbacks) { + callback() + } + await flushAsyncTicks() + + expect(isTerminalScrollIntentRebuildInFlight(pane.terminal)).toBe(false) + expect(pane.terminal.scrollToLine).not.toHaveBeenCalled() + }) + + it('does not apply a delayed snapshot restore after newer user intent', async () => { + const { connectPanePty } = await import('./pty-connection') + const { markTerminalFollowOutput } = await import('@/lib/pane-manager/terminal-scroll-intent') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: ((data: string, meta?: { seq?: number; rawLength?: number }) => 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 hidden = 'x'.repeat(2 * 1024 * 1024 + 1) + const live = 'visible-after\r\n' + getMainBufferSnapshot.mockResolvedValue({ + data: 'snapshot-state\r\n', + cols: 100, + rows: 30, + seq: hidden.length + live.length + }) + + const pane = createPane(1) + pane.terminal.buffer.active.viewportY = 42 + pane.terminal.buffer.active.baseY = 100 + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + const deps = createDeps({ isVisibleRef: { current: false } }) + const disposable = connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.(live, { + seq: hidden.length + live.length, + rawLength: live.length + }) + await flushAsyncTicks(20) + + expect(writes).toContain('snapshot-state\r\n') + pane.terminal.buffer.active.viewportY = 200 + pane.terminal.buffer.active.baseY = 200 + markTerminalFollowOutput(pane.terminal) + pane.terminal.scrollToLine.mockClear() + for (const callback of parseCallbacks) { + callback() + } + await flushAsyncTicks() + + // Why: replay completion must not overwrite scroll intent recorded while + // xterm was still parsing the restored snapshot. + expect(pane.terminal.scrollToLine).not.toHaveBeenCalled() + disposable.dispose() + }) + it('does not signal SIGWINCH after hidden-backlog snapshot replay when dimensions are unchanged', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') @@ -12345,7 +12982,7 @@ describe('connectPanePty', () => { capturedReplayCallback.current?.('first replay') capturedReplayCallback.current?.('second replay') - await flushAsyncTicks(2) + await flushAsyncTicks(6) expect(pane.terminal.write).toHaveBeenCalledTimes(1) expect(pane.terminal.write).toHaveBeenNthCalledWith( @@ -12366,6 +13003,116 @@ describe('connectPanePty', () => { disposable.dispose() }) + it('holds newer live bytes until a later replay frame has fully parsed', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const transport = createMockTransport('remote:env-1@@terminal-live-order') + const callbacksRef: { + replay: ((data: string) => void) | null + data: ((data: string) => void) | null + } = { replay: null, data: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + callbacksRef.replay = callbacks.onReplayData ?? null + callbacksRef.data = callbacks.onData ?? null + return 'remote:env-1@@terminal-live-order' + }) + transportFactoryQueue.push(transport) + const pane = createPane(1) + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + const binding = connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(8) + + callbacksRef.replay?.('authoritative replay') + await flushAsyncTicks(8) + expect(writes).toEqual(['\x1b[2J\x1b[3J\x1b[H']) + + callbacksRef.data?.('NEWER-LIVE\r\n') + expect(writes).not.toContain('NEWER-LIVE\r\n') + for (let index = 0; index < 12 && parseCallbacks.length > 0; index += 1) { + parseCallbacks.shift()?.() + await flushAsyncTicks(4) + } + await flushAsyncTicks(8) + + const replayIndex = writes.indexOf('authoritative replay') + const resetIndex = writes.indexOf(POST_REPLAY_REATTACH_RESET) + const liveIndex = writes.indexOf('NEWER-LIVE\r\n') + expect(replayIndex).toBeGreaterThan(0) + expect(resetIndex).toBeGreaterThan(replayIndex) + expect(liveIndex).toBeGreaterThan(resetIndex) + binding.dispose() + }) + + it('drops a queued relay replay instead of retagging it for a replacement PTY', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const transport = createMockTransport('remote:env-1@@terminal-old') + const replayCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + replayCallback.current = callbacks.onReplayData ?? null + return 'remote:env-1@@terminal-old' + }) + transportFactoryQueue.push(transport) + const pane = createPane(1) + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + const binding = connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(8) + + replayCallback.current?.('blocking replay') + await flushAsyncTicks(8) + replayCallback.current?.('stale queued replay') + vi.mocked(transport.getPtyId).mockReturnValue('remote:env-1@@terminal-replacement') + while (parseCallbacks.length > 0) { + parseCallbacks.shift()?.() + await flushAsyncTicks(4) + } + await flushAsyncTicks(12) + + expect(writes).not.toContain('blocking replay') + expect(writes).not.toContain('stale queued replay') + binding.dispose() + }) + + it('requests snapshot recovery for one oversized live frame deferred by replay', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-large-live') + const callbacksRef: { + replay: ((data: string) => void) | null + data: ((data: string) => void) | null + } = { replay: null, data: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + callbacksRef.replay = callbacks.onReplayData ?? null + callbacksRef.data = callbacks.onData ?? null + return 'pty-large-live' + }) + transportFactoryQueue.push(transport) + const pane = createPane(1) + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + const binding = connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(8) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue(null) + getMainBufferSnapshot.mockClear() + + callbacksRef.replay?.('authoritative replay') + await flushAsyncTicks(8) + const oversizedLiveFrame = 'L'.repeat(512 * 1024 + 1) + callbacksRef.data?.(oversizedLiveFrame) + while (parseCallbacks.length > 0) { + parseCallbacks.shift()?.() + await flushAsyncTicks(4) + } + await flushAsyncTicks(20) + + expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-large-live', { + scrollbackRows: 5000 + }) + expect(writes.some((write) => write.startsWith('L'))).toBe(false) + binding.dispose() + }) + it('does not switch renderers for Arabic output', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport() @@ -13565,8 +14312,10 @@ describe('connectPanePty', () => { it('attaches remote runtime PTY handles instead of creating a replacement terminal', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport() - transport.attach.mockImplementation(() => { + transport.attach.mockImplementation(({ callbacks }: { callbacks?: ConnectCallbacks }) => { transport.getPtyId.mockReturnValue('remote:env-1@@terminal-1') + callbacks?.onReplayData?.('restored remote prompt $ ') + callbacks?.onConnect?.() }) transportFactoryQueue.push(transport) @@ -13582,10 +14331,12 @@ describe('connectPanePty', () => { } as StoreState const pane = createPane(2) + pane.terminal.write.mockImplementation((_data: string, callback?: () => void) => callback?.()) const manager = createManager(2) const deps = createDeps() connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks() expect(transport.connect).not.toHaveBeenCalled() expect(transport.attach).toHaveBeenCalledWith( @@ -13593,6 +14344,58 @@ describe('connectPanePty', () => { ) expect(deps.updateTabPtyId).toHaveBeenCalledWith('tab-1', 'remote:env-1@@terminal-1') expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'remote:env-1@@terminal-1') + await vi.waitFor(() => + expect(window.api.pty.reportRendererSerializerReady).toHaveBeenCalledWith( + 'remote:env-1@@terminal-1' + ) + ) + }) + + it('reports remote renderer readiness only after delayed restore output is parsed', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + let remoteCallbacks: ConnectCallbacks | undefined + transport.attach.mockImplementation(({ callbacks }: { callbacks?: ConnectCallbacks }) => { + transport.getPtyId.mockReturnValue('remote:env-1@@terminal-delayed') + remoteCallbacks = callbacks + }) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'remote:terminal-delayed' }] + }, + settings: { + ...mockStoreState.settings, + activeRuntimeEnvironmentId: 'env-1' + } + } as StoreState + const pane = createPane(2) + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + + connectPanePty(pane as never, createManager(2) as never, createDeps() as never) + await flushAsyncTicks() + expect(window.api.pty.reportRendererSerializerReady).not.toHaveBeenCalled() + + remoteCallbacks?.onReplayData?.('delayed restored prompt $ ') + remoteCallbacks?.onConnect?.() + await vi.waitFor(() => expect(parseCallbacks.length).toBeGreaterThan(0)) + expect(window.api.pty.reportRendererSerializerReady).not.toHaveBeenCalled() + + for (let step = 0; step < 10; step += 1) { + await vi.waitFor(() => expect(parseCallbacks.length).toBeGreaterThan(0)) + parseCallbacks.shift()?.() + await flushAsyncTicks() + if (vi.mocked(window.api.pty.reportRendererSerializerReady!).mock.calls.length > 0) { + break + } + } + expect(writes.join('')).toContain('delayed restored prompt $ ') + await vi.waitFor(() => + expect(window.api.pty.reportRendererSerializerReady).toHaveBeenCalledWith( + 'remote:env-1@@terminal-delayed' + ) + ) }) it('cold-spawns slept remote runtime PTYs instead of reattaching the preserved handle', async () => { @@ -13612,6 +14415,9 @@ describe('connectPanePty', () => { | ((ptyId: string) => void) | undefined onPtySpawn?.(freshPtyId) + const connectCallbacks = callbacks as ConnectCallbacks | undefined + connectCallbacks?.onReplayData?.('shell ready\r\n') + connectCallbacks?.onConnect?.() return freshPtyId } ) @@ -13646,6 +14452,7 @@ describe('connectPanePty', () => { } as StoreState const pane = createPane(1) + pane.terminal.write.mockImplementation((_data: string, callback?: () => void) => callback?.()) const manager = createManager(1) const deps = createDeps({ restoredLeafId: LEAF_1, @@ -13685,6 +14492,7 @@ describe('connectPanePty', () => { expect(deps.clearTabPtyId).toHaveBeenCalledWith('tab-1', restoredPtyId) expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, freshPtyId) expect(deps.updateTabPtyId).toHaveBeenCalledWith('tab-1', freshPtyId) + expect(window.api.pty.reportRendererSerializerReady).toHaveBeenCalledWith(freshPtyId) expect(transport.sendInput).not.toHaveBeenCalled() expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledWith(paneKey) }) @@ -15811,9 +16619,11 @@ describe('connectPanePty', () => { it('replays attach buffer for deferred SSH reattach and clears stale tab session metadata', async () => { const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() + const transport = createMockTransport('leaf-session') transport.connect.mockImplementation(async (opts: { sessionId?: string }) => { - return { id: opts.sessionId ?? 'pty-new', replay: 'restored-ssh-output' } + const id = opts.sessionId ?? 'pty-new' + transport.getPtyId.mockReturnValue(id) + return { id, replay: 'restored-ssh-output' } }) transportFactoryQueue.push(transport) @@ -15826,6 +16636,7 @@ describe('connectPanePty', () => { } const pane = createPane(1) + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) const manager = createManager(1) const deps = createDeps({ restoredLeafId: LEAF_1, @@ -15835,6 +16646,15 @@ describe('connectPanePty', () => { connectPanePty(pane as never, manager as never, deps as never) await flushAsyncTicks(20) + const settlePaneSerializer = vi.mocked(window.api.pty.settlePaneSerializer) + expect(parseCallbacks.length).toBeGreaterThan(0) + expect(settlePaneSerializer).not.toHaveBeenCalled() + for (let step = 0; step < 20 && settlePaneSerializer.mock.calls.length === 0; step += 1) { + parseCallbacks.shift()?.() + await flushAsyncTicks() + } + expect(settlePaneSerializer).toHaveBeenCalledWith(expect.any(String), 1) + const api = ( globalThis as unknown as { window: { @@ -15855,12 +16675,9 @@ describe('connectPanePty', () => { // Why: the relay's replay buffer holds the full terminal history, so the // client clears xterm before writing to prevent duplication with any // content already in the terminal from a prior session. - expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[2J\x1b[3J\x1b[H', expect.any(Function)) - expect(pane.terminal.write).toHaveBeenCalledWith('restored-ssh-output', expect.any(Function)) - expect(pane.terminal.write).toHaveBeenCalledWith( - POST_REPLAY_REATTACH_RESET, - expect.any(Function) - ) + expect(writes).toContain('\x1b[2J\x1b[3J\x1b[H') + expect(writes).toContain('restored-ssh-output') + expect(writes).toContain(POST_REPLAY_REATTACH_RESET) expect(api.pty.signal).toHaveBeenCalledWith('leaf-session', 'SIGWINCH') }) @@ -18546,7 +19363,7 @@ describe('connectPanePty', () => { await flushAsyncTicks() // xterm is 120x40 (createPane default), PTY reports 80x24 → re-assert. - expect(transport.resize).toHaveBeenCalledWith(120, 40) + expect(transport.resize).toHaveBeenCalledWith(120, 40, { claim: true }) }) it('does not fit during visibility-resume reassertion', async () => { @@ -18571,7 +19388,7 @@ describe('connectPanePty', () => { await flushAsyncTicks() expect(fit).not.toHaveBeenCalled() - expect(transport.resize).toHaveBeenCalledWith(120, 40) + expect(transport.resize).toHaveBeenCalledWith(120, 40, { claim: true }) }) it('re-asserts after observed pane geometry changes while visible', async () => { @@ -18789,6 +19606,61 @@ describe('connectPanePty', () => { } }) + it('defers a remote-desktop viewport claim until structural replay completes', async () => { + const originalDocument = globalThis.document + ;(globalThis as { document?: Document }).document = { + visibilityState: 'visible', + hasFocus: vi.fn(() => true) + } as unknown as Document + globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { + queueMicrotask(() => callback(0)) + return 1 + }) + const { setFitOverride } = await import('@/lib/pane-manager/mobile-fit-overrides') + const { beginTerminalScrollIntentBufferRebuild, endTerminalScrollIntentBufferRebuild } = + await import('@/lib/pane-manager/terminal-scroll-intent-rebuild') + const pane = createPane(2) + const observer = installObservedPane(pane) + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-pane-2') + transportFactoryQueue.push(transport) + const manager = createManager(2) + const deps = createDeps({ + restoredLeafId: LEAF_2, + paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } + }) + let proposedGrid = { cols: 120, rows: 40 } + pane.fitAddon = { + ...pane.fitAddon, + proposeDimensions: vi.fn(() => proposedGrid) + } as never + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks() + observer.trigger() + await flushAsyncTicks() + setFitOverride('pty-pane-2', 'remote-desktop-fit', 80, 24) + proposedGrid = { cols: 70, rows: 30 } + vi.mocked(pane.terminal.resize).mockClear() + transport.resize.mockClear() + beginTerminalScrollIntentBufferRebuild(pane.terminal) + + observer.trigger() + await flushAsyncTicks() + expect(pane.terminal.resize).not.toHaveBeenCalled() + expect(transport.resize).not.toHaveBeenCalled() + + endTerminalScrollIntentBufferRebuild(pane.terminal) + await flushAsyncTicks() + expect(pane.terminal.resize).toHaveBeenCalledWith(70, 30) + expect(transport.resize).toHaveBeenCalledWith(70, 30, { claim: true }) + } finally { + setFitOverride('pty-pane-2', 'desktop-fit', 0, 0) + observer.restore() + globalThis.document = originalDocument + } + }) + it('skips observed desktop reassertion while mobile owns the PTY without a fit override', async () => { const { setDriverForPty } = await import('@/lib/pane-manager/mobile-driver-state') const pane = createPane(2) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 8382693987f..859a71bba1a 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -41,7 +41,12 @@ import { type HasPty } from './terminal-dead-session-reconcile' import type { PtyConnectionDeps } from './pty-connection-types' -import { safeFit } from '@/lib/pane-manager/pane-tree-ops' +import { + cancelPendingSafeFitContinuations, + safeFit, + safeFitAndThen, + type SafeFitContinuationHandle +} from '@/lib/pane-manager/pane-tree-ops' import { requestStablePaneFit } from '@/lib/pane-manager/pane-fit-resize-observer' import { getFitOverrideForPty, bindPanePtyId } from '@/lib/pane-manager/mobile-fit-overrides' import { isPtyLocked } from '@/lib/pane-manager/mobile-driver-state' @@ -49,7 +54,12 @@ import { reconcilePtySizeAcrossFrames, type PtySizeReconcileHandle } from './pty import { shouldClaimRemoteDesktopViewport } from './remote-desktop-viewport-claim' import { getAppliedSizeReadE2eDelayMs } from './pty-applied-size-read-e2e-delay' import { createPtySizeReassertion } from './pty-size-reassertion' -import { isPaneReplaying, replayIntoTerminal, replayIntoTerminalAsync } from './replay-guard' +import { + isPaneReplaying, + replayIntoTerminal, + replayIntoTerminalAsync, + waitForTerminalReplayWritesParsed +} from './replay-guard' import { isTerminalWritePipelineCertifiedDead, registerUndeliverableWriteHandler @@ -98,7 +108,11 @@ import { } from '../../../../shared/terminal-color-scheme-protocol' import { warnTerminalLifecycleAnomaly } from './terminal-lifecycle-diagnostics' import { subscribeToTerminalUserInput } from './terminal-user-input-signal' -import { registerPtySerializer, registerPtyTitleSource } from './pty-buffer-serializer' +import { + hasPtySerializer, + registerPtySerializer, + registerPtyTitleSource +} from './pty-buffer-serializer' import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream' import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection' import { @@ -115,10 +129,16 @@ import { } from '@/lib/pane-manager/windows-pty-compatibility' import { recordTerminalOutput } from '@/lib/pane-manager/pane-scroll' import { ensureArabicShapingJoinerForText } from '@/lib/pane-manager/terminal-arabic-shaping-joiner' +import { clearTerminalScrollbackAndFollowOutput } from '@/lib/pane-manager/terminal-scrollback-clear' import { - captureTerminalWriteScrollIntent, - enforceTerminalWriteScrollIntent + getTerminalScrollIntentKind, + markTerminalFollowOutput } from '@/lib/pane-manager/terminal-scroll-intent' +import { + cancelTerminalScrollIntentBufferRebuildCompletions, + deferTerminalGeometryMutationDuringRebuild +} from '@/lib/pane-manager/terminal-scroll-intent-rebuild' +import { createTerminalStructuralReplayCoordinator } from '@/lib/pane-manager/terminal-structural-replay-coordinator' import { createBrowserUuid } from '@/lib/browser-uuid' import { makePaneKey, parseLegacyNumericPaneKey } from '../../../../shared/stable-pane-id' import { @@ -959,6 +979,7 @@ export function connectPanePty( const terminalRecoveryInstance = registerTerminalPaneRecoveryInstance(deps.tabId) exposeE2eTerminalPtyOutputDebug() let disposed = false + const structuralReplayCoordinator = createTerminalStructuralReplayCoordinator(pane.terminal) let connectFrame: number | null = null let connectFallbackTimer: ReturnType | null = null let startupGridSettleHandle: TerminalStartupGridSettleHandle | null = null @@ -966,6 +987,10 @@ export function connectPanePty( let connectStarted = false let unregisterBacklogRecovery: (() => void) | null = null let unregisterDocumentVisibilityRecovery: (() => void) | null = null + let cancelHiddenOutputSnapshotScrollRestore = (): void => {} + let pendingHiddenSnapshotFit: SafeFitContinuationHandle | null = null + let pendingReattachFit: SafeFitContinuationHandle | null = null + let cancelFreshSpawnFollowReset = (): void => {} let cleanupHiddenOutputRestoreDeferredRetry = (): void => {} let cleanupHiddenOutputRestoreForegroundDeadline = (): void => {} let cleanupHiddenOutputRestoreFloodRepaint = (): void => {} @@ -1867,6 +1892,21 @@ export function connectPanePty( (isTuiAgent(registeredLaunchAgent) ? registeredLaunchAgent : undefined) ) } + // Why: the concrete TUI agent a fresh spawn is expected to launch, used to seed + // a command-start confirmation on no-OSC shells (Git Bash/cmd) that never emit + // one. Returns null when the expectation isn't a recognized TUI agent. + const resolveExpectedLaunchTuiAgent = (): TuiAgent | null => { + const state = useAppStore.getState() + const tab = (state.tabsByWorktree[deps.worktreeId] ?? []).find( + (candidate) => candidate.id === deps.tabId + ) + const candidate = + tab?.launchAgent ?? + paneStartup?.launchAgent ?? + paneStartup?.initialAgentStatus?.agent ?? + state.agentLaunchConfigByPaneKey[cacheKey]?.identity.agentType + return isTuiAgent(candidate) ? candidate : null + } // Why: a launched/hook-known agent pane must confirm — not trust — a 133;D so a // full-screen agent's leaked nested-shell 133;D can't clear its tab identity, // even on a restore where no command-start read has recorded evidence yet. @@ -2709,10 +2749,25 @@ export function connectPanePty( // frame-level sync often runs before that async result arrives. scheduleRuntimeGraphSync() agentCompletionCoordinator.startProcessTracking() - // Why: fresh spawns receive future OSC command-start events; only adopted or - // restored PTYs may already be inside Codex with no new foreground signal. + // Why: fresh spawns normally rely on a future OSC 133 command-start read to + // identify the launched agent; only adopted or restored PTYs may already be + // inside Codex with no new foreground signal. But no-OSC shells (Git Bash, + // cmd.exe) never emit a command-start, so an expected-agent pane spawned into + // one would never gain the fresh process evidence that authorizes Droid's + // Windows Shift+Enter CSI-u routing — leaving Shift+Enter to submit (#7620). + // Seed the SAME command-start confirmation the manually-typed launch path + // uses (onCommandStarted): its bounded retry ladder spans agent boot, and a + // miss publishes shellForeground:false — recoverable by later focus/reveal + // samples. A visible-pty sample would instead let a slow boot latch a + // known-agent shell-confirm that clears launch identity and blocks recovery. + // A real OSC 133;C, if it arrives, simply supersedes this. if (options.sampleVisibleForegroundAgent === true) { sampleVisiblePaneForegroundAgent() + } else if (options.seedInitialAgentStatus === true) { + const freshSpawnLaunchAgent = resolveExpectedLaunchTuiAgent() + if (freshSpawnLaunchAgent) { + paneForegroundAgentTracker.onCommandStarted(freshSpawnLaunchAgent) + } } } @@ -3162,8 +3217,13 @@ export function connectPanePty( const hadExistingPaneTransportAtConnect = deps.paneTransportsRef.current.size > 0 let lastTerminalInputAt = Number.NEGATIVE_INFINITY let hasReceivedPtyOutput = false - let deferredReattachLiveData: { data: string; meta?: PtyDataMeta }[] | null = null + let deferredReattachLiveData: + | { data: string; ptyId: string | null; streamGeneration: number; meta?: PtyDataMeta }[] + | null = null let deferredReattachLiveDataChars = 0 + let reattachLiveDataDeferralDepth = 0 + let deferredReattachLiveDataOwners = new Map() + let transportStreamGeneration = 0 const MAX_DEFERRED_REATTACH_LIVE_CHARS = 512 * 1024 const MAX_DEFERRED_REATTACH_LIVE_CHUNKS = 1_024 const markTerminalInputSent = (): void => { @@ -3629,7 +3689,7 @@ export function connectPanePty( getPtyId: () => transport.getPtyId(), isRemotePtyId: isRemoteRuntimePtyId, shouldSuppressDesktopResize: () => shouldSuppressDesktopPtyResize(), - fit: () => safeFit(pane), + fitAndRun: (continuation) => safeFitAndThen(pane, 'pty-size-reassertion', continuation), getTerminalDimensions: () => ({ cols: pane.terminal.cols, rows: pane.terminal.rows }), getAppliedSize: async (ptyId) => { // Why: e2e seam — delays the read past the reveal fit to reproduce the @@ -3712,6 +3772,18 @@ export function connectPanePty( let pendingPaneGeometryChanged = false const handleObservedPaneGeometry = (): void => { pendingGeometryReportRaf = null + if (disposed) { + return + } + if ( + deferTerminalGeometryMutationDuringRebuild( + pane.terminal, + 'observed-pane-geometry', + handleObservedPaneGeometry + ) + ) { + return + } const paneGeometryChanged = pendingPaneGeometryChanged pendingPaneGeometryChanged = false const currentPtyId = transport.getPtyId() @@ -3842,7 +3914,9 @@ export function connectPanePty( // the grid stabilizes. While hidden it keeps watching for a late settle. isAuthoritative: () => isRendererPtyResizeAuthoritative(), measure: () => { - safeFit(pane) + if (!safeFit(pane)) { + return null + } const cols = pane.terminal.cols const rows = pane.terminal.rows return cols > 0 && rows > 0 ? { cols, rows } : null @@ -3879,7 +3953,9 @@ export function connectPanePty( } } const measureStartupGrid = (): { cols: number; rows: number } | null => { - safeFit(pane) + if (!safeFit(pane)) { + return null + } const cols = pane.terminal.cols const rows = pane.terminal.rows return cols > 0 && rows > 0 ? { cols, rows } : null @@ -4020,7 +4096,10 @@ export function connectPanePty( return { data, cols: pane.terminal.cols, - rows: pane.terminal.rows + rows: pane.terminal.rows, + ...(rendererOrderedPtyId === ptyId && rendererOrderedSeq !== null + ? { seq: rendererOrderedSeq } + : {}) } } catch { return null @@ -4029,7 +4108,7 @@ export function connectPanePty( () => { clearHiddenOutputRestoreState() discardTerminalOutput(pane.terminal) - pane.terminal.clear() + clearTerminalScrollbackAndFollowOutput(pane.terminal) } ) const unregisterTitleSource = registerPtyTitleSource(ptyId, (handler) => @@ -4043,6 +4122,47 @@ export function connectPanePty( } } + let replayWriteQueue = Promise.resolve() + const settlePaneSerializerAfterReplay = async ( + ptyId: string, + generation: number + ): Promise => { + try { + await replayWriteQueue + if (disposed || transport.getPtyId() !== ptyId) { + await window.api.pty.clearPendingPaneSerializer(cacheKey, generation).catch(() => {}) + return + } + await waitForTerminalOutputParsed(pane.terminal) + if (!disposed && transport.getPtyId() === ptyId) { + await window.api.pty.settlePaneSerializer(cacheKey, generation) + return + } + } catch { + // Clear below so a failed parser/replay cannot leave the pane generation pending. + } + await window.api.pty.clearPendingPaneSerializer(cacheKey, generation).catch(() => {}) + } + const reportRemoteRendererSerializerReady = (): void => { + const ptyId = transport.getPtyId() + if (!ptyId || !isRemoteRuntimePtyId(ptyId)) { + return + } + if (!hasPtySerializer(ptyId)) { + registerPaneSerializerFor(ptyId) + } + // Why: onSubscribed follows the snapshot callback, but replay drains + // asynchronously; join it and xterm's parser before reporting readiness. + void replayWriteQueue + .then(() => waitForTerminalOutputParsed(pane.terminal)) + .then(() => { + if (!disposed && transport.getPtyId() === ptyId) { + void window.api.pty.reportRendererSerializerReady?.(ptyId) + } + }) + .catch(() => {}) + } + // Why: for ordinary local startup commands, the local PTY provider already // writes via the shell-ready barrier. terminal-paste and SSH startup // commands stay renderer-delivered so xterm/relay can apply their handling. @@ -4416,12 +4536,60 @@ export function connectPanePty( }, 50) } + let freshSpawnFollowResetDisposables: IDisposable[] = [] + cancelFreshSpawnFollowReset = (): void => { + for (const disposable of freshSpawnFollowResetDisposables) { + disposable.dispose() + } + freshSpawnFollowResetDisposables = [] + } + const resetFreshSpawnFollowOutput = (): void => { + cancelFreshSpawnFollowReset() + markTerminalFollowOutput(pane.terminal) + let nativeFollowResetComplete = false + const tryResetNativeFollow = (): void => { + if ( + disposed || + getTerminalScrollIntentKind(pane.terminal) !== 'followOutput' || + deferTerminalGeometryMutationDuringRebuild( + pane.terminal, + 'fresh-spawn-follow-reset', + tryResetNativeFollow + ) + ) { + return + } + try { + pane.terminal.scrollToBottom() + nativeFollowResetComplete = true + cancelFreshSpawnFollowReset() + } catch (err) { + if (!(err instanceof TypeError && /dimensions/.test(err.message))) { + cancelFreshSpawnFollowReset() + throw err + } + } + } + tryResetNativeFollow() + if (!nativeFollowResetComplete) { + // Why: xterm's browser viewport can reject scrolling while its renderer + // is detached; the first render/resize is the earliest safe native retry. + freshSpawnFollowResetDisposables = [ + pane.terminal.onRender(tryResetNativeFollow), + pane.terminal.onResize(tryResetNativeFollow) + ] + } + } + const startFreshSpawn = ( startupOverride?: PendingStartupCommand | null, options: FreshSpawnOptions = {} ): Promise => { clearPaneMode2031State() clearHiddenOutputRestoreState() + // Why: a canceled old replay clear can preserve xterm's native + // isUserScrolling flag. A replacement shell must start in follow mode. + resetFreshSpawnFollowOutput() // Why: a fresh spawn is a new process with kitty keyboard flags at // zero. The exit-handler reset alone is not enough: a late exit from a // replaced PTY takes the stale-transport early return and skips it, so @@ -4449,6 +4617,7 @@ export function connectPanePty( : window.api.pty.declarePendingPaneSerializer(cacheKey).catch(() => null) transportConnectInFlightSince = Date.now() + const outputCallbacks = captureTransportOutputCallbacks(reportError) const spawnedRaw = transport.connect({ url: '', cols, @@ -4461,11 +4630,7 @@ export function connectPanePty( ...(coldRestoreOverride ? { launchToken: coldRestoreOverride.launchToken } : {}), ...(coldRestoreOverride ? { launchAgent: coldRestoreOverride.agent } : {}), ...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}), - callbacks: { - onData: dataCallback, - onReplayData: replayDataCallback, - onError: reportError - } + callbacks: outputCallbacks.callbacks }) void Promise.resolve(spawnedRaw) @@ -4475,6 +4640,13 @@ export function connectPanePty( }) const trackedPromise: Promise = Promise.resolve(spawnedRaw) .then(async (spawnedPtyId) => { + if (outputCallbacks.generation !== transportStreamGeneration) { + const gen = await preSignalPromise + if (typeof gen === 'number') { + void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) + } + return null + } const resolvedPtyId = spawnedPtyId && typeof spawnedPtyId === 'object' && 'id' in spawnedPtyId ? spawnedPtyId.id @@ -4529,9 +4701,11 @@ export function connectPanePty( reconcilePtySizeAfterSpawn(resolvedPtyId, cols, rows) } const gen = await preSignalPromise - if (typeof gen === 'number' && resolvedPtyId) { - if (!isRemoteRuntimePtyId(resolvedPtyId)) { + if (resolvedPtyId && (typeof gen === 'number' || isRemoteRuntimePtyId(resolvedPtyId))) { + if (!isRemoteRuntimePtyId(resolvedPtyId) || !hasPtySerializer(resolvedPtyId)) { registerPaneSerializerFor(resolvedPtyId) + } + if (typeof gen === 'number') { void window.api.pty.settlePaneSerializer(cacheKey, gen).catch(() => {}) } } else if (typeof gen === 'number') { @@ -4736,10 +4910,18 @@ export function connectPanePty( writeFreshShellViewportBlanking() } - const sendFocusedReattachFocusInAfterReplay = (): void => { + const sendFocusedReattachFocusInAfterReplay = ( + expectedPtyId: string | null = transport.getPtyId(), + expectedStreamGeneration = transportStreamGeneration + ): void => { const scheduledGeneration = reattachReplayPayloadSignalGeneration void waitForTerminalOutputParsed(pane.terminal).then(() => { - if (disposed) { + const currentPtyId = transport.getPtyId() + if ( + disposed || + expectedStreamGeneration !== transportStreamGeneration || + currentPtyId !== expectedPtyId + ) { return } // Why: a newer replay frame owns the judgment; its own post-parse @@ -4778,24 +4960,56 @@ export function connectPanePty( }) } - let replayWriteQueue = Promise.resolve() type PendingReplayData = { data: string clearBeforeReplay: boolean + ptyId: string | null + generation: number + streamGeneration: number pendingEscapeTailAnsi?: string } let pendingReplayData: PendingReplayData | null = null + let replayPayloadGeneration = 0 let replayDrainQueued = false - const drainReplayDataQueue = async (): Promise => { + const drainReplayDataQueue = async ( + expectedPtyId: string | null, + expectedStreamGeneration: number + ): Promise => { + let appliedCurrentPayload = false while (pendingReplayData !== null) { - const { data, clearBeforeReplay, pendingEscapeTailAnsi } = pendingReplayData + if ( + pendingReplayData.ptyId !== expectedPtyId || + pendingReplayData.streamGeneration !== expectedStreamGeneration + ) { + return false + } + if ( + transport.getPtyId() !== expectedPtyId || + transportStreamGeneration !== expectedStreamGeneration + ) { + pendingReplayData = null + return false + } + const payload = pendingReplayData + const { data, clearBeforeReplay, pendingEscapeTailAnsi } = payload pendingReplayData = null + const isCurrentPayload = (): boolean => + !disposed && + payload.generation === replayPayloadGeneration && + payload.streamGeneration === transportStreamGeneration && + transport.getPtyId() === payload.ptyId + if (!isCurrentPayload()) { + continue + } // Relay replay buffers may overlap with content already rendered in // xterm. Local eager replay decides this earlier so metadata-only frames // can keep restored scrollback while still using the replay guard. if (clearBeforeReplay) { await writeReplayDataAsync('\x1b[2J\x1b[3J\x1b[H') + if (!isCurrentPayload()) { + continue + } } if (clearBeforeReplay || data.length > 0) { // Why: an empty clearing frame is still an authoritative repaint and @@ -4808,9 +5022,15 @@ export function connectPanePty( // apply as sets to keep the mirrored stack from accumulating frames. kittyKeyboardModes.scanReplay(data) await writeReplayDataAsync(data) + if (!isCurrentPayload()) { + continue + } if (clearBeforeReplay || data.length > 0) { await writeReplayDataAsync(reattachReplayResetSequence(data)) - sendFocusedReattachFocusInAfterReplay() + if (!isCurrentPayload()) { + continue + } + sendFocusedReattachFocusInAfterReplay(payload.ptyId, payload.streamGeneration) } // Why: the daemon could not serialize a PTY read that ended mid-escape, // so the emulator shipped the dangling partial separately. Write it LAST @@ -4820,43 +5040,113 @@ export function connectPanePty( if (pendingEscapeTailAnsi) { await writeReplayDataAsync(pendingEscapeTailAnsi) } - if (disposed) { - pendingReplayData = null - return + if (!isCurrentPayload()) { + continue } // Why: remote-runtime snapshots can arrive after WebGL attached to an // empty buffer; rebuilding after replay parses seeds the glyph atlas // from the now-populated xterm state. manager.rebuildPaneWebgl(pane.id) + appliedCurrentPayload = true } + return appliedCurrentPayload + } + const scheduleReplayDataDrain = (): void => { + if (replayDrainQueued) { + return + } + const scheduledPtyId = pendingReplayData?.ptyId ?? null + replayDrainQueued = true + // Why: live bytes are newer than the authoritative replay frame. Hold + // them until clear + replay + reset have all parsed, or replay can erase them. + const scheduledStreamGeneration = + pendingReplayData?.streamGeneration ?? transportStreamGeneration + beginReattachLiveDataDeferral(scheduledStreamGeneration) + let replayCompleted = false + replayWriteQueue = replayWriteQueue + .catch(() => undefined) + .then(() => + structuralReplayCoordinator.run( + async () => { + replayCompleted = await drainReplayDataQueue( + scheduledPtyId, + scheduledStreamGeneration + ) + }, + { + shouldRestore: () => + !disposed && + transport.getPtyId() === scheduledPtyId && + transportStreamGeneration === scheduledStreamGeneration + } + ) + ) + .then(() => { + replayCompleted &&= !disposed && transport.getPtyId() === scheduledPtyId + }) + .finally(() => { + replayDrainQueued = false + if (pendingReplayData !== null) { + // Why: preserve the PTY identity captured when the callback fired; + // re-reading it here could retag stale bytes for a replacement PTY. + scheduleReplayDataDrain() + } + finishReattachLiveDataDeferral(replayCompleted, scheduledStreamGeneration) + }) } const replayDataCallback = ( data: string, - meta: { clearBeforeReplay?: boolean; pendingEscapeTailAnsi?: string } = {} + meta: { clearBeforeReplay?: boolean; pendingEscapeTailAnsi?: string } = {}, + streamGeneration = transportStreamGeneration ): void => { pendingReplayData = { data, clearBeforeReplay: meta.clearBeforeReplay !== false, + ptyId: transport.getPtyId(), + generation: (replayPayloadGeneration += 1), + streamGeneration, ...(meta.pendingEscapeTailAnsi ? { pendingEscapeTailAnsi: meta.pendingEscapeTailAnsi } : {}) } - if (replayDrainQueued) { - return - } - replayDrainQueued = true - replayWriteQueue = replayWriteQueue - .catch(() => undefined) - .then(drainReplayDataQueue) - .finally(() => { - replayDrainQueued = false - if (pendingReplayData !== null) { - replayDataCallback(pendingReplayData.data, { - clearBeforeReplay: pendingReplayData.clearBeforeReplay, - ...(pendingReplayData.pendingEscapeTailAnsi - ? { pendingEscapeTailAnsi: pendingReplayData.pendingEscapeTailAnsi } - : {}) - }) + scheduleReplayDataDrain() + } + + const captureTransportOutputCallbacks = (onError: (message: string) => void) => { + // Why: a new stream generation cannot inherit an old replay's pending + // destination-grid fit or keep its live-data waiter open. + pendingHiddenSnapshotFit?.cancel() + pendingHiddenSnapshotFit = null + pendingReattachFit?.cancel() + pendingReattachFit = null + const generation = (transportStreamGeneration += 1) + const isCurrent = (): boolean => !disposed && generation === transportStreamGeneration + return { + generation, + callbacks: { + onConnect: (): void => { + if (isCurrent()) { + reportRemoteRendererSerializerReady() + } + }, + onData: (data: string, meta?: PtyDataMeta): void => { + if (isCurrent()) { + dataCallback(data, meta, generation) + } + }, + onReplayData: ( + data: string, + meta?: { clearBeforeReplay?: boolean; pendingEscapeTailAnsi?: string } + ): void => { + if (isCurrent()) { + replayDataCallback(data, meta, generation) + } + }, + onError: (message: string): void => { + if (isCurrent()) { + onError(message) + } } - }) + } + } } type PendingHiddenOutputRestoreChunk = { @@ -4876,6 +5166,12 @@ export function connectPanePty( let hiddenOutputRestoreDeferredRetryTimer: ReturnType | null = null let hiddenOutputRestoreForegroundDeadlineTimer: ReturnType | null = null let hiddenOutputRestoreDeferredRetryAttempts = 0 + let hiddenOutputSnapshotScrollRestore: { + ptyId: string | null + generation: number + valid: boolean + started: boolean + } | null = null // Why: hidden recovery state belongs to one PTY stream. Reattach/restart // can reuse the pane object for a different session before visibility. let hiddenOutputRestorePtyId: string | null = null @@ -5982,6 +6278,14 @@ export function connectPanePty( : hiddenOutputRestorePendingChunks.slice() const hadPendingOverflow = hiddenOutputRestorePendingOverflow hiddenOutputRestoreGeneration += 1 + if ( + hiddenOutputSnapshotScrollRestore?.valid && + hiddenOutputSnapshotScrollRestore.ptyId === expectedPtyId + ) { + // Why: same-PTY flood abandonment stops recovery bookkeeping, but its + // already-queued replay must keep the rebuild bracket and final pin. + hiddenOutputSnapshotScrollRestore.generation = hiddenOutputRestoreGeneration + } hiddenOutputRestoreInFlight = null hiddenOutputRestoreNeeded = false hiddenOutputRestorePtyId = null @@ -6046,6 +6350,7 @@ export function connectPanePty( } function clearHiddenOutputRestoreState(): void { + cancelSnapshotScrollRestore() clearPendingLiveChunksDuringRestore() hiddenStartupRendererQueryPending = '' hiddenRendererStateDirty = false @@ -6055,6 +6360,23 @@ export function connectPanePty( hiddenOutputRestoreGeneration += 1 } + function cancelSnapshotScrollRestore(): void { + pendingHiddenSnapshotFit?.cancel() + pendingHiddenSnapshotFit = null + const scrollRestore = hiddenOutputSnapshotScrollRestore + if (!scrollRestore) { + return + } + scrollRestore.valid = false + hiddenOutputSnapshotScrollRestore = null + if (scrollRestore.started) { + cancelTerminalScrollIntentBufferRebuildCompletions(pane.terminal) + } + // Why: invalidation suppresses restoration, but queued bytes still own + // the bracket until their FIFO sentinels prove parsing has finished. + } + cancelHiddenOutputSnapshotScrollRestore = cancelSnapshotScrollRestore + function clearPaneMode2031State(): void { deps.paneMode2031Ref.current.delete(pane.id) deps.paneLastThemeModeRef.current.delete(pane.id) @@ -6121,7 +6443,7 @@ export function connectPanePty( }) } - function applyMainBufferSnapshot(snapshot: { + async function applyMainBufferSnapshot(snapshot: { data: string cols: number rows: number @@ -6129,8 +6451,19 @@ export function connectPanePty( alternateScreen?: boolean scrollbackAnsi?: string pendingEscapeTailAnsi?: string - }): void { - const scrollIntent = captureTerminalWriteScrollIntent(pane.terminal) + }): Promise { + const restorePtyId = transport.getPtyId() + const restoreGeneration = hiddenOutputRestoreGeneration + if (hiddenOutputSnapshotScrollRestore) { + cancelSnapshotScrollRestore() + } + const scrollRestore = { + ptyId: restorePtyId, + generation: restoreGeneration, + valid: true, + started: false + } + hiddenOutputSnapshotScrollRestore = scrollRestore const colsBeforeReplay = pane.terminal.cols const rowsBeforeReplay = pane.terminal.rows const hasSnapshotDimensions = @@ -6138,83 +6471,135 @@ export function connectPanePty( Number.isFinite(snapshot.rows) && snapshot.cols > 0 && snapshot.rows > 0 - discardTerminalOutput(pane.terminal) - if ( - hasSnapshotDimensions && - (pane.terminal.cols !== snapshot.cols || pane.terminal.rows !== snapshot.rows) - ) { - // Why: serialized terminal snapshots encode layout at their source - // dimensions. Replay at those dimensions first, then fit back below. - // This xterm-only resize must not SIGWINCH the live TUI. - suppressSnapshotReplayPtyResize = true - try { - pane.terminal.resize(snapshot.cols, snapshot.rows) - } finally { - suppressSnapshotReplayPtyResize = false - } - } - if (!snapshot.alternateScreen) { - // Why: this clear (incl. \x1b[3J) wipes xterm's scrollback. Alt-screen - // TUIs (Claude Code, vim) keep their scroll history in xterm, so - // clearing on restore loses scroll-up after a hidden->visible return. - // Mirrors the attach-time guard in pty-transport.ts. - writeReplayData('\x1b[2J\x1b[3J\x1b[H') - } else if (snapshot.scrollbackAnsi !== undefined) { - // Why: SerializeAddon captures normal and alternate buffers together. - // Rebuild normal while it is active, then return to a clean alt frame. - writeReplayData('\x1b[?1049l\x1b[2J\x1b[3J\x1b[H') - writeReplayData(snapshot.scrollbackAnsi) - writeReplayData('\x1b[0m\x1b[?1049h\x1b[2J\x1b[H') - } else { - // Why: the snapshot's own ?1049h is a no-op when the pane is already on - // the alternate screen, and the serialized frame skips blank cells — so - // without clearing the alt screen the pre-hide frame bleeds through - // every cell the final frame leaves blank. \x1b[2J on the alt buffer - // does not touch the normal buffer's scrollback the TUI returns to. - writeReplayData('\x1b[0m\x1b[?1049h\x1b[2J\x1b[H') - } - writeReplayData(snapshot.data) - // Why: status/title-corroborated live agents own ?25l/?1004h (a forced - // ?1004l here would silence focus events until the agent restarts, since - // agents only enable focus reporting at startup). - writeReplayData( - hasLiveAgentReattachStatusOrTitleSignal() - ? POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET - : POST_REPLAY_LIVE_SNAPSHOT_RESET - ) - if (snapshot.pendingEscapeTailAnsi) { - // Why last: the snapshot was taken with main's emulator mid-escape; - // re-arming the dangling sequence must be the FINAL replay write (any - // later ESC — including the reset above — aborts it) so the racing - // live tail's continuation completes it exactly as live, instead of - // rendering literally (Bug E fix / #7329). - writeReplayData(snapshot.pendingEscapeTailAnsi) - } - hiddenRendererStateDirty = false - recordRendererOrderedSeq(snapshot) - resetHiddenRendererRiskState() - recordTerminalOutput(pane.terminal) - const currentPtyId = transport.getPtyId() - if (currentPtyId && !getFitOverrideForPty(currentPtyId)) { - safeFit(pane) - const replayChangedDimensions = hasSnapshotDimensions - ? pane.terminal.cols !== snapshot.cols || pane.terminal.rows !== snapshot.rows - : pane.terminal.cols !== colsBeforeReplay || pane.terminal.rows !== rowsBeforeReplay - if (replayChangedDimensions && isRendererPtyResizeAuthoritative()) { - transport.resize(pane.terminal.cols, pane.terminal.rows) - if (!isRemoteRuntimePtyId(currentPtyId)) { - // Why: redundant SIGWINCH can make alternate-screen TUIs rebuild - // their internal scroll viewport to the top on tab return. - window.api.pty.signal(currentPtyId, 'SIGWINCH') + try { + await structuralReplayCoordinator.run( + async () => { + if ( + !scrollRestore.valid || + disposed || + transport.getPtyId() !== scrollRestore.ptyId || + hiddenOutputRestoreGeneration !== scrollRestore.generation + ) { + return + } + scrollRestore.started = true + discardTerminalOutput(pane.terminal) + if ( + hasSnapshotDimensions && + (pane.terminal.cols !== snapshot.cols || pane.terminal.rows !== snapshot.rows) + ) { + // Why: xterm parses writes later. Keep snapshot dimensions until + // the FIFO sentinel completes so serialized wraps stay exact. + suppressSnapshotReplayPtyResize = true + try { + pane.terminal.resize(snapshot.cols, snapshot.rows) + } finally { + suppressSnapshotReplayPtyResize = false + } + } + if (!snapshot.alternateScreen) { + // Why: this clear (incl. \x1b[3J) wipes xterm's scrollback. Alt-screen + // TUIs (Claude Code, vim) keep their scroll history in xterm, so + // clearing on restore loses scroll-up after a hidden->visible return. + // Mirrors the attach-time guard in pty-transport.ts. + writeReplayData('\x1b[2J\x1b[3J\x1b[H') + } else if (snapshot.scrollbackAnsi !== undefined) { + // Why: SerializeAddon captures normal and alternate buffers together. + // Rebuild normal while it is active, then return to a clean alt frame. + writeReplayData('\x1b[?1049l\x1b[2J\x1b[3J\x1b[H') + writeReplayData(snapshot.scrollbackAnsi) + writeReplayData('\x1b[0m\x1b[?1049h\x1b[2J\x1b[H') + } else { + // Why: the snapshot's own ?1049h is a no-op when the pane is already on + // the alternate screen, and the serialized frame skips blank cells — so + // without clearing the alt screen the pre-hide frame bleeds through + // every cell the final frame leaves blank. \x1b[2J on the alt buffer + // does not touch the normal buffer's scrollback the TUI returns to. + writeReplayData('\x1b[0m\x1b[?1049h\x1b[2J\x1b[H') + } + writeReplayData(snapshot.data) + // Why: status/title-corroborated live agents own ?25l/?1004h (a forced + // ?1004l here would silence focus events until the agent restarts, since + // agents only enable focus reporting at startup). + writeReplayData( + hasLiveAgentReattachStatusOrTitleSignal() + ? POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET + : POST_REPLAY_LIVE_SNAPSHOT_RESET + ) + if (snapshot.pendingEscapeTailAnsi) { + // Why last: the snapshot was taken with main's emulator mid-escape; + // re-arming the dangling sequence must be the FINAL replay write (any + // later ESC — including the reset above — aborts it) so the racing + // live tail's continuation completes it exactly as live, instead of + // rendering literally (Bug E fix / #7329). + writeReplayData(snapshot.pendingEscapeTailAnsi) + } + hiddenRendererStateDirty = false + recordRendererOrderedSeq(snapshot) + resetHiddenRendererRiskState() + recordTerminalOutput(pane.terminal) + await waitForTerminalReplayWritesParsed(pane.terminal) + }, + { + shouldRestore: () => + scrollRestore.valid && + !disposed && + transport.getPtyId() === scrollRestore.ptyId && + hiddenOutputRestoreGeneration === scrollRestore.generation, + afterRestore: async () => { + const isCurrentRestore = (): boolean => + scrollRestore.valid && + !disposed && + transport.getPtyId() === scrollRestore.ptyId && + hiddenOutputRestoreGeneration === scrollRestore.generation + if (!isCurrentRestore()) { + return + } + const currentPtyId = transport.getPtyId() + if (!currentPtyId || getFitOverrideForPty(currentPtyId)) { + return + } + const fit = safeFitAndThen( + pane, + 'hidden-snapshot-pty-resize', + () => { + if (!isCurrentRestore() || transport.getPtyId() !== currentPtyId) { + return + } + const replayChangedDimensions = hasSnapshotDimensions + ? pane.terminal.cols !== snapshot.cols || pane.terminal.rows !== snapshot.rows + : pane.terminal.cols !== colsBeforeReplay || + pane.terminal.rows !== rowsBeforeReplay + if (replayChangedDimensions && isRendererPtyResizeAuthoritative()) { + transport.resize(pane.terminal.cols, pane.terminal.rows) + if (!isRemoteRuntimePtyId(currentPtyId)) { + // Why: redundant SIGWINCH can make alternate-screen TUIs rebuild + // their internal scroll viewport to the top on tab return. + window.api.pty.signal(currentPtyId, 'SIGWINCH') + } + } + }, + { shouldContinue: isCurrentRestore } + ) + pendingHiddenSnapshotFit = fit + try { + await fit.completion + } finally { + if (pendingHiddenSnapshotFit === fit) { + pendingHiddenSnapshotFit = null + } + } + if (isCurrentRestore()) { + scheduleReattachIdleAgentCursorReset() + } + } } + ) + } finally { + if (hiddenOutputSnapshotScrollRestore === scrollRestore) { + hiddenOutputSnapshotScrollRestore = null } - scheduleReattachIdleAgentCursorReset() } - // Why: snapshot replay clears and rebuilds xterm state; re-apply the - // user's scroll intent once so hidden catch-up cannot repin the viewport. - // Restore by bottom offset — the rebuilt buffer renumbers every row, so - // the pre-replay absolute viewport line points at arbitrary content. - enforceTerminalWriteScrollIntent(pane.terminal, scrollIntent, { restoreBy: 'bottomOffset' }) } function requestHiddenOutputRestoreIfNeeded(opts?: { bypassScheduler?: boolean }): boolean { @@ -6351,7 +6736,15 @@ export function connectPanePty( } hiddenOutputRestoreDeferredRetryAttempts = 0 restoreIterations += 1 - applyMainBufferSnapshot(snapshot) + await applyMainBufferSnapshot(snapshot) + if ( + disposed || + hiddenOutputRestoreGeneration !== restoreGeneration || + hiddenOutputRestorePtyId !== currentPtyId || + transport.getPtyId() !== currentPtyId + ) { + return + } // Why: everything at or before snapshot.seq is now painted; chunks // still draining from main's ACK backlog below that point are // duplicates the dataCallback reconciliation must suppress. @@ -6456,14 +6849,41 @@ export function connectPanePty( } } - const dataCallback = (data: string, meta?: PtyDataMeta): void => { + const dataCallback = ( + data: string, + meta?: PtyDataMeta, + streamGeneration = transportStreamGeneration + ): void => { + if (streamGeneration !== transportStreamGeneration) { + return + } if (deferredReattachLiveData !== null) { - deferredReattachLiveData.push({ data, ...(meta ? { meta } : {}) }) - deferredReattachLiveDataChars += data.length - let dropped = false + // Why: a replacement stream must not inherit either bytes or a gap + // marker from the replay owner it superseded. + deferredReattachLiveData = deferredReattachLiveData.filter( + (chunk) => chunk.streamGeneration === streamGeneration + ) + deferredReattachLiveDataChars = deferredReattachLiveData.reduce( + (total, chunk) => total + chunk.data.length, + 0 + ) + const oversized = data.length > MAX_DEFERRED_REATTACH_LIVE_CHARS + const deferredData = oversized ? data.slice(-MAX_DEFERRED_REATTACH_LIVE_CHARS) : data + deferredReattachLiveData.push({ + data: deferredData, + ptyId: transport.getPtyId(), + streamGeneration, + ...(meta ? { meta } : {}) + }) + deferredReattachLiveDataChars += deferredData.length + // Why: retaining one arbitrarily large IPC frame would bypass this + // queue's memory bound. Mark it as a stream gap so snapshot recovery + // replaces it instead of feeding a partial ANSI frame to xterm. + let dropped = oversized while ( - deferredReattachLiveData.length > MAX_DEFERRED_REATTACH_LIVE_CHUNKS || - deferredReattachLiveDataChars > MAX_DEFERRED_REATTACH_LIVE_CHARS + deferredReattachLiveData.length > 1 && + (deferredReattachLiveData.length > MAX_DEFERRED_REATTACH_LIVE_CHUNKS || + deferredReattachLiveDataChars > MAX_DEFERRED_REATTACH_LIVE_CHARS) ) { const removed = deferredReattachLiveData.shift() deferredReattachLiveDataChars -= removed?.data.length ?? 0 @@ -6666,34 +7086,72 @@ export function connectPanePty( } }) - const beginReattachLiveDataDeferral = (): void => { - deferredReattachLiveData = [] - deferredReattachLiveDataChars = 0 + const beginReattachLiveDataDeferral = (ownerGeneration = transportStreamGeneration): void => { + reattachLiveDataDeferralDepth += 1 + if (reattachLiveDataDeferralDepth === 1) { + deferredReattachLiveData = [] + deferredReattachLiveDataChars = 0 + deferredReattachLiveDataOwners = new Map() + } + if (!deferredReattachLiveDataOwners.has(ownerGeneration)) { + deferredReattachLiveDataOwners.set(ownerGeneration, { failed: false }) + } } - const finishReattachLiveDataDeferral = (deliver: boolean): void => { + const finishReattachLiveDataDeferral = ( + deliver: boolean, + acceptedGeneration = transportStreamGeneration + ): void => { + if (reattachLiveDataDeferralDepth <= 0) { + return + } + if (!deliver) { + const owner = deferredReattachLiveDataOwners.get(acceptedGeneration) + if (owner) { + owner.failed = true + } + } + reattachLiveDataDeferralDepth -= 1 + if (reattachLiveDataDeferralDepth > 0) { + return + } const chunks = deferredReattachLiveData deferredReattachLiveData = null deferredReattachLiveDataChars = 0 - if (!deliver || disposed || !chunks) { + const currentPtyId = transport.getPtyId() + const currentGeneration = transportStreamGeneration + const currentOwner = deferredReattachLiveDataOwners.get(currentGeneration) + deferredReattachLiveDataOwners = new Map() + if (disposed || !chunks) { return } // Why: createOrAttach snapshots precede bytes emitted before its IPC // reply. Paint the authoritative replay first, then admit those live // chunks so the replay clear cannot erase newer output. for (const chunk of chunks) { - dataCallback(chunk.data, chunk.meta) + if ( + chunk.ptyId !== currentPtyId || + chunk.streamGeneration !== currentGeneration || + currentOwner?.failed === true + ) { + continue + } + dataCallback(chunk.data, chunk.meta, chunk.streamGeneration) } } - const handleReattachResult = ( + const handleReattachResult = async ( result: PtyConnectResult | string | void, staleSessionId?: string | null, - coldRestoreStartup?: ColdRestoreAgentResumeStartup | null - ): boolean => { + coldRestoreStartup?: ColdRestoreAgentResumeStartup | null, + attemptGeneration = transportStreamGeneration + ): Promise => { if (disposed) { return false } + if (attemptGeneration !== transportStreamGeneration) { + return false + } const connectResult = result && typeof result === 'object' && 'id' in result ? (result as PtyConnectResult) : null @@ -6754,6 +7212,15 @@ export function connectPanePty( }) return false } + const isCurrentReattachPayload = (): boolean => { + const currentPtyId = transport.getPtyId() + return ( + !disposed && attemptGeneration === transportStreamGeneration && currentPtyId === ptyId + ) + } + if (!isCurrentReattachPayload()) { + return false + } setPanePtyFitBinding(ptyId) reportPanePtyVisibility(ptyId, deps.isVisibleRef.current) registerSideEffectFactConsumerForPty(ptyId) @@ -6779,125 +7246,179 @@ export function connectPanePty( // newer than disk-recorded scrollback. If we ever return all three, // the daemon and relay are by definition tracking the same session // and only the freshest source belongs on screen. - if (connectResult?.snapshot) { - rememberReattachPayloadAgentSignal(connectResult.snapshot, { fullScreenReplay: true }) - // Why: the daemon serializes its grid with soft-wrapped lines as - // continuous text. Replaying that at a different column count rewraps - // rows one cell early/late (bug #7279). Replay at the snapshot's own - // dimensions first; safeFit below fits the pane back and resizes the - // remote PTY. Suppress the xterm->PTY forward so this layout-only - // resize does not SIGWINCH the live remote TUI. Mirrors - // applyMainBufferSnapshot. - const snapshotCols = connectResult.snapshotCols - const snapshotRows = connectResult.snapshotRows - const hasSnapshotDimensions = - typeof snapshotCols === 'number' && - typeof snapshotRows === 'number' && - Number.isFinite(snapshotCols) && - Number.isFinite(snapshotRows) && - snapshotCols > 0 && - snapshotRows > 0 - if ( - hasSnapshotDimensions && - (pane.terminal.cols !== snapshotCols || pane.terminal.rows !== snapshotRows) - ) { - suppressSnapshotReplayPtyResize = true + const hasStructuralReplay = Boolean( + connectResult?.snapshot || connectResult?.replay || connectResult?.coldRestore + ) + let reattachPayloadApplied = !hasStructuralReplay + const applyReattachPayload = async (): Promise => { + if (!isCurrentReattachPayload()) { + return + } + if (connectResult?.snapshot) { + rememberReattachPayloadAgentSignal(connectResult.snapshot, { fullScreenReplay: true }) + // Why: the daemon serializes its grid with soft-wrapped lines as + // continuous text. Replaying that at a different column count rewraps + // rows one cell early/late (bug #7279). Replay at the snapshot's own + // dimensions first; safeFit below fits the pane back and resizes the + // remote PTY. Suppress the xterm->PTY forward so this layout-only + // resize does not SIGWINCH the live remote TUI. Mirrors + // applyMainBufferSnapshot. + const snapshotCols = connectResult.snapshotCols + const snapshotRows = connectResult.snapshotRows + const hasSnapshotDimensions = + typeof snapshotCols === 'number' && + typeof snapshotRows === 'number' && + Number.isFinite(snapshotCols) && + Number.isFinite(snapshotRows) && + snapshotCols > 0 && + snapshotRows > 0 + if ( + hasSnapshotDimensions && + (pane.terminal.cols !== snapshotCols || pane.terminal.rows !== snapshotRows) + ) { + suppressSnapshotReplayPtyResize = true + try { + pane.terminal.resize(snapshotCols, snapshotRows) + } finally { + suppressSnapshotReplayPtyResize = false + } + } + writeReplayData('\x1b[2J\x1b[3J\x1b[H') + // Why: the daemon snapshot's rehydrate preamble carries the live + // session's kitty keyboard flags; re-arm the mirror from it so Option + // chords keep their kitty encoding after a window reload. + kittyKeyboardModes.scanReplay(connectResult.snapshot) + writeReplayData(connectResult.snapshot) + // Snapshot reattach keeps a live session, so avoid the broader mode + // reset. We only drop renderer-owned state that should not leak from + // replay bytes into the restored renderer terminal. + writeReplayData(reattachReplayResetSequence(connectResult.snapshot)) + if (connectResult.pendingEscapeTailAnsi) { + // Why last: re-arm the daemon's dangling mid-escape sequence AFTER the + // reset (whose ESC would abort it) so the racing live continuation + // completes it instead of rendering literally (#7329). + writeReplayData(connectResult.pendingEscapeTailAnsi) + } + sendFocusedReattachFocusInAfterReplay(ptyId, attemptGeneration) + if (connectResult.coldRestore) { + // Snapshot superseded the cold-restore payload — ack it so the + // daemon does not redeliver it on the next reattach. + if (!isRemoteRuntimePtyId(ptyId)) { + window.api.pty.ackColdRestore(ptyId) + } + } + } else if (connectResult?.replay) { + rememberReattachPayloadAgentSignal(connectResult.replay, { fullScreenReplay: true }) + // Relay replay holds the last 100 KB of raw output. The xterm may + // already hold pre-disconnect content; clear first to avoid + // duplication. The reattach reset clears renderer-owned state without + // tearing down the still-running TUI's live modes. + writeReplayData('\x1b[2J\x1b[3J\x1b[H') + // Why: raw relay replay contains the application's own kitty pushes + // when they fall inside the retained window; re-arm the mirror with + // replay (set) semantics so redelivery cannot grow the stack. + kittyKeyboardModes.scanReplay(connectResult.replay) + writeReplayData(connectResult.replay) + writeReplayData(reattachReplayResetSequence(connectResult.replay)) + sendFocusedReattachFocusInAfterReplay(ptyId, attemptGeneration) + if (connectResult.coldRestore) { + if (!isRemoteRuntimePtyId(ptyId)) { + window.api.pty.ackColdRestore(ptyId) + } + } + } else if (connectResult?.coldRestore) { + // replayIntoTerminal: the recorded scrollback is raw PTY output that + // may contain query sequences the previous agent CLI emitted; + // writing them through xterm.write would trigger auto-replies that + // land in the new shell's stdin. See replay-guard.ts. + writeReplayData(connectResult.coldRestore.scrollback) + const preparedStartup = coldRestoreStartup ?? buildColdRestoreAgentResumeStartup() + const didPrepareResume = applyColdRestoreAgentResumeStartup(preparedStartup) + if (didPrepareResume) { + if (preparedStartup?.hasSleepingRecord) { + showSessionRestoredBanner() + } + clearSleepingRecordAfterColdRestoreSpawn(preparedStartup) + } + // Cold-restore means the daemon lost the session and spawned a + // fresh shell — no TUI is consuming the mode-setting bytes that a + // crashed TUI (e.g. Claude's \e[?1004h) left in the scrollback, so + // reset them to match the fresh shell's expectations. + writeReplayData(POST_REPLAY_MODE_RESET) + // Why: the dead run's scrollback was never scanned, and any kitty + // flags it pushed died with it — the fresh shell starts at zero. + kittyKeyboardModes.reset() + consumeRestoredViewportBlankingMarker() + writeFreshShellViewportBlanking() + if (!isRemoteRuntimePtyId(ptyId)) { + window.api.pty.ackColdRestore(ptyId) + } + if (didPrepareResume && !coldRestoreStartup) { + schedulePendingStartupCommandDelivery() + } + } + if (hasStructuralReplay) { + await waitForTerminalReplayWritesParsed(pane.terminal) + if (!isCurrentReattachPayload()) { + return + } + reattachPayloadApplied = true + } + } + + const fitAfterReattachRestore = async (): Promise => { + if (!isCurrentReattachPayload()) { + return + } + const reattachPtyId = transport.getPtyId() + if (!reattachPtyId) { + return + } + if (!getFitOverrideForPty(reattachPtyId)) { + const fit = safeFitAndThen( + pane, + 'reattach-pty-resize', + () => { + if (!isCurrentReattachPayload() || transport.getPtyId() !== reattachPtyId) { + return + } + const reattachCols = pane.terminal.cols + const reattachRows = pane.terminal.rows + if (reattachCols > 0 && reattachRows > 0) { + transport.resize(reattachCols, reattachRows) + } + // Why: POSIX only delivers SIGWINCH when terminal dimensions actually + // change. Sending it explicitly guarantees restored TUIs repaint at + // the correct cursor position after snapshot replay. + if (!isRemoteRuntimePtyId(reattachPtyId)) { + window.api.pty.signal(reattachPtyId, 'SIGWINCH') + } + }, + { shouldContinue: isCurrentReattachPayload } + ) + pendingReattachFit = fit try { - pane.terminal.resize(snapshotCols, snapshotRows) + await fit.completion } finally { - suppressSnapshotReplayPtyResize = false + if (pendingReattachFit === fit) { + pendingReattachFit = null + } } - } - writeReplayData('\x1b[2J\x1b[3J\x1b[H') - // Why: the daemon snapshot's rehydrate preamble carries the live - // session's kitty keyboard flags; re-arm the mirror from it so Option - // chords keep their kitty encoding after a window reload. - kittyKeyboardModes.scanReplay(connectResult.snapshot) - writeReplayData(connectResult.snapshot) - // Snapshot reattach keeps a live session, so avoid the broader mode - // reset. We only drop renderer-owned state that should not leak from - // replay bytes into the restored renderer terminal. - writeReplayData(reattachReplayResetSequence(connectResult.snapshot)) - if (connectResult.pendingEscapeTailAnsi) { - // Why last: re-arm the daemon's dangling mid-escape sequence AFTER the - // reset (whose ESC would abort it) so the racing live continuation - // completes it instead of rendering literally (#7329). - writeReplayData(connectResult.pendingEscapeTailAnsi) - } - sendFocusedReattachFocusInAfterReplay() - if (connectResult.coldRestore) { - // Snapshot superseded the cold-restore payload — ack it so the - // daemon does not redeliver it on the next reattach. - if (!isRemoteRuntimePtyId(ptyId)) { - window.api.pty.ackColdRestore(ptyId) - } - } - } else if (connectResult?.replay) { - rememberReattachPayloadAgentSignal(connectResult.replay, { fullScreenReplay: true }) - // Relay replay holds the last 100 KB of raw output. The xterm may - // already hold pre-disconnect content; clear first to avoid - // duplication. The reattach reset clears renderer-owned state without - // tearing down the still-running TUI's live modes. - writeReplayData('\x1b[2J\x1b[3J\x1b[H') - // Why: raw relay replay contains the application's own kitty pushes - // when they fall inside the retained window; re-arm the mirror with - // replay (set) semantics so redelivery cannot grow the stack. - kittyKeyboardModes.scanReplay(connectResult.replay) - writeReplayData(connectResult.replay) - writeReplayData(reattachReplayResetSequence(connectResult.replay)) - sendFocusedReattachFocusInAfterReplay() - if (connectResult.coldRestore) { - if (!isRemoteRuntimePtyId(ptyId)) { - window.api.pty.ackColdRestore(ptyId) - } - } - } else if (connectResult?.coldRestore) { - // replayIntoTerminal: the recorded scrollback is raw PTY output that - // may contain query sequences the previous agent CLI emitted; - // writing them through xterm.write would trigger auto-replies that - // land in the new shell's stdin. See replay-guard.ts. - writeReplayData(connectResult.coldRestore.scrollback) - const preparedStartup = coldRestoreStartup ?? buildColdRestoreAgentResumeStartup() - const didPrepareResume = applyColdRestoreAgentResumeStartup(preparedStartup) - if (didPrepareResume) { - if (preparedStartup?.hasSleepingRecord) { - showSessionRestoredBanner() - } - clearSleepingRecordAfterColdRestoreSpawn(preparedStartup) - } - // Cold-restore means the daemon lost the session and spawned a - // fresh shell — no TUI is consuming the mode-setting bytes that a - // crashed TUI (e.g. Claude's \e[?1004h) left in the scrollback, so - // reset them to match the fresh shell's expectations. - writeReplayData(POST_REPLAY_MODE_RESET) - // Why: the dead run's scrollback was never scanned, and any kitty - // flags it pushed died with it — the fresh shell starts at zero. - kittyKeyboardModes.reset() - consumeRestoredViewportBlankingMarker() - writeFreshShellViewportBlanking() - if (!isRemoteRuntimePtyId(ptyId)) { - window.api.pty.ackColdRestore(ptyId) - } - if (didPrepareResume && !coldRestoreStartup) { - schedulePendingStartupCommandDelivery() + } else if (isCurrentReattachPayload() && !isRemoteRuntimePtyId(reattachPtyId)) { + window.api.pty.signal(reattachPtyId, 'SIGWINCH') } } - // Why: when a mobile-fit override is active, skip sending desktop dims - // to the PTY — the PTY is already at phone dimensions and must stay there. - const reattachPtyId = transport.getPtyId() - if (!reattachPtyId || !getFitOverrideForPty(reattachPtyId)) { - safeFit(pane) - const reattachCols = pane.terminal.cols - const reattachRows = pane.terminal.rows - if (reattachCols > 0 && reattachRows > 0) { - transport.resize(reattachCols, reattachRows) - } + if (hasStructuralReplay) { + await structuralReplayCoordinator.run(applyReattachPayload, { + shouldRestore: isCurrentReattachPayload, + afterRestore: fitAfterReattachRestore + }) + } else { + await applyReattachPayload() + await fitAfterReattachRestore() } - // Why: POSIX only delivers SIGWINCH when terminal dimensions actually - // change. Sending it explicitly guarantees restored TUIs repaint at - // the correct cursor position after snapshot replay. - if (!isRemoteRuntimePtyId(ptyId)) { - window.api.pty.signal(ptyId, 'SIGWINCH') + if (!isCurrentReattachPayload() || !reattachPayloadApplied) { + return false } scheduleReattachIdleAgentCursorReset() @@ -7097,7 +7618,14 @@ export function connectPanePty( const coldRestoreStartup = buildColdRestoreAgentResumeStartup() clearPaneMode2031State() clearHiddenOutputRestoreState() - beginReattachLiveDataDeferral() + const outputCallbacks = captureTransportOutputCallbacks((message) => { + if (isSshSessionExpiredError(message)) { + expiredReattachError = true + return + } + reportError(message) + }) + beginReattachLiveDataDeferral(outputCallbacks.generation) transportConnectInFlightSince = Date.now() const reattachPromise = transport.connect({ url: '', @@ -7116,17 +7644,7 @@ export function connectPanePty( : {}), ...(coldRestoreStartup?.agent ? { launchAgent: coldRestoreStartup.agent } : {}), ...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}), - callbacks: { - onData: dataCallback, - onReplayData: replayDataCallback, - onError: (message) => { - if (isSshSessionExpiredError(message)) { - expiredReattachError = true - return - } - reportError(message) - } - } + callbacks: outputCallbacks.callbacks }) void Promise.resolve(reattachPromise) .catch(() => null) @@ -7135,6 +7653,14 @@ export function connectPanePty( }) void Promise.resolve(reattachPromise) .then(async (result) => { + if (outputCallbacks.generation !== transportStreamGeneration) { + finishReattachLiveDataDeferral(false, outputCallbacks.generation) + const gen = await preSignalPromise + if (typeof gen === 'number') { + void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) + } + return + } console.warn( `[pty-connection] Reattach result for tab=${deps.tabId}:`, result @@ -7145,7 +7671,7 @@ export function connectPanePty( : 'undefined' ) if (!result && expiredReattachError) { - finishReattachLiveDataDeferral(false) + finishReattachLiveDataDeferral(false, outputCallbacks.generation) const gen = await preSignalPromise if (typeof gen === 'number') { void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) @@ -7160,23 +7686,38 @@ export function connectPanePty( }) return } - const accepted = handleReattachResult(result, pendingSessionId, coldRestoreStartup) - finishReattachLiveDataDeferral(accepted) + const accepted = await handleReattachResult( + result, + pendingSessionId, + coldRestoreStartup, + outputCallbacks.generation + ) + finishReattachLiveDataDeferral(accepted, outputCallbacks.generation) const gen = await preSignalPromise if (typeof gen === 'number') { if (!isRemoteRuntimePtyId(pendingSessionId)) { - void window.api.pty.settlePaneSerializer(cacheKey, gen).catch(() => {}) + const settledPtyId = + result && typeof result === 'object' && 'id' in result + ? result.id + : (transport.getPtyId() ?? pendingSessionId) + const hasRestorePayload = + result && + typeof result === 'object' && + ('snapshot' in result || 'replay' in result || 'coldRestore' in result) + await (hasRestorePayload + ? settlePaneSerializerAfterReplay(settledPtyId, gen) + : window.api.pty.settlePaneSerializer(cacheKey, gen)) } } }) .catch(async (err) => { - finishReattachLiveDataDeferral(false) + finishReattachLiveDataDeferral(false, outputCallbacks.generation) const gen = await preSignalPromise if (typeof gen === 'number') { void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) } console.warn(`[pty-connection] Reattach FAILED for tab=${deps.tabId}:`, err) - if (disposed) { + if (disposed || outputCallbacks.generation !== transportStreamGeneration) { return } if (isSshSessionExpiredError(err)) { @@ -7293,7 +7834,14 @@ export function connectPanePty( let expiredReattachError = false const coldRestoreStartup = buildColdRestoreAgentResumeStartup() - beginReattachLiveDataDeferral() + const outputCallbacks = captureTransportOutputCallbacks((message) => { + if (isSshSessionExpiredError(message)) { + expiredReattachError = true + return + } + reportError(message) + }) + beginReattachLiveDataDeferral(outputCallbacks.generation) transportConnectInFlightSince = Date.now() const reattachPromise = transport.connect({ url: '', @@ -7310,17 +7858,7 @@ export function connectPanePty( ...(coldRestoreStartup?.launchToken ? { launchToken: coldRestoreStartup.launchToken } : {}), ...(coldRestoreStartup?.agent ? { launchAgent: coldRestoreStartup.agent } : {}), ...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}), - callbacks: { - onData: dataCallback, - onReplayData: replayDataCallback, - onError: (message) => { - if (isSshSessionExpiredError(message)) { - expiredReattachError = true - return - } - reportError(message) - } - } + callbacks: outputCallbacks.callbacks }) void Promise.resolve(reattachPromise) @@ -7330,8 +7868,16 @@ export function connectPanePty( }) void Promise.resolve(reattachPromise) .then(async (result) => { + if (outputCallbacks.generation !== transportStreamGeneration) { + finishReattachLiveDataDeferral(false, outputCallbacks.generation) + const gen = await preSignalPromise + if (typeof gen === 'number') { + void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) + } + return + } if (!result && expiredReattachError) { - finishReattachLiveDataDeferral(false) + finishReattachLiveDataDeferral(false, outputCallbacks.generation) const gen = await preSignalPromise if (typeof gen === 'number') { void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) @@ -7346,26 +7892,40 @@ export function connectPanePty( }) return } - const accepted = handleReattachResult( + const accepted = await handleReattachResult( result, deferredReattachSessionId, - coldRestoreStartup + coldRestoreStartup, + outputCallbacks.generation ) - finishReattachLiveDataDeferral(accepted) + finishReattachLiveDataDeferral(accepted, outputCallbacks.generation) const gen = await preSignalPromise if (typeof gen === 'number') { if (!isRemoteRuntimePtyId(deferredReattachSessionId)) { - void window.api.pty.settlePaneSerializer(cacheKey, gen).catch(() => {}) + const settledPtyId = + result && typeof result === 'object' && 'id' in result + ? result.id + : (transport.getPtyId() ?? deferredReattachSessionId) + const hasRestorePayload = + result && + typeof result === 'object' && + ('snapshot' in result || 'replay' in result || 'coldRestore' in result) + await (hasRestorePayload + ? settlePaneSerializerAfterReplay(settledPtyId, gen) + : window.api.pty.settlePaneSerializer(cacheKey, gen)) } } }) .catch(async (err) => { - finishReattachLiveDataDeferral(false) + finishReattachLiveDataDeferral(false, outputCallbacks.generation) const gen = await preSignalPromise if (typeof gen === 'number') { void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) } const message = err instanceof Error ? err.message : String(err) + if (outputCallbacks.generation !== transportStreamGeneration) { + return + } warnTerminalLifecycleAnomaly('restored PTY reattach threw', { tabId: deps.tabId, worktreeId: deps.worktreeId, @@ -7409,22 +7969,19 @@ export function connectPanePty( try { clearPaneMode2031State() clearHiddenOutputRestoreState() + const outputCallbacks = captureTransportOutputCallbacks(reportError) transport.attach({ existingPtyId: attachPtyId, cols, rows, - callbacks: { - onData: dataCallback, - onReplayData: replayDataCallback, - onError: reportError - } + callbacks: outputCallbacks.callbacks }) const attachedPtyId = transport.getPtyId() ?? attachPtyId bindActivePanePty(attachedPtyId, { updateTabPtyId: 'if-missing', sampleVisibleForegroundAgent: true }) - if (attachPtyId === eagerLivePtyId) { + if (attachPtyId === eagerLivePtyId || isRemoteRuntimePtyId(attachedPtyId)) { registerPaneSerializerFor(attachedPtyId) } } catch (err) { @@ -7465,15 +8022,12 @@ export function connectPanePty( } clearPaneMode2031State() clearHiddenOutputRestoreState() + const outputCallbacks = captureTransportOutputCallbacks(reportError) transport.attach({ existingPtyId: spawnedPtyId, cols, rows, - callbacks: { - onData: dataCallback, - onReplayData: replayDataCallback, - onError: reportError - } + callbacks: outputCallbacks.callbacks }) const attachedPtyId = transport.getPtyId() ?? spawnedPtyId // Why: this path reuses a PTY spawned by an earlier mount, so no @@ -7661,10 +8215,16 @@ export function connectPanePty( reconcileIfSessionMissing, dispose() { disposed = true + cancelPendingSafeFitContinuations(pane) + pendingHiddenSnapshotFit = null + pendingReattachFit = null // A normal park/reconnect/remount does not advance the recovery epoch; // invalidate this concrete xterm so its delayed retry cannot hit the next. terminalRecoveryInstance.unregister() unregisterUndeliverableWriteHandler() + cancelHiddenOutputSnapshotScrollRestore() + structuralReplayCoordinator.dispose() + cancelFreshSpawnFollowReset() // Why: the post-spawn reconcile polls across frames; cancel its pending // rAF so a torn-down pane cannot keep fitting/resizing after disposal. ptySizeReconcileHandle?.cancel() diff --git a/src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts b/src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts index 3b22b2d9acf..59286263364 100644 --- a/src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts @@ -15,7 +15,7 @@ describe('createPtySizeReassertion', () => { getPtyId: () => 'pty-1', isRemotePtyId: () => false, shouldSuppressDesktopResize: () => false, - fit: vi.fn(), + fitAndRun: (continuation) => continuation(), getTerminalDimensions: () => ({ cols: 82, rows: 30 }), getAppliedSize: vi.fn(async () => ({ cols: 120, rows: 30 })), forwardResize @@ -34,7 +34,7 @@ describe('createPtySizeReassertion', () => { getPtyId: () => 'pty-1', isRemotePtyId: () => false, shouldSuppressDesktopResize: () => false, - fit: vi.fn(), + fitAndRun: (continuation) => continuation(), getTerminalDimensions: () => ({ cols: 82, rows: 30 }), getAppliedSize: vi.fn(async () => ({ cols: 82, rows: 30 })), forwardResize @@ -53,8 +53,9 @@ describe('createPtySizeReassertion', () => { getPtyId: () => 'pty-1', isRemotePtyId: () => false, shouldSuppressDesktopResize: () => false, - fit: vi.fn(() => { + fitAndRun: vi.fn((continuation) => { calls.push('fit') + continuation() }), getTerminalDimensions: vi.fn(() => { calls.push('measure') @@ -81,8 +82,9 @@ describe('createPtySizeReassertion', () => { getPtyId: () => 'pty-1', isRemotePtyId: () => false, shouldSuppressDesktopResize: () => false, - fit: vi.fn(() => { + fitAndRun: vi.fn((continuation) => { forwardResize(82, 30) + continuation() }), getTerminalDimensions: () => ({ cols: 82, rows: 30 }), getAppliedSize: vi.fn(async () => ({ cols: 82, rows: 30 })), @@ -97,14 +99,14 @@ describe('createPtySizeReassertion', () => { }) it('can verify current dimensions without fitting again', async () => { - const fit = vi.fn() + const fitAndRun = vi.fn((continuation: () => void) => continuation()) const forwardResize = vi.fn() const reassertion = createPtySizeReassertion({ isDisposed: () => false, getPtyId: () => 'pty-1', isRemotePtyId: () => false, shouldSuppressDesktopResize: () => false, - fit, + fitAndRun, getTerminalDimensions: () => ({ cols: 82, rows: 30 }), getAppliedSize: vi.fn(async () => ({ cols: 120, rows: 30 })), forwardResize @@ -113,7 +115,7 @@ describe('createPtySizeReassertion', () => { reassertion.request({ fit: false }) await flushAsyncTicks() - expect(fit).not.toHaveBeenCalled() + expect(fitAndRun).not.toHaveBeenCalled() expect(forwardResize).toHaveBeenCalledWith(82, 30) }) @@ -124,7 +126,7 @@ describe('createPtySizeReassertion', () => { getPtyId: () => 'remote:terminal-1', isRemotePtyId: () => true, shouldSuppressDesktopResize: () => false, - fit: vi.fn(), + fitAndRun: (continuation) => continuation(), getTerminalDimensions: () => ({ cols: 82, rows: 30 }), getAppliedSize, forwardResize: vi.fn() @@ -134,7 +136,7 @@ describe('createPtySizeReassertion', () => { getPtyId: () => 'pty-1', isRemotePtyId: () => false, shouldSuppressDesktopResize: () => true, - fit: vi.fn(), + fitAndRun: (continuation) => continuation(), getTerminalDimensions: () => ({ cols: 82, rows: 30 }), getAppliedSize, forwardResize: vi.fn() @@ -164,7 +166,7 @@ describe('createPtySizeReassertion', () => { getPtyId: () => 'pty-1', isRemotePtyId: () => false, shouldSuppressDesktopResize: () => false, - fit: vi.fn(), + fitAndRun: (continuation) => continuation(), getTerminalDimensions: () => ({ cols: 82, rows: 30 }), getAppliedSize, forwardResize @@ -201,7 +203,7 @@ describe('createPtySizeReassertion', () => { getPtyId: () => 'pty-1', isRemotePtyId: () => false, shouldSuppressDesktopResize: () => false, - fit: vi.fn(), + fitAndRun: (continuation) => continuation(), getTerminalDimensions: () => ({ cols: targetCols, rows: 40 }), getAppliedSize, forwardResize @@ -240,7 +242,7 @@ describe('createPtySizeReassertion', () => { getPtyId: () => 'pty-1', isRemotePtyId: () => false, shouldSuppressDesktopResize: () => false, - fit: vi.fn(), + fitAndRun: (continuation) => continuation(), getTerminalDimensions: () => dims, getAppliedSize, forwardResize @@ -273,7 +275,7 @@ describe('createPtySizeReassertion', () => { getPtyId: () => 'pty-1', isRemotePtyId: () => false, shouldSuppressDesktopResize: () => false, - fit: vi.fn(), + fitAndRun: (continuation) => continuation(), getTerminalDimensions: () => dims, getAppliedSize, forwardResize @@ -313,7 +315,7 @@ describe('createPtySizeReassertion', () => { getPtyId: () => 'pty-1', isRemotePtyId: () => false, shouldSuppressDesktopResize: () => false, - fit: vi.fn(), + fitAndRun: (continuation) => continuation(), getTerminalDimensions: () => dims, getAppliedSize, forwardResize @@ -346,7 +348,7 @@ describe('createPtySizeReassertion', () => { getPtyId: () => 'pty-1', isRemotePtyId: () => false, shouldSuppressDesktopResize: () => false, - fit: vi.fn(), + fitAndRun: (continuation) => continuation(), getTerminalDimensions: () => dims, getAppliedSize, forwardResize @@ -369,7 +371,7 @@ describe('createPtySizeReassertion', () => { getPtyId: () => 'pty-1', isRemotePtyId: () => false, shouldSuppressDesktopResize: () => false, - fit: vi.fn(), + fitAndRun: (continuation) => continuation(), getTerminalDimensions: () => ({ cols: 82, rows: 30 }), getAppliedSize: vi.fn(async () => { throw new Error('unavailable') diff --git a/src/renderer/src/components/terminal-pane/pty-size-reassertion.ts b/src/renderer/src/components/terminal-pane/pty-size-reassertion.ts index 7f127b67083..6c5db87d04c 100644 --- a/src/renderer/src/components/terminal-pane/pty-size-reassertion.ts +++ b/src/renderer/src/components/terminal-pane/pty-size-reassertion.ts @@ -5,7 +5,7 @@ export type PtySizeReassertionOptions = { getPtyId: () => string | null isRemotePtyId: (ptyId: string) => boolean shouldSuppressDesktopResize: () => boolean - fit: () => void + fitAndRun: (continuation: () => void) => void getTerminalDimensions: () => PtySizeReassertionDimensions getAppliedSize: (ptyId: string) => Promise forwardResize: (cols: number, rows: number) => void @@ -46,7 +46,8 @@ export function createPtySizeReassertion(options: PtySizeReassertionOptions): Pt return } if (shouldFit) { - options.fit() + options.fitAndRun(() => run(false)) + return } const target = options.getTerminalDimensions() if (!dimensionsAreUsable(target)) { diff --git a/src/renderer/src/components/terminal-pane/replay-guard.test.ts b/src/renderer/src/components/terminal-pane/replay-guard.test.ts index 8f5d99ecbcb..beddf35e1a1 100644 --- a/src/renderer/src/components/terminal-pane/replay-guard.test.ts +++ b/src/renderer/src/components/terminal-pane/replay-guard.test.ts @@ -4,6 +4,7 @@ import { isPaneReplaying, replayIntoTerminal, replayIntoTerminalAsync, + waitForTerminalReplayWritesParsed, type ReplayingPanesRef } from './replay-guard' import { configureLazyArabicShapingJoiner } from '@/lib/pane-manager/terminal-arabic-shaping-joiner' @@ -418,6 +419,27 @@ describe('replay-guard', () => { }) describe('replay-guard stall handling (probe-certified release)', () => { + it('waits for the FIFO replay sentinel without releasing on elapsed time', async () => { + vi.useFakeTimers() + const { terminal } = makeFakePane(1) + let resolved = false + + void waitForTerminalReplayWritesParsed(terminal, { stallCheckMs: 1_000 }).then(() => { + resolved = true + }) + expect(terminal.lastData).toEqual(['']) + + vi.advanceTimersByTime(1_000) + expect(terminal.lastData).toEqual(['', '']) + expect(resolved).toBe(false) + vi.advanceTimersByTime(60_000) + expect(resolved).toBe(false) + + terminal.flush() + await Promise.resolve() + expect(resolved).toBe(true) + }) + it('HOLDS the guard while a slow replay is still parsing — a probe is queued, never a blind release', () => { // Why this is the load-bearing safety test: a time-based release here // would leak xterm auto-replies into the shell (and a leaked ESC into an diff --git a/src/renderer/src/components/terminal-pane/replay-guard.ts b/src/renderer/src/components/terminal-pane/replay-guard.ts index 1e1042de088..6711390d851 100644 --- a/src/renderer/src/components/terminal-pane/replay-guard.ts +++ b/src/renderer/src/components/terminal-pane/replay-guard.ts @@ -247,3 +247,49 @@ export function replayIntoTerminalAsync( }) }) } + +/** Resolves after every replay write already queued on this terminal has + * parsed. A delayed FIFO probe covers a lost sentinel callback without ever + * treating elapsed time alone as proof that parsing finished. */ +export function waitForTerminalReplayWritesParsed( + terminal: ReplayGuardWriteTarget, + options: Pick = {} +): Promise { + return new Promise((resolve) => { + let finished = false + let stallTimer: ReturnType | null = null + const finish = (): void => { + if (finished) { + return + } + finished = true + if (stallTimer !== null) { + clearTimeout(stallTimer) + stallTimer = null + } + resolve() + } + const queueProbe = (): void => { + if (finished) { + return + } + try { + // Why: an empty write is FIFO with earlier replay bytes. Its callback + // can recover a lost sentinel callback without changing parser state. + terminal.write('', finish) + } catch { + // A disposed terminal cannot parse any remaining replay bytes. + finish() + } + } + stallTimer = setTimeout(queueProbe, options.stallCheckMs ?? REPLAY_GUARD_STALL_CHECK_MS) + try { + // Why empty: pendingEscapeTailAnsi must remain the final replay bytes; + // xterm still orders this completion after every earlier write. + terminal.write('', finish) + } catch { + // A disposed terminal cannot parse any remaining replay bytes. + finish() + } + }) +} diff --git a/src/renderer/src/components/terminal-pane/terminal-appearance.ts b/src/renderer/src/components/terminal-pane/terminal-appearance.ts index d1af2c9f1af..cf92bca5c65 100644 --- a/src/renderer/src/components/terminal-pane/terminal-appearance.ts +++ b/src/renderer/src/components/terminal-pane/terminal-appearance.ts @@ -10,7 +10,7 @@ import { } from '@/lib/terminal-theme' import { buildFontFamily } from './layout-serialization' import { guardParserHandler } from './terminal-parser-handler-guard' -import { captureScrollState, restoreScrollState, safeFit } from '@/lib/pane-manager/pane-tree-ops' +import { safeFit, safeFitAndThen } from '@/lib/pane-manager/pane-tree-ops' import { normalizeTerminalFastScrollSensitivity, normalizeTerminalScrollSensitivity, @@ -300,21 +300,26 @@ export function applyTerminalAppearance( // separate hook and lets live toggles (settings change, font swap) // land immediately. manager.setPaneLigaturesEnabled(pane.id, ligaturesEnabled) - try { - const state = captureScrollState(pane.terminal) - safeFit(pane) - restoreScrollState(pane.terminal, state) - } catch { - /* ignore */ - } const transport = paneTransports.get(pane.id) // Why: skip PTY resize when a mobile-fit override is active — the PTY // is already at the correct phone dimensions and must not be resized // back to desktop dimensions by an appearance change. const appearancePtyId = transport?.getPtyId() if (transport?.isConnected() && (!appearancePtyId || !getFitOverrideForPty(appearancePtyId))) { - transport.resize(pane.terminal.cols, pane.terminal.rows) maybePushMode2031Flip(pane.id, appearance.mode, transport, paneMode2031, paneLastThemeMode) + safeFitAndThen(pane, 'appearance-pty-resize', () => { + const currentTransport = paneTransports.get(pane.id) + if ( + currentTransport !== transport || + !transport.isConnected() || + transport.getPtyId() !== appearancePtyId + ) { + return + } + transport.resize(pane.terminal.cols, pane.terminal.rows) + }) + } else { + safeFit(pane) } } diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts index b42304cb851..29d9f27d287 100644 --- a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts @@ -13,7 +13,8 @@ vi.mock('@/lib/pane-manager/pane-terminal-output-scheduler', () => ({ requestTerminalBacklogRecovery: vi.fn() })) vi.mock('@/lib/pane-manager/terminal-scroll-intent', () => ({ - enforceTerminalCurrentScrollIntent: vi.fn() + enforceTerminalCurrentScrollIntent: vi.fn(), + syncTerminalScrollIntentFromViewport: vi.fn() })) vi.mock('./pane-helpers', () => ({ fitAndFocusPanes: vi.fn(), @@ -26,6 +27,11 @@ vi.mock('./terminal-webgl-atlas-recovery', () => ({ // the terminal-output debounce (which a background stream could otherwise defer). scheduleTabRevealWebglAtlasRecovery: () => scheduleTabRevealWebglAtlasRecovery() })) +const resetTerminalLinkifierHoverState = vi.fn() +vi.mock('@/lib/pane-manager/terminal-linkifier-hover-reset', () => ({ + resetTerminalLinkifierHoverState: (terminal: unknown) => + resetTerminalLinkifierHoverState(terminal) +})) type FakeManager = { getPanes: ReturnType @@ -73,6 +79,34 @@ describe('resumeTerminalVisibility reveal repaint', () => { expect(scheduleTabRevealWebglAtlasRecovery).toHaveBeenCalledTimes(1) }) + it('captures native trim movement before enforcing viewport intent', async () => { + const terminal = { name: 'trimmed-terminal' } + const manager = createManager() + manager.getPanes.mockReturnValue([{ terminal }]) + const { enforceTerminalCurrentScrollIntent, syncTerminalScrollIntentFromViewport } = vi.mocked( + await import('@/lib/pane-manager/terminal-scroll-intent') + ) + + resumeTerminalVisibility(resumeArgs(manager, true)) + + expect(syncTerminalScrollIntentFromViewport).toHaveBeenCalledWith(terminal) + expect(syncTerminalScrollIntentFromViewport.mock.invocationCallOrder[0]).toBeLessThan( + enforceTerminalCurrentScrollIntent.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY + ) + }) + + it('resets each pane linkifier hover cache on reveal so links recover without a scroll', () => { + const first = { name: 'pane-a' } + const second = { name: 'pane-b' } + const manager = createManager() + manager.getPanes.mockReturnValue([{ terminal: first }, { terminal: second }]) + + resumeTerminalVisibility(resumeArgs(manager, false)) + + expect(resetTerminalLinkifierHoverState).toHaveBeenCalledWith(first) + expect(resetTerminalLinkifierHoverState).toHaveBeenCalledWith(second) + }) + it('schedules the repaint after rendering resumes on a heavy reveal', () => { const order: string[] = [] const manager = createManager(order) diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts index d1a0cc9e20f..c330efb37e4 100644 --- a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts @@ -5,7 +5,11 @@ import { flushTerminalOutput, requestTerminalBacklogRecovery } from '@/lib/pane-manager/pane-terminal-output-scheduler' -import { enforceTerminalCurrentScrollIntent } from '@/lib/pane-manager/terminal-scroll-intent' +import { + enforceTerminalCurrentScrollIntent, + syncTerminalScrollIntentFromViewport +} from '@/lib/pane-manager/terminal-scroll-intent' +import { resetTerminalLinkifierHoverState } from '@/lib/pane-manager/terminal-linkifier-hover-reset' import { fitAndFocusPanes, fitPanes, focusActivePane } from './pane-helpers' import { scheduleTabRevealWebglAtlasRecovery } from './terminal-webgl-atlas-recovery' @@ -51,6 +55,13 @@ export function resumeTerminalVisibility({ captureViewportPositions, withSuppressedScrollTracking }: ResumeTerminalVisibilityArgs): void { + // Why: hiding the surface fired mouseleave, which cleared xterm's current + // link but left its hover cell cache; without this reset a link stays dead + // until a scroll when the pointer returns to the same cell on reveal. + for (const pane of manager.getPanes()) { + resetTerminalLinkifierHoverState(pane.terminal) + } + syncTerminalViewportIntents(manager) // Why: WebGL resume can disturb xterm's viewport bookkeeping before the // post-resume fit runs. Capture numeric viewport positions first; the // restore path avoids content matching so duplicate agent log lines do @@ -133,6 +144,7 @@ export function recoverVisibleTerminalWindowWake({ requestTerminalBacklogRecovery(pane.terminal) flushTerminalOutput(pane.terminal, { maxChars: WINDOW_WAKE_FLUSH_CHARS }) } + syncTerminalViewportIntents(manager) manager.resumeRendering() if (isActive) { fitAndFocusPanes(manager) @@ -170,6 +182,7 @@ function resumeTerminalVisibilityHeavy(manager: PaneManager, isActive: boolean): requestTerminalBacklogRecovery(pane.terminal) flushTerminalOutput(pane.terminal, { maxChars: VISIBLE_RESUME_FLUSH_CHARS }) } + syncTerminalViewportIntents(manager) // Resume WebGL immediately so the terminal shows its last-known state // on the first painted frame. macOS context creation is ~5 ms; on // Windows (ANGLE -> D3D11) it can be 100-500 ms but a deferred resume @@ -190,3 +203,11 @@ function enforceTerminalViewportIntents(manager: PaneManager): void { enforceTerminalCurrentScrollIntent(pane.terminal) } } + +function syncTerminalViewportIntents(manager: PaneManager): void { + for (const pane of manager.getPanes()) { + // Why: native scrollback trimming moves a pinned viewport content-stably. + // Capture that live position before resume/fit can disturb it. + syncTerminalScrollIntentFromViewport(pane.terminal) + } +} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts index 4587aac9354..de537af3d91 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts @@ -20,6 +20,7 @@ const mocks = vi.hoisted(() => ({ getTerminalOutputEpoch: vi.fn(() => 0), handleTerminalFileDrop: vi.fn(), enforceTerminalCurrentScrollIntent: vi.fn(), + syncTerminalScrollIntentFromViewport: vi.fn(), pasteTerminalText: vi.fn(), recordTerminalUserInputForLeaf: vi.fn(), requestTerminalBacklogRecovery: vi.fn(), @@ -79,7 +80,8 @@ vi.mock('@/lib/pane-manager/pane-scroll', () => ({ })) vi.mock('@/lib/pane-manager/terminal-scroll-intent', () => ({ - enforceTerminalCurrentScrollIntent: mocks.enforceTerminalCurrentScrollIntent + enforceTerminalCurrentScrollIntent: mocks.enforceTerminalCurrentScrollIntent, + syncTerminalScrollIntentFromViewport: mocks.syncTerminalScrollIntentFromViewport })) vi.mock('./terminal-drop-handler', () => ({ 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 c2c355a8e9b..e1fe139d445 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 @@ -106,10 +106,8 @@ import { getConnectionId } from '@/lib/connection-context' import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' import { isPaneReplaying, type ReplayingPanesRef } from './replay-guard' import { fitAndFocusPanes, fitPanes } from './pane-helpers' -import { - markTerminalPinnedViewport, - syncTerminalScrollIntentSoon -} from '@/lib/pane-manager/terminal-scroll-intent' +import { markTerminalPinnedViewport } from '@/lib/pane-manager/terminal-scroll-intent' +import { syncTerminalScrollIntentSoon } from '@/lib/pane-manager/terminal-scroll-intent-settle' import { registerRuntimeTerminalTab, scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' import { captureParkedTerminalPaneCandidates } from './terminal-parked-tab-watchers' import { e2eConfig } from '@/lib/e2e-config' @@ -1024,11 +1022,20 @@ export function useTerminalPaneLifecycle({ } if (e.type === 'keydown') { + const shouldSyncCurrentTerminal = (): boolean => + managerRef.current + ?.getPanes() + .some((candidate) => candidate.terminal === pane.terminal) === true if (e.key === 'PageUp' || e.key === 'Home') { markTerminalPinnedViewport(pane.terminal) - syncTerminalScrollIntentSoon(pane.terminal, { preservePinnedAtBottom: true }) + syncTerminalScrollIntentSoon(pane.terminal, { + preservePinnedAtBottom: true, + shouldSync: shouldSyncCurrentTerminal + }) } else if (e.key === 'PageDown' || e.key === 'End') { - syncTerminalScrollIntentSoon(pane.terminal) + syncTerminalScrollIntentSoon(pane.terminal, { + shouldSync: shouldSyncCurrentTerminal + }) } } diff --git a/src/renderer/src/components/terminal-pane/useTerminalFontZoom.ts b/src/renderer/src/components/terminal-pane/useTerminalFontZoom.ts index da714e9bfe4..1d2bd6c22a0 100644 --- a/src/renderer/src/components/terminal-pane/useTerminalFontZoom.ts +++ b/src/renderer/src/components/terminal-pane/useTerminalFontZoom.ts @@ -1,7 +1,7 @@ import { useEffect } from 'react' import type { PaneManager } from '@/lib/pane-manager/pane-manager' import { dispatchZoomLevelChanged } from '@/lib/zoom-events' -import { captureScrollState, restoreScrollState, safeFit } from '@/lib/pane-manager/pane-tree-ops' +import { safeFit } from '@/lib/pane-manager/pane-tree-ops' import { getPaneOwnedActiveHelperTextarea } from './regular-terminal-focus-ownership' type FontZoomDeps = { @@ -57,13 +57,7 @@ export function useTerminalFontZoom({ } pane.terminal.options.fontSize = nextSize - try { - const state = captureScrollState(pane.terminal) - safeFit(pane) - restoreScrollState(pane.terminal, state) - } catch { - /* ignore */ - } + safeFit(pane) const percent = Math.round((nextSize / globalSize) * 100) dispatchZoomLevelChanged('terminal', percent) diff --git a/src/renderer/src/components/terminal/terminal-tab-actions.test.ts b/src/renderer/src/components/terminal/terminal-tab-actions.test.ts index ce310190eef..7c7bb511efc 100644 --- a/src/renderer/src/components/terminal/terminal-tab-actions.test.ts +++ b/src/renderer/src/components/terminal/terminal-tab-actions.test.ts @@ -442,6 +442,27 @@ describe('closeTerminalTab', () => { expect(onCancel).toHaveBeenCalledTimes(1) }) + it('rejects a pinned background lifecycle close without opening a confirmation modal', () => { + const requestPinnedTabCloseConfirm = vi.fn() + const closeUnifiedTab = vi.fn() + const onClosed = vi.fn() + const onCancel = vi.fn() + getStateMock.mockReturnValue( + makePinnedTabState({ + confirmClosePinnedTab: true, + requestPinnedTabCloseConfirm, + closeUnifiedTab + }) + ) + + closeTerminalTab('pinned-entity-1', { rejectPinned: true, onClosed, onCancel }) + + expect(requestPinnedTabCloseConfirm).not.toHaveBeenCalled() + expect(closeUnifiedTab).not.toHaveBeenCalled() + expect(onClosed).not.toHaveBeenCalled() + expect(onCancel).toHaveBeenCalledTimes(1) + }) + it('guards a pinned tab closed by its unified id (workspace overlay path)', () => { const requestPinnedTabCloseConfirm = vi.fn() const closeUnifiedTab = vi.fn() diff --git a/src/renderer/src/components/terminal/terminal-tab-actions.ts b/src/renderer/src/components/terminal/terminal-tab-actions.ts index 3f41e8315bf..ebd861e6952 100644 --- a/src/renderer/src/components/terminal/terminal-tab-actions.ts +++ b/src/renderer/src/components/terminal/terminal-tab-actions.ts @@ -49,6 +49,7 @@ export function closeTerminalTab( tabId: string, options?: { force?: boolean + rejectPinned?: boolean reason?: TerminalTabCloseReason captureRecentlyClosed?: boolean localPtyTeardownOwnedExternally?: boolean @@ -78,6 +79,12 @@ export function closeTerminalTab( !options?.force && isPinnedVisibleTab(state, owningWorktreeId, terminalTabId) ) { + // Why: background lifecycle callers cannot safely wait on a modal whose + // owner may be unattended; reject pinned tabs without bypassing the guard. + if (options?.rejectPinned) { + options.onCancel?.() + return + } guardPinnedTabClose({ isPinned: true, tabLabel: resolvePinnedTabLabel(state, owningWorktreeId, terminalTabId), diff --git a/src/renderer/src/components/worktree-jump-palette-source-context-boundary.test.ts b/src/renderer/src/components/worktree-jump-palette-source-context-boundary.test.ts index ac60d86d2eb..31bdaf65d5e 100644 --- a/src/renderer/src/components/worktree-jump-palette-source-context-boundary.test.ts +++ b/src/renderer/src/components/worktree-jump-palette-source-context-boundary.test.ts @@ -13,14 +13,21 @@ function sourceBetween(startPattern: string, endPattern: string): string { } describe('WorktreeJumpPalette source-context boundaries', () => { - it('resolves typed GitHub issue/PR entries through the lookup repo source host', () => { - expect(source).toContain('buildTaskSourceContextFromRepo') - + it('defers pasted GitHub URL resolution to the composer so cross-project detection runs', () => { + // Why: pasting a cross-project URL must surface the same "Switch project?" + // prompt as Cmd+N. The palette hands the raw URL to the composer's name + // field instead of pre-resolving it against an arbitrary repo, which + // silently linked cross-project items to the wrong project. const githubLinkSection = sourceBetween( - 'void lookupGitHubWorkItemByOwnerRepoForSource({', + '// Case 1: user pasted a GH issue/PR URL.', '// Case 2: user typed a raw issue number.' ) - expect(githubLinkSection).toContain('sourceContext') + expect(githubLinkSection).toContain('prefilledName: trimmed') + expect(githubLinkSection).not.toContain('lookupGitHubWorkItemByOwnerRepoForSource') + }) + + it('resolves typed raw issue/PR numbers through the lookup repo source host', () => { + expect(source).toContain('buildTaskSourceContextFromRepo') const rawNumberSection = sourceBetween( 'void lookupGitHubWorkItemForSource({', diff --git a/src/renderer/src/hooks/installed-agent-skills-change-event.ts b/src/renderer/src/hooks/installed-agent-skills-change-event.ts new file mode 100644 index 00000000000..2e11a17ff03 --- /dev/null +++ b/src/renderer/src/hooks/installed-agent-skills-change-event.ts @@ -0,0 +1 @@ +export const INSTALLED_AGENT_SKILLS_CHANGED_EVENT = 'orca:installed-agent-skills-changed' diff --git a/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts index 51b552f3436..c46bd3ea5c2 100644 --- a/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts +++ b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts @@ -411,6 +411,36 @@ describe('useComposerState host-context boundaries', () => { ) }) + it('keeps a Linear branch override when its workspace-scoped issue survives a repo change', () => { + const section = sourceBetween( + HOOK_SOURCE, + 'const handleRepoChange = useCallback', + 'const handleFolderSourceRepoChange = useCallback' + ) + + expect(section).toContain('const preservedLinearBranchName = preserveLinearLinkedWorkItem') + expect(section).toContain('getLinearLinkedWorkItemBranchName(linkedWorkItem)') + expect(section).toContain('setBranchNameOverride(preservedLinearBranchName)') + expect(section).toContain( + 'setBranchNameOverridePreservesNameEdits(Boolean(preservedLinearBranchName))' + ) + expect(section).toContain("branchAutoNameRef.current = preservedLinearBranchName ?? ''") + }) + + it('clears a Linear branch override when its linked issue is removed', () => { + const section = sourceBetween( + HOOK_SOURCE, + 'const handleRemoveLinkedWorkItem = useCallback', + 'const handleNameValueChange = useCallback' + ) + + expect(section).toContain('const removedLinearItem = isLinearLinkedWorkItem(linkedWorkItem)') + expect(section).toContain('if (removedLinearItem)') + expect(section).toContain('setBranchNameOverride(undefined)') + expect(section).toContain('setBranchNameOverridePreservesNameEdits(false)') + expect(section).toContain("branchAutoNameRef.current = ''") + }) + it('selects a project by its own host instead of pinning the current host', () => { // Regression: passing the current host as a hard `hostId` made picking a // project set up only on a different host a silent no-op. The current host @@ -445,6 +475,8 @@ describe('useComposerState host-context boundaries', () => { ) expect(githubApply).toContain('setLinkedGitLabIssue(null)') expect(githubApply).toContain('setLinkedGitLabMR(null)') + expect(githubApply).toContain('setBranchNameOverridePreservesNameEdits(false)') + expect(githubApply).toContain("branchAutoNameRef.current = ''") const gitlabApply = sourceBetween( HOOK_SOURCE, @@ -453,6 +485,8 @@ describe('useComposerState host-context boundaries', () => { ) expect(gitlabApply).toContain("setLinkedIssue('')") expect(gitlabApply).toContain('setLinkedPR(null)') + expect(gitlabApply).toContain('setBranchNameOverridePreservesNameEdits(false)') + expect(gitlabApply).toContain("branchAutoNameRef.current = ''") const projectGroupSmartHandlers = sourceBetween( HOOK_SOURCE, diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index eecc7a6c974..aaad1b6a622 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -78,6 +78,7 @@ import { import { getLocalRepoProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import { buildLinearIssueLinkedWorkItem, + getLinearLinkedWorkItemBranchName, isLinearLinkedWorkItem } from '@/lib/linear-linked-work-item' import { getLinearIssueWorkspaceName } from '../../../shared/workspace-name' @@ -960,6 +961,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const [linkedWorkItem, setLinkedWorkItem] = useState( () => linkedWorkItemSeed ) + const initialLinearBranchName = getLinearLinkedWorkItemBranchName(linkedWorkItemSeed) const taskSourceContext = useMemo(() => { if ( persistDraft && @@ -1082,9 +1084,12 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const [compareBaseRef, setCompareBaseRef] = useState( persistDraft ? newWorkspaceDraft?.compareBaseRef : undefined ) - const [branchNameOverride, setBranchNameOverride] = useState(undefined) - const [branchNameOverridePreservesNameEdits, setBranchNameOverridePreservesNameEdits] = - useState(false) + const [branchNameOverride, setBranchNameOverride] = useState( + initialLinearBranchName + ) + const [branchNameOverridePreservesNameEdits, setBranchNameOverridePreservesNameEdits] = useState( + Boolean(initialLinearBranchName) + ) const [smartNameMode, setSmartNameMode] = useState('smart') // Why (#5181): when the user picks an existing LOCAL branch, let them reuse it // (check it out) instead of creating a new branch from it. `reuseEligibleBranch` @@ -2084,6 +2089,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS } if (!options.preserveBranchNameOverride) { setBranchNameOverride(undefined) + setBranchNameOverridePreservesNameEdits(false) + branchAutoNameRef.current = '' } }, [name] @@ -2321,6 +2328,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS lastAutoNameRef.current = nextName } setBranchNameOverride(undefined) + setBranchNameOverridePreservesNameEdits(false) + branchAutoNameRef.current = '' }, [name] ) @@ -2348,6 +2357,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const handleRemoveLinkedWorkItem = useCallback((): void => { smartGitHubPrStartPointSelectionRef.current = null + const removedLinearItem = isLinearLinkedWorkItem(linkedWorkItem) setLinkedWorkItem(null) setLinkedIssue('') setLinkedPR(null) @@ -2355,7 +2365,14 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS if (name === lastAutoNameRef.current) { lastAutoNameRef.current = '' } - }, [name]) + if (removedLinearItem) { + // Why: a Linear branch override belongs to its linked issue; unlinking + // must not leave provider metadata driving a later worktree create. + setBranchNameOverride(undefined) + setBranchNameOverridePreservesNameEdits(false) + branchAutoNameRef.current = '' + } + }, [linkedWorkItem, name]) const handleNameValueChange = useCallback( (nextName: string): void => { @@ -2646,6 +2663,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS } } const preserveLinearLinkedWorkItem = isLinearLinkedWorkItem(linkedWorkItem) + const preservedLinearBranchName = preserveLinearLinkedWorkItem + ? getLinearLinkedWorkItemBranchName(linkedWorkItem) + : undefined setRepoId(value) if (!options.preserveStartFrom) { smartGitHubPrStartPointSelectionRef.current = null @@ -2672,10 +2692,13 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS setBaseBranch(undefined) setCompareBaseRef(undefined) setPushTarget(undefined) - setBranchNameOverride(undefined) + // Why: Linear sources are workspace-scoped, so their canonical branch + // survives choosing a different implementation repo with the issue. + setBranchNameOverride(preservedLinearBranchName) + setBranchNameOverridePreservesNameEdits(Boolean(preservedLinearBranchName)) + branchAutoNameRef.current = preservedLinearBranchName ?? '' // Why (#5181): reuse state is branch-scoped, so a repo switch must clear - // it alongside the branch override (matches the other reset paths). - setBranchNameOverridePreservesNameEdits(false) + // it even when a workspace-scoped Linear override is restored. setReuseEligibleBranch(null) setReuseSelectedBranch(false) setForkPushWarning(null) @@ -2929,6 +2952,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS } setStartFromResetHint(null) setBranchNameOverride(undefined) + setBranchNameOverridePreservesNameEdits(false) setForkPushWarning(null) branchAutoNameRef.current = '' smartGitHubPrStartPointSelectionRef.current = null @@ -3037,6 +3061,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS applyLinkedGitLabWorkItem(item) setStartFromResetHint(null) setBranchNameOverride(undefined) + setBranchNameOverridePreservesNameEdits(false) setForkPushWarning(null) branchAutoNameRef.current = '' // Why: MR metadata can be sourced from one host/account while the @@ -3208,7 +3233,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS setLinkedPR(null) setLinkedGitLabIssue(null) setLinkedGitLabMR(null) - setLinkedWorkItem(buildLinearIssueLinkedWorkItem(issue)) + const linkedLinearIssue = buildLinearIssueLinkedWorkItem(issue) + setLinkedWorkItem(linkedLinearIssue) const suggestedName = getLinearIssueWorkspaceName(issue) // Why: same lookup-text rule as applyLinkedWorkItem, plus the typed // Linear identifier ("STA-123") that matched this issue. @@ -3222,9 +3248,11 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS setName(suggestedName) lastAutoNameRef.current = suggestedName } - setBranchNameOverride(undefined) + const linearBranchName = getLinearLinkedWorkItemBranchName(linkedLinearIssue) + setBranchNameOverride(linearBranchName) + setBranchNameOverridePreservesNameEdits(Boolean(linearBranchName)) setForkPushWarning(null) - branchAutoNameRef.current = '' + branchAutoNameRef.current = linearBranchName ?? '' // Why: match the GitHub issue/PR flow by drafting linked context for // review instead of auto-submitting. Auto-filling the note here would // turn a source selection into user-authored instructions. diff --git a/src/renderer/src/hooks/useInstalledAgentSkills.ts b/src/renderer/src/hooks/useInstalledAgentSkills.ts index e06a8a2a2ee..e19bac655b1 100644 --- a/src/renderer/src/hooks/useInstalledAgentSkills.ts +++ b/src/renderer/src/hooks/useInstalledAgentSkills.ts @@ -7,9 +7,9 @@ import type { } from '../../../shared/skills' import { ORCHESTRATION_SKILL_NAME } from '@/lib/agent-feature-install-commands' import { markOrchestrationSetupComplete } from '@/lib/orchestration-setup-state' +import { INSTALLED_AGENT_SKILLS_CHANGED_EVENT } from './installed-agent-skills-change-event' import { useMountedRef } from './useMountedRef' -const INSTALLED_AGENT_SKILLS_CHANGED_EVENT = 'orca:installed-agent-skills-changed' export const GLOBAL_AGENT_SKILL_SOURCE_KINDS = [ 'home' ] as const satisfies readonly SkillSourceKind[] diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index bc0ca7fec92..16f5deaddd8 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -313,6 +313,7 @@ describe('useIpcEvents zoom routing', () => { }, agentStatus: { onSet: () => () => {} }, ui: makeEvents({ + consumePendingOpenSettings: () => Promise.resolve(false), onTerminalZoom: (listener: (direction: 'in' | 'out' | 'reset') => void) => { terminalZoomListenerRef.current = listener return () => {} @@ -455,6 +456,7 @@ describe('useIpcEvents zoom routing', () => { }, agentStatus: { onSet: () => () => {} }, ui: makeEvents({ + consumePendingOpenSettings: () => Promise.resolve(false), onTerminalZoom: (listener: (direction: 'in' | 'out' | 'reset') => void) => { terminalZoomListenerRef.current = listener return () => {} @@ -666,6 +668,7 @@ describe('useIpcEvents rate-limit hydration', () => { }, agentStatus: { onSet: () => () => {} }, ui: makeEvents({ + consumePendingOpenSettings: () => Promise.resolve(false), getZoomLevel: vi.fn(() => 0) }) } @@ -954,6 +957,7 @@ describe('useIpcEvents browser tab create routing', () => { ui: { onStateChanged: () => () => {}, onOpenSettings: () => () => {}, + consumePendingOpenSettings: () => Promise.resolve(false), onOpenFeatureTour: () => () => {}, onToggleLeftSidebar: () => () => {}, onToggleRightSidebar: () => () => {}, @@ -969,6 +973,7 @@ describe('useIpcEvents browser tab create routing', () => { onActivateWorktree: () => () => {}, onCreateTerminal: () => () => {}, onRequestTerminalCreate: () => () => {}, + onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: () => {}, onSplitTerminal: () => () => {}, onRenameTerminal: () => () => {}, @@ -1175,6 +1180,7 @@ describe('useIpcEvents updater integration', () => { ui: { onStateChanged: () => () => {}, onOpenSettings: () => () => {}, + consumePendingOpenSettings: () => Promise.resolve(false), onOpenFeatureTour: () => () => {}, onToggleLeftSidebar: () => () => {}, onToggleRightSidebar: () => () => {}, @@ -1190,6 +1196,7 @@ describe('useIpcEvents updater integration', () => { onActivateWorktree: () => () => {}, onCreateTerminal: () => () => {}, onRequestTerminalCreate: () => () => {}, + onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: () => {}, onSplitTerminal: () => () => {}, onRenameTerminal: () => () => {}, @@ -1293,6 +1300,130 @@ describe('useIpcEvents updater integration', () => { expect(removeSshCredentialRequest).toHaveBeenCalledWith('req-1') }) + it('opens Settings from a Settings intent queued before the listener attached', async () => { + const openSettingsPage = vi.fn() + let onOpenSettingsRegistered = false + + vi.doMock('react', async () => { + const actual = await vi.importActual('react') + return { + ...actual, + useEffect: (effect: () => void | (() => void)) => { + effect() + } + } + }) + + vi.doMock('../store', () => ({ + useAppStore: { + subscribe: vi.fn(() => () => {}), + getState: () => ({ + openSettingsPage, + fetchRepos: vi.fn(), + fetchWorktrees: vi.fn(), + setActiveView: vi.fn(), + activeModal: null, + closeModal: vi.fn(), + openModal: vi.fn(), + activeWorktreeId: 'wt-1', + activeView: 'terminal', + setActiveRepo: vi.fn(), + setActiveWorktree: vi.fn(), + revealWorktreeInSidebar: vi.fn(), + setIsFullScreen: vi.fn(), + setUpdateStatus: vi.fn(), + setRateLimitsFromPush: vi.fn(), + settings: { terminalFontSize: 13 } + }) + } + })) + + vi.doMock('@/lib/ui-zoom', () => ({ applyUIZoom: vi.fn() })) + vi.doMock('@/lib/worktree-activation', () => ({ + activateAndRevealWorktree: vi.fn(), + ensureWorktreeHasInitialTerminal: vi.fn() + })) + vi.doMock('@/components/sidebar/visible-worktrees', () => ({ + getVisibleWorktreeIds: () => [] + })) + vi.doMock('@/lib/editor-font-zoom', () => ({ + nextEditorFontZoomLevel: vi.fn(() => 0), + computeEditorFontSize: vi.fn(() => 13) + })) + vi.doMock('@/components/settings/SettingsConstants', () => ({ + zoomLevelToPercent: vi.fn(() => 100), + ZOOM_MIN: -3, + ZOOM_MAX: 3 + })) + vi.doMock('@/lib/zoom-events', () => ({ dispatchZoomLevelChanged: vi.fn() })) + + const makeEvents = (target: Record = {}): Record => + new Proxy(target, { + get: (namespace, prop) => + prop in namespace ? Reflect.get(namespace, prop) : () => () => {} + }) + + vi.stubGlobal('window', { + api: { + repos: makeEvents(), + worktrees: makeEvents(), + keybindings: makeEvents(), + settings: makeEvents(), + browser: makeEvents(), + updater: { + getStatus: () => Promise.resolve({ state: 'idle' }), + onStatus: () => () => {}, + onClearDismissal: () => () => {} + }, + rateLimits: { + get: () => Promise.resolve({ limits: {}, lastUpdatedAt: Date.now() }), + onUpdate: () => () => {} + }, + runtime: { + getTerminalFitOverrides: () => Promise.resolve([]), + getTerminalDrivers: () => Promise.resolve([]), + getBrowserDrivers: () => Promise.resolve([]), + onTerminalFitOverrideChanged: () => () => {}, + onTerminalDriverChanged: () => () => {}, + onBrowserDriverChanged: () => () => {} + }, + ssh: { + listTargets: () => Promise.resolve([]), + listPortForwards: () => Promise.resolve([]), + listDetectedPorts: () => Promise.resolve([]), + getState: () => Promise.resolve(null), + onStateChanged: () => () => {}, + onCredentialRequest: () => () => {}, + onCredentialResolved: () => () => {}, + onPortForwardsChanged: () => () => {}, + onDetectedPortsChanged: () => () => {} + }, + agentStatus: { onSet: () => () => {} }, + ui: makeEvents({ + onOpenSettings: () => { + onOpenSettingsRegistered = true + return () => {} + }, + // Why: exercise the positive branch — an intent queued before mount is + // pulled once the renderer's onOpenSettings listener is attached. + consumePendingOpenSettings: () => Promise.resolve(true), + getZoomLevel: () => 0, + set: vi.fn() + }) + } + }) + + const { useIpcEvents } = await import('./useIpcEvents') + + useIpcEvents() + // Why: the pending-intent pull is a Promise; flush microtasks before asserting. + await Promise.resolve() + await Promise.resolve() + + expect(onOpenSettingsRegistered).toBe(true) + expect(openSettingsPage).toHaveBeenCalledTimes(1) + }) + it('clears stale remote PTYs when an SSH connection fully disconnects', async () => { const clearTabPtyId = vi.fn() const setSshConnectionState = vi.fn() @@ -1419,6 +1550,7 @@ describe('useIpcEvents updater integration', () => { ui: { onStateChanged: () => () => {}, onOpenSettings: () => () => {}, + consumePendingOpenSettings: () => Promise.resolve(false), onOpenFeatureTour: () => () => {}, onToggleLeftSidebar: () => () => {}, onToggleRightSidebar: () => () => {}, @@ -1434,6 +1566,7 @@ describe('useIpcEvents updater integration', () => { onActivateWorktree: () => () => {}, onCreateTerminal: () => () => {}, onRequestTerminalCreate: () => () => {}, + onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: () => {}, onSplitTerminal: () => () => {}, onRenameTerminal: () => () => {}, @@ -1859,6 +1992,7 @@ describe('useIpcEvents updater integration', () => { ui: { onStateChanged: () => () => {}, onOpenSettings: () => () => {}, + consumePendingOpenSettings: () => Promise.resolve(false), onOpenFeatureTour: () => () => {}, onToggleLeftSidebar: () => () => {}, onToggleRightSidebar: () => () => {}, @@ -1918,6 +2052,7 @@ describe('useIpcEvents updater integration', () => { requestTerminalCreateListenerRef.current = listener return () => {} }, + onRequestTerminalTabMount: () => () => {}, replyTerminalCreate, onSplitTerminal: () => () => {}, onRenameTerminal: () => () => {}, @@ -2684,6 +2819,7 @@ describe('useIpcEvents browser tab close routing', () => { type CloseActiveTabListener = () => void type CloseTerminalListener = (data: { tabId: string; paneRuntimeId?: number | null }) => void type CloseSessionTabListener = (data: { tabId: string; worktreeId: string }) => void + type TerminalTabCloseRequestListener = (data: { requestId: string; tabId: string }) => void async function useIpcEventsForCloseRouting({ closeActiveTabListenerRef, @@ -2691,7 +2827,10 @@ describe('useIpcEvents browser tab close routing', () => { closeTerminalListenerRef, getState, requestTabCloseListenerRef, - replyTabClose = vi.fn() + replyTabClose = vi.fn(), + terminalTabCloseRequestListenerRef, + respondTerminalTabClose = vi.fn(), + persistWorkspaceSession = vi.fn().mockResolvedValue(undefined) }: { closeActiveTabListenerRef?: { current: CloseActiveTabListener | null } closeSessionTabListenerRef?: { current: CloseSessionTabListener | null } @@ -2699,6 +2838,9 @@ describe('useIpcEvents browser tab close routing', () => { getState: () => Record requestTabCloseListenerRef?: { current: RequestTabCloseListener | null } replyTabClose?: ReturnType + terminalTabCloseRequestListenerRef?: { current: TerminalTabCloseRequestListener | null } + respondTerminalTabClose?: ReturnType + persistWorkspaceSession?: ReturnType }): Promise { vi.doMock('react', async () => { const actual = await vi.importActual('react') @@ -2779,6 +2921,12 @@ describe('useIpcEvents browser tab close routing', () => { vi.doMock('@/lib/zoom-events', () => ({ dispatchZoomLevelChanged: vi.fn() })) + vi.doMock('@/lib/workspace-session-host-persistence', () => ({ + persistWorkspaceSessionByHost: persistWorkspaceSession + })) + vi.doMock('@/lib/workspace-session', () => ({ + buildWorkspaceSessionPayload: vi.fn(() => ({})) + })) vi.stubGlobal('window', { dispatchEvent: vi.fn(), @@ -2792,6 +2940,7 @@ describe('useIpcEvents browser tab close routing', () => { ui: { onStateChanged: () => () => {}, onOpenSettings: () => () => {}, + consumePendingOpenSettings: () => Promise.resolve(false), onOpenFeatureTour: () => () => {}, onToggleLeftSidebar: () => () => {}, onToggleRightSidebar: () => () => {}, @@ -2807,6 +2956,7 @@ describe('useIpcEvents browser tab close routing', () => { onActivateWorktree: () => () => {}, onCreateTerminal: () => () => {}, onRequestTerminalCreate: () => () => {}, + onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: () => {}, onSplitTerminal: () => () => {}, onRenameTerminal: () => () => {}, @@ -2827,6 +2977,13 @@ describe('useIpcEvents browser tab close routing', () => { } return () => {} }, + onTerminalTabCloseRequest: (listener: TerminalTabCloseRequestListener) => { + if (terminalTabCloseRequestListenerRef) { + terminalTabCloseRequestListenerRef.current = listener + } + return () => {} + }, + respondTerminalTabClose, onSleepWorktree: () => () => {}, onResumeSleepingAgents: () => () => {}, onNewBrowserTab: () => () => {}, @@ -2974,6 +3131,65 @@ describe('useIpcEvents browser tab close routing', () => { expect(closeTerminalTabMock).toHaveBeenCalledWith('terminal-1') }) + it('acknowledges whole-tab close only after the fresh session is durably persisted', async () => { + const listenerRef: { current: TerminalTabCloseRequestListener | null } = { current: null } + let finishPersist!: () => void + const persistWorkspaceSession = vi.fn( + () => + new Promise((resolve) => { + finishPersist = resolve + }) + ) + const respondTerminalTabClose = vi.fn() + closeTerminalTabMock.mockImplementation((_tabId: string, options: { onClosed?: () => void }) => + options.onClosed?.() + ) + await useIpcEventsForCloseRouting({ + getState: () => ({}), + terminalTabCloseRequestListenerRef: listenerRef, + respondTerminalTabClose, + persistWorkspaceSession + }) + + listenerRef.current?.({ requestId: 'close-1', tabId: 'terminal-1' }) + await Promise.resolve() + + expect(closeTerminalTabMock).toHaveBeenCalledWith( + 'terminal-1', + expect.objectContaining({ rejectPinned: true }) + ) + expect(persistWorkspaceSession).toHaveBeenCalledTimes(1) + expect(respondTerminalTabClose).not.toHaveBeenCalled() + + finishPersist() + await vi.waitFor(() => + expect(respondTerminalTabClose).toHaveBeenCalledWith({ requestId: 'close-1' }) + ) + }) + + it('rejects a pinned whole-tab close without persisting or reporting success', async () => { + const listenerRef: { current: TerminalTabCloseRequestListener | null } = { current: null } + const persistWorkspaceSession = vi.fn().mockResolvedValue(undefined) + const respondTerminalTabClose = vi.fn() + closeTerminalTabMock.mockImplementation((_tabId: string, options: { onCancel?: () => void }) => + options.onCancel?.() + ) + await useIpcEventsForCloseRouting({ + getState: () => ({}), + terminalTabCloseRequestListenerRef: listenerRef, + respondTerminalTabClose, + persistWorkspaceSession + }) + + listenerRef.current?.({ requestId: 'close-pinned', tabId: 'terminal-pinned' }) + + expect(persistWorkspaceSession).not.toHaveBeenCalled() + expect(respondTerminalTabClose).toHaveBeenCalledWith({ + requestId: 'close-pinned', + error: 'terminal_tab_pinned' + }) + }) + it('confirms before closing a pinned active browser tab from the native close event', async () => { const closeActiveTabListenerRef: { current: CloseActiveTabListener | null } = { current: null } const closeBrowserTab = vi.fn() @@ -3281,6 +3497,7 @@ describe('useIpcEvents browser tab close routing', () => { ui: { onStateChanged: () => () => {}, onOpenSettings: () => () => {}, + consumePendingOpenSettings: () => Promise.resolve(false), onOpenFeatureTour: () => () => {}, onToggleLeftSidebar: () => () => {}, onToggleRightSidebar: () => () => {}, @@ -3296,6 +3513,7 @@ describe('useIpcEvents browser tab close routing', () => { onActivateWorktree: () => () => {}, onCreateTerminal: () => () => {}, onRequestTerminalCreate: () => () => {}, + onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: () => {}, onSplitTerminal: () => () => {}, onRenameTerminal: () => () => {}, @@ -3498,6 +3716,7 @@ describe('useIpcEvents browser tab close routing', () => { ui: { onStateChanged: () => () => {}, onOpenSettings: () => () => {}, + consumePendingOpenSettings: () => Promise.resolve(false), onOpenFeatureTour: () => () => {}, onToggleLeftSidebar: () => () => {}, onToggleRightSidebar: () => () => {}, @@ -3513,6 +3732,7 @@ describe('useIpcEvents browser tab close routing', () => { onActivateWorktree: () => () => {}, onCreateTerminal: () => () => {}, onRequestTerminalCreate: () => () => {}, + onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: () => {}, onSplitTerminal: () => () => {}, onRenameTerminal: () => () => {}, @@ -3710,6 +3930,7 @@ describe('useIpcEvents browser tab close routing', () => { ui: { onStateChanged: () => () => {}, onOpenSettings: () => () => {}, + consumePendingOpenSettings: () => Promise.resolve(false), onOpenFeatureTour: () => () => {}, onToggleLeftSidebar: () => () => {}, onToggleRightSidebar: () => () => {}, @@ -3725,6 +3946,7 @@ describe('useIpcEvents browser tab close routing', () => { onActivateWorktree: () => () => {}, onCreateTerminal: () => () => {}, onRequestTerminalCreate: () => () => {}, + onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: () => {}, onSplitTerminal: () => () => {}, onRenameTerminal: () => () => {}, @@ -3940,6 +4162,7 @@ describe('useIpcEvents CLI-created worktree activation', () => { ui: { onStateChanged: () => () => {}, onOpenSettings: () => () => {}, + consumePendingOpenSettings: () => Promise.resolve(false), onOpenFeatureTour: () => () => {}, onToggleLeftSidebar: () => () => {}, onToggleRightSidebar: () => () => {}, @@ -3964,6 +4187,7 @@ describe('useIpcEvents CLI-created worktree activation', () => { }, onCreateTerminal: () => () => {}, onRequestTerminalCreate: () => () => {}, + onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: () => {}, onSplitTerminal: () => () => {}, onRenameTerminal: () => () => {}, @@ -4196,6 +4420,7 @@ describe('useIpcEvents CLI-created worktree activation', () => { ui: { onStateChanged: () => () => {}, onOpenSettings: () => () => {}, + consumePendingOpenSettings: () => Promise.resolve(false), onOpenFeatureTour: () => () => {}, onToggleLeftSidebar: () => () => {}, onToggleRightSidebar: () => () => {}, @@ -4211,6 +4436,7 @@ describe('useIpcEvents CLI-created worktree activation', () => { onActivateWorktree: () => () => {}, onCreateTerminal: () => () => {}, onRequestTerminalCreate: () => () => {}, + onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: () => {}, onSplitTerminal: () => () => {}, onRenameTerminal: () => () => {}, @@ -4429,6 +4655,7 @@ describe('useIpcEvents agent status snapshot integration', () => { ui: { onStateChanged: () => () => {}, onOpenSettings: () => () => {}, + consumePendingOpenSettings: () => Promise.resolve(false), onOpenFeatureTour: () => () => {}, onToggleLeftSidebar: () => () => {}, onToggleRightSidebar: () => () => {}, @@ -4444,6 +4671,7 @@ describe('useIpcEvents agent status snapshot integration', () => { onActivateWorktree: () => () => {}, onCreateTerminal: () => () => {}, onRequestTerminalCreate: () => () => {}, + onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: () => {}, onSplitTerminal: () => () => {}, onRenameTerminal: () => () => {}, diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index efec4bae01c..838cf50720e 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -14,6 +14,8 @@ import { createBackgroundSleepingAgentWakeDispatcher } from '@/lib/wake-sleeping import { OPEN_WORKSPACE_BOARD_EVENT } from '@/components/sidebar/useWorkspaceBoardPanel' import { SPLIT_TERMINAL_PANE_EVENT, CLOSE_TERMINAL_PANE_EVENT } from '@/constants/terminal' import { requestBackgroundTerminalWorktreeMount } from '@/components/terminal/background-terminal-worktree-mount' +import { planMobileTerminalTabMount } from '@/lib/mobile-terminal-tab-mount' +import { hasRegisteredRuntimeTerminalTab } from '@/runtime/sync-runtime-graph' import type { SplitTerminalPaneDetail, CloseTerminalPaneDetail } from '@/constants/terminal' import { getVisibleWorktreeIds } from '@/components/sidebar/visible-worktrees' import { activateTabNumberShortcut } from '@/lib/tab-number-shortcuts' @@ -96,6 +98,7 @@ import { collectLeafIdsInOrder } from '@/components/terminal-pane/layout-seriali import { track } from '@/lib/telemetry' import { singlePaneLayoutSnapshot } from '@/store/slices/terminal-helpers' import { buildWorkspaceSessionPayload } from '@/lib/workspace-session' +import { persistWorkspaceSessionByHost } from '@/lib/workspace-session-host-persistence' import { getLinearIssueWorkspaceName } from '../../../shared/workspace-name' import type { RuntimeClientEvent } from '../../../shared/runtime-client-events' import type { AppState } from '../store/types' @@ -1212,6 +1215,18 @@ export function useIpcEvents(): void { }) ) + // Why: a tray/menu-bar "Settings…" click can fire before this listener + // attaches on a fresh window; consume any intent queued for us. Guarded + // with `?.` so a stale preload bundle doesn't crash the listener set. + void window.api.ui + .consumePendingOpenSettings?.() + .then((open) => { + if (open) { + useAppStore.getState().openSettingsPage() + } + }) + .catch(() => {}) + unsubs.push( window.api.ui.onOpenSetupGuide?.(() => { useAppStore.getState().openModal('setup-guide', { telemetrySource: 'help_menu' }) @@ -1666,6 +1681,32 @@ export function useIpcEvents(): void { ) ) + // Why: background-mounting a mobile-subscribed tab attaches a PTY that this + // renderer never mounted, without navigating the desktop (STA-1840). + unsubs.push( + window.api.ui.onRequestTerminalTabMount(({ worktreeId, tabId, ptyId }) => { + if (!worktreeId) { + return + } + // Why: synthetic pty handles need persisted-tab resolution, but a miss + // must not mount every saved terminal in a large hidden worktree. + const mount = planMobileTerminalTabMount( + useAppStore.getState(), + { + worktreeId, + ...(tabId ? { tabId } : {}), + ...(ptyId ? { ptyId } : {}) + }, + { + isTabMounted: hasRegisteredRuntimeTerminalTab + } + ) + if (mount) { + requestBackgroundTerminalWorktreeMount(mount) + } + }) + ) + // Why: CLI-driven terminal creation sends a request and waits for the // tabId reply so it can resolve a handle the caller can use immediately. // This mirrors the browser's onRequestTabCreate/replyTabCreate pattern. @@ -1981,6 +2022,40 @@ export function useIpcEvents(): void { }) ) + // Why: during an in-place renderer reload, an older preload can briefly + // remain installed. Keep the new request listener additive at that seam. + if (window.api.ui.onTerminalTabCloseRequest) { + unsubs.push( + window.api.ui.onTerminalTabCloseRequest(({ requestId, tabId }) => { + let responded = false + const respond = (error?: string): void => { + if (responded) { + return + } + responded = true + window.api.ui.respondTerminalTabClose({ requestId, ...(error ? { error } : {}) }) + } + closeTerminalTab(tabId, { + rejectPinned: true, + onCancel: () => respond('terminal_tab_pinned'), + onClosed: () => { + void (async () => { + const state = useAppStore.getState() + await persistWorkspaceSessionByHost( + window.api.session, + buildWorkspaceSessionPayload(state), + state + ) + respond() + })().catch((error: unknown) => { + respond(error instanceof Error ? error.message : 'terminal_tab_close_failed') + }) + } + }) + }) + ) + } + unsubs.push( window.api.ui.onSleepWorktree(({ worktreeId }) => { void runSleepWorktree(worktreeId) @@ -2100,10 +2175,8 @@ export function useIpcEvents(): void { if (getRuntimeEnvironmentIdForWorktree(store, sourcePage.worktreeId)) { return } - // Why: the guest process can request "open this link in Orca", but it - // does not own Orca's worktree/tab model. Resolve the source page's - // worktree and create a new outer browser tab so the link opens as a - // separate tab in the outer Orca tab bar. + // Why: only the renderer owns Orca's tab model. Creating the tab with + // the default activation behavior brings the clicked link forward. store.createBrowserTab(sourcePage.worktreeId, url, { title: url }) }) ) diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts index e39c75db2bf..60ab54ac4c4 100644 --- a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts @@ -382,7 +382,8 @@ export function buildSettingsNavigationMetadata({ icon: Palette, searchEntries: getAppearancePaneSearchEntries({ showWarpImport: showDesktopOnlySettings, - showSystemTray: showDesktopOnlySettings && isWindows + showSystemTray: showDesktopOnlySettings && isWindows, + showMenuBarIcon: showDesktopOnlySettings && isMac }), group: 'interface' }, diff --git a/src/renderer/src/hooks/useSkillFreshness.test.tsx b/src/renderer/src/hooks/useSkillFreshness.test.tsx new file mode 100644 index 00000000000..812ad72ffe8 --- /dev/null +++ b/src/renderer/src/hooks/useSkillFreshness.test.tsx @@ -0,0 +1,238 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { SkillFreshnessInventory } from '../../../shared/skill-freshness' +import { + _skillFreshnessCacheForTests, + type SkillFreshnessState, + useSkillFreshness +} from './useSkillFreshness' + +function deferred(): { + promise: Promise + resolve: (value: T) => void + reject: (cause: unknown) => void +} { + let resolve!: (value: T) => void + let reject!: (cause: unknown) => void + const promise = new Promise((complete, fail) => { + resolve = complete + reject = fail + }) + return { promise, resolve, reject } +} + +function inventory(scannedAt: number, eligibleUpdateNames: string[] = []): SkillFreshnessInventory { + return { schemaVersion: 1, installations: [], eligibleUpdateNames, scannedAt } +} + +let root: Root | null = null +let container: HTMLDivElement | null = null +let state: SkillFreshnessState | null = null +const states = new Map() + +function Probe({ id = 'default' }: { id?: string }): null { + state = useSkillFreshness() + states.set(id, state) + return null +} + +describe('useSkillFreshness', () => { + beforeEach(() => { + _skillFreshnessCacheForTests.reset() + state = null + states.clear() + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(async () => { + vi.useRealTimers() + if (root) { + await act(async () => root?.unmount()) + } + root = null + container?.remove() + container = null + }) + + it('runs a follow-up scan when invalidated during an in-flight request', async () => { + const first = deferred() + const second = deferred() + const freshnessInventory = vi + .fn() + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise) + window.api = { skills: { freshnessInventory } } as never + + await act(async () => root?.render()) + expect(freshnessInventory).toHaveBeenCalledTimes(1) + + await act(async () => window.dispatchEvent(new Event('focus'))) + await act(async () => first.resolve(inventory(1))) + expect(freshnessInventory).toHaveBeenCalledTimes(2) + + await act(async () => second.resolve(inventory(2))) + expect(state?.inventory?.scannedAt).toBe(2) + }) + + it('skips focus rescans inside the cooldown but honors install-change events', async () => { + const first = deferred() + const second = deferred() + const freshnessInventory = vi + .fn() + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise) + window.api = { skills: { freshnessInventory } } as never + + await act(async () => root?.render()) + await act(async () => first.resolve(inventory(1))) + expect(freshnessInventory).toHaveBeenCalledTimes(1) + + await act(async () => window.dispatchEvent(new Event('focus'))) + expect(freshnessInventory).toHaveBeenCalledTimes(1) + + await act(async () => window.dispatchEvent(new Event('orca:installed-agent-skills-changed'))) + await act(async () => second.resolve(inventory(2))) + expect(freshnessInventory).toHaveBeenCalledTimes(2) + expect(state?.inventory?.scannedAt).toBe(2) + }) + + it('retracts stale update authority during the cooldown and runs one trailing focus scan', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-07-15T00:00:00Z')) + const second = deferred() + const freshnessInventory = vi + .fn() + .mockResolvedValueOnce(inventory(1, ['orca-cli'])) + .mockReturnValueOnce(second.promise) + window.api = { skills: { freshnessInventory } } as never + + await act(async () => root?.render()) + expect(state?.inventory?.eligibleUpdateNames).toEqual(['orca-cli']) + + await act(async () => window.dispatchEvent(new Event('focus'))) + expect(freshnessInventory).toHaveBeenCalledTimes(1) + expect(state?.inventory).toBeNull() + expect(state?.loading).toBe(true) + + await act(async () => vi.advanceTimersByTimeAsync(15_000)) + expect(freshnessInventory).toHaveBeenCalledTimes(2) + await act(async () => second.resolve(inventory(2))) + expect(state?.inventory?.scannedAt).toBe(2) + }) + + it('coalesces multiple consumers into one rescan per invalidation event', async () => { + const first = deferred() + const second = deferred() + const freshnessInventory = vi + .fn() + .mockReturnValueOnce(first.promise) + .mockReturnValue(second.promise) + window.api = { skills: { freshnessInventory } } as never + + await act(async () => + root?.render( + <> + + + + ) + ) + await act(async () => first.resolve(inventory(1))) + expect(freshnessInventory).toHaveBeenCalledTimes(1) + + await act(async () => window.dispatchEvent(new Event('orca:installed-agent-skills-changed'))) + await act(async () => second.resolve(inventory(2))) + expect(freshnessInventory).toHaveBeenCalledTimes(2) + }) + + it('publishes a manual refresh to every consumer', async () => { + const second = deferred() + const freshnessInventory = vi + .fn() + .mockResolvedValueOnce(inventory(1)) + .mockReturnValueOnce(second.promise) + window.api = { skills: { freshnessInventory } } as never + + await act(async () => + root?.render( + <> + + + + ) + ) + expect(states.get('one')?.inventory?.scannedAt).toBe(1) + expect(states.get('two')?.inventory?.scannedAt).toBe(1) + + let refresh: Promise | undefined + await act(async () => { + refresh = states.get('one')?.refresh() + await Promise.resolve() + }) + expect(states.get('one')?.inventory).toBeNull() + expect(states.get('two')?.inventory).toBeNull() + + await act(async () => second.resolve(inventory(2))) + await refresh + expect(states.get('one')?.inventory?.scannedAt).toBe(2) + expect(states.get('two')?.inventory?.scannedAt).toBe(2) + expect(freshnessInventory).toHaveBeenCalledTimes(2) + }) + + it('fails closed when an invalidation scan rejects', async () => { + const second = deferred() + const freshnessInventory = vi + .fn() + .mockResolvedValueOnce(inventory(1)) + .mockReturnValueOnce(second.promise) + window.api = { skills: { freshnessInventory } } as never + + await act(async () => root?.render()) + expect(state?.inventory?.scannedAt).toBe(1) + + await act(async () => window.dispatchEvent(new Event('orca:installed-agent-skills-changed'))) + expect(state?.inventory).toBeNull() + expect(state?.loading).toBe(true) + + await act(async () => second.reject(new Error('scan failed'))) + expect(state?.inventory).toBeNull() + expect(state?.loading).toBe(false) + expect(state?.error).toBe('scan failed') + }) + + it('installs one event-listener pair for multiple consumers and cleans it up', async () => { + const addEventListener = vi.spyOn(window, 'addEventListener') + const removeEventListener = vi.spyOn(window, 'removeEventListener') + window.api = { + skills: { freshnessInventory: vi.fn().mockResolvedValue(inventory(1)) } + } as never + + await act(async () => + root?.render( + <> + + + + ) + ) + + expect(addEventListener.mock.calls.filter(([name]) => name === 'focus')).toHaveLength(1) + expect( + addEventListener.mock.calls.filter(([name]) => name === 'orca:installed-agent-skills-changed') + ).toHaveLength(1) + + await act(async () => root?.unmount()) + root = null + expect(removeEventListener.mock.calls.filter(([name]) => name === 'focus')).toHaveLength(1) + expect( + removeEventListener.mock.calls.filter( + ([name]) => name === 'orca:installed-agent-skills-changed' + ) + ).toHaveLength(1) + }) +}) diff --git a/src/renderer/src/hooks/useSkillFreshness.ts b/src/renderer/src/hooks/useSkillFreshness.ts new file mode 100644 index 00000000000..91ea0c4bdc2 --- /dev/null +++ b/src/renderer/src/hooks/useSkillFreshness.ts @@ -0,0 +1,178 @@ +import { useEffect, useSyncExternalStore } from 'react' +import type { SkillFreshnessInventory } from '../../../shared/skill-freshness' +import { INSTALLED_AGENT_SKILLS_CHANGED_EVENT } from './installed-agent-skills-change-event' + +// Why: window focus fires on every alt-tab, and each scan re-reads and re-hashes +// every installed package; a just-completed scan stays authoritative briefly. +const FOCUS_RESCAN_COOLDOWN_MS = 15_000 +let cachedInventory: SkillFreshnessInventory | null = null +let pendingInventory: Promise | null = null +let invalidationRevision = 0 +let completedRevision = -1 +let lastCompletedScanAt = 0 +let refreshSequence = 0 +let scheduledFocusRescan: number | null = null + +type SkillFreshnessSnapshot = { + inventory: SkillFreshnessInventory | null + loading: boolean + error: string | null +} + +let snapshot: SkillFreshnessSnapshot = { + inventory: null, + loading: false, + error: null +} +const subscribers = new Set<() => void>() + +function publishSnapshot(next: SkillFreshnessSnapshot): void { + if ( + snapshot.inventory === next.inventory && + snapshot.loading === next.loading && + snapshot.error === next.error + ) { + return + } + snapshot = next + for (const subscriber of subscribers) { + subscriber() + } +} + +async function loadInventory(force: boolean): Promise { + if (force) { + invalidationRevision += 1 + } + const targetRevision = invalidationRevision + for (;;) { + if (cachedInventory && completedRevision >= targetRevision) { + return cachedInventory + } + if (!pendingInventory) { + const requestRevision = invalidationRevision + const request = window.api.skills + .freshnessInventory() + .then((inventory) => { + cachedInventory = inventory + completedRevision = Math.max(completedRevision, requestRevision) + lastCompletedScanAt = Date.now() + return inventory + }) + .finally(() => { + if (pendingInventory === request) { + pendingInventory = null + } + }) + pendingInventory = request + } + await pendingInventory + } +} + +async function refreshSkillFreshness(force = true): Promise { + if (scheduledFocusRescan !== null) { + window.clearTimeout(scheduledFocusRescan) + scheduledFocusRescan = null + } + const sequence = ++refreshSequence + // Why: eligibility is write authority for the draft command. Once invalidated, + // stale bytes must stop authorizing UI even if the replacement scan fails. + publishSnapshot({ inventory: null, loading: true, error: null }) + try { + const inventory = await loadInventory(force) + if (sequence === refreshSequence) { + publishSnapshot({ inventory, loading: false, error: null }) + } + } catch (cause) { + if (sequence === refreshSequence) { + publishSnapshot({ + inventory: null, + loading: false, + error: cause instanceof Error ? cause.message : 'Could not inspect Orca skills.' + }) + } + } +} + +function onWindowFocus(): void { + const cooldownRemaining = FOCUS_RESCAN_COOLDOWN_MS - (Date.now() - lastCompletedScanAt) + if (cooldownRemaining <= 0) { + void refreshSkillFreshness(true) + return + } + if (!snapshot.inventory?.eligibleUpdateNames.length || scheduledFocusRescan !== null) { + return + } + // Why: a focus event can follow an external edit. Retract stale update + // authority immediately, but keep rapid alt-tabs to one trailing disk scan. + publishSnapshot({ inventory: null, loading: true, error: null }) + scheduledFocusRescan = window.setTimeout( + () => { + scheduledFocusRescan = null + void refreshSkillFreshness(true) + }, + Math.min(cooldownRemaining, FOCUS_RESCAN_COOLDOWN_MS) + ) +} + +function onInstalledSkillsChanged(): void { + void refreshSkillFreshness(true) +} + +function subscribe(subscriber: () => void): () => void { + subscribers.add(subscriber) + if (subscribers.size === 1) { + // Why: every consumer reads one external snapshot, so focus/install events + // install one listener and trigger one shared IPC scan regardless of UI count. + window.addEventListener('focus', onWindowFocus) + window.addEventListener(INSTALLED_AGENT_SKILLS_CHANGED_EVENT, onInstalledSkillsChanged) + } + return () => { + subscribers.delete(subscriber) + if (subscribers.size === 0) { + window.removeEventListener('focus', onWindowFocus) + window.removeEventListener(INSTALLED_AGENT_SKILLS_CHANGED_EVENT, onInstalledSkillsChanged) + } + } +} + +function getSnapshot(): SkillFreshnessSnapshot { + return snapshot +} + +function ensureInventoryLoaded(): void { + if (!snapshot.inventory && !snapshot.loading) { + void refreshSkillFreshness(false) + } +} + +export type SkillFreshnessState = SkillFreshnessSnapshot & { + refresh: () => Promise +} + +export function useSkillFreshness(): SkillFreshnessState { + const current = useSyncExternalStore(subscribe, getSnapshot, getSnapshot) + + useEffect(() => { + ensureInventoryLoaded() + }, []) + + return { ...current, refresh: refreshSkillFreshness } +} + +export const _skillFreshnessCacheForTests = { + reset(): void { + cachedInventory = null + pendingInventory = null + invalidationRevision = 0 + completedRevision = -1 + lastCompletedScanAt = 0 + refreshSequence = 0 + if (scheduledFocusRescan !== null) { + window.clearTimeout(scheduledFocusRescan) + scheduledFocusRescan = null + } + snapshot = { inventory: null, loading: false, error: null } + } +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 997b1ca6c81..f275b3923b6 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -31,6 +31,10 @@ "portsToggleDescription": "Show live workspace ports. Click it for workspace-scoped ports and external listeners.", "antigravityToggleDescription": "Show Antigravity subscription usage for the active workspace.", "grokToggleDescription": "Show Grok subscription credit usage when signed in via Grok CLI." + }, + "menuBarIcon": { + "title": "Show Menu Bar Icon", + "description": "Keep an Orca shortcut and activity indicator in the macOS menu bar." } } }, @@ -67,7 +71,8 @@ "quit": "Quit", "minimizeNotice": { "body": "Orca is still running in the system tray" - } + }, + "activityWaiting": "Orca - activity waiting" }, "worktreeJumpPalette": { "matchLabel": { @@ -3219,7 +3224,11 @@ "42fdd4da1d": "Claude sign-in is being refreshed. Agent sessions may still be signed in.", "c06c1d215d": "Claude usage could not be refreshed because the network request failed.", "cabdc2a9e0": "Claude sign-in credentials could not be read.", - "a7517cccb6": "Claude usage is unavailable right now." + "a7517cccb6": "Claude usage is unavailable right now.", + "e2c6a4f917": "Run Grok to refresh", + "d1b7f509ac": "Run grok in a terminal on the computer running Orca and wait for it to start. If prompted, complete sign-in, then retry usage. You do not need to send a chat message.", + "f90b3d7a16": "Run Kimi to refresh", + "a37e8c15d4": "Run kimi in a terminal on the computer running Orca and wait for it to start, then retry usage." }, "SshTargetStatusRow": { "sshHost": "SSH Host" @@ -3596,6 +3605,64 @@ "cd7893fbc1": "Scanning skills", "35b9a724a0": "Available", "0c74e7ff34": "Local" + }, + "SkillFreshnessNudge": { + "titleOne": "An installed Orca skill is out of date", + "titleMany": "{{value0}} installed Orca skills are out of date", + "description": "Update {{value0}} so agents follow the current instructions for this version of Orca.", + "updateOne": "Update skill", + "updateMany": "Update skills" + }, + "SkillFreshnessRow": { + "statusUpdateAvailable": "Update available", + "statusCantUpdate": "Skipped", + "cantUpdateReason": "Orca left this skill out of the update command.", + "skippedReasonUnrecognized": "The copy here doesn’t match the official version — it may be modified, or a different skill with the same name. Orca left it out of the update so it won’t overwrite it. Remove it if you want Orca to update this skill.", + "skippedReasonReadOnly": "This copy is in a read-only location, so Orca left it out of the update. Change its permissions to let Orca update it.", + "skippedReasonInaccessible": "Orca couldn’t read this copy, so it left the skill out of the update.", + "skippedReasonInRepo": "This is a project skill, not a global one — Orca only updates your global skills, so it left this out of the update.", + "skippedReasonPluginCache": "A plugin manages this skill, so Orca left it out of the update — update the plugin instead.", + "skippedReasonExternalLink": "This copy is a shortcut pointing outside Orca’s skill folders, so Orca left it out of the update.", + "skippedReasonBrokenLink": "This copy is a shortcut to something that no longer exists, so Orca left it out — you can safely delete it.", + "chipCurrent": "Current", + "chipUnrecognized": "Unrecognized", + "chipInaccessible": "Inaccessible", + "chipDuplicate": "Duplicate", + "chipExternalLink": "External link", + "chipBrokenLink": "Broken link", + "chipReadOnly": "Read only", + "chipInRepo": "In a repo", + "chipPluginCache": "Plugin cache", + "tipCurrent": "This copy matches the current official version.", + "tipUnrecognized": "This copy doesn’t match any official version — it may be modified, or a different skill with the same name.", + "tipInaccessible": "Orca couldn’t read this copy (a permissions or file error).", + "tipDuplicate": "A separate copy of this skill, installed apart from the main one.", + "tipExternalLink": "A shortcut pointing outside Orca’s skill folders.", + "tipBrokenLink": "A shortcut to something that no longer exists.", + "tipReadOnly": "This copy is in a read-only location.", + "tipInRepo": "This copy lives inside a project, not your global skills.", + "tipPluginCache": "This copy is managed by a plugin." + }, + "SkillFreshnessUpdateDialog": { + "title": "Update skills", + "checking": "Checking installed Orca skills…", + "none": "No installed Orca skills found.", + "updateOne": "1 skill can be updated safely", + "updateMany": "{{value0}} skills can be updated safely", + "success": "All installed Orca skills are up to date.", + "attention": "Some installed Orca skills were left out of the update.", + "attentionDescription": "Open Update details to see why each one was skipped.", + "details": "Update details", + "terminalTitle": "Update Orca skills", + "terminalDescription": "Review the pre-filled command, then press Enter to run it.", + "terminalAria": "Orca skill update terminal", + "checkNow": "Re-check", + "close": "Close" + }, + "SkillFreshnessStatusPill": { + "updateAvailable": "Update available", + "upToDate": "Up to date", + "installed": "Installed" } }, "sidebar": { @@ -8678,7 +8745,9 @@ "fe9231215b": "Checking Linear access before showing setup actions.", "e1f5e6424c": "{{value0}} workspace{{value1}} connected", "disconnect_all": "Disconnect", - "account_scope_prefix": "Account scope" + "account_scope_prefix": "Account scope", + "2d60ec7921": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are sent to the selected remote runtime and stored there with runtime-supported encryption.", + "977e360b71": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are stored locally and encrypted when local runtime storage supports it." } } } @@ -9030,7 +9099,9 @@ "b7e2d9f0a3": "Same weekly credit % as the grok /usage screen in the terminal.", "c6d1a8f4e2": "Resets {{when}}", "e6dadc1e2b": "Monthly usage", - "75e396bf42": "Included monthly usage for Grok unified-billing accounts." + "75e396bf42": "Included monthly usage for Grok unified-billing accounts.", + "b36fa2c908": "Signed in. Orca reads the Grok CLI session stored on disk.", + "f08c41de73": "Session expired — run grok on the computer running Orca and wait for it to start. If prompted, complete sign-in, then click Refresh usage. No chat message is needed." }, "AppearanceWindowSidebarSection": { "usagePercentageDisplayUsed": "Used", @@ -11845,18 +11916,18 @@ "45cf7ceb3f": "Medium section heading.", "c209a116b7": "Heading 2", "3294a2c0cc": "Create a collapsible section with a large heading summary.", - "41482b15ce": "Toggle Heading 1", + "41482b15ce": "Toggle H1", "570611864e": "Large section heading.", "e66e7f04c6": "Heading 1", "5f9a0ed7c4": "Heading 4", "01a71dbbdd": "Nested section heading.", "8440fa4acf": "Heading 5", "b287b93c66": "Deep section heading.", - "7a2c1f9b04": "Toggle Heading 2", + "7a2c1f9b04": "Toggle H2", "b3e5d8a1c6": "Create a collapsible section with a medium heading summary.", - "2f9d6b4e10": "Toggle Heading 3", + "2f9d6b4e10": "Toggle H3", "8c1a3e7d52": "Create a collapsible section with a small heading summary.", - "5e0b9c2a71": "Toggle Heading 4", + "5e0b9c2a71": "Toggle H4", "d4f16a8b39": "Create a collapsible section with a nested heading summary." } } @@ -12692,7 +12763,24 @@ "70fcd360c4": "https://example.atlassian.net", "e176f9d0c5": "Jira Cloud site URL", "d785c42b8b": "Use a Jira Cloud site URL, Atlassian email, and API token to browse issues.", - "8388bdea2b": "Connect Jira site" + "8388bdea2b": "Connect Jira site", + "2e2b69e48e": "Use a self-hosted Jira base URL and a personal access token to browse issues.", + "b67e919bd5": "Jira instance type", + "17787d6e4b": "Atlassian Cloud", + "bc7a831773": "Self-hosted", + "3489e186d6": "Jira site URL", + "cbc27fa599": "https://jira.example.com", + "730d973bae": "Personal access token", + "8b9c7b9e7b": "Jira personal access token", + "ccfb086d3e": "Create a personal access token in your Jira profile under Personal Access Tokens.", + "1d947a07ab": "Use a self-hosted Jira base URL, username, and password to browse issues.", + "f49708c369": "Jira authentication method", + "84a810dd0e": "Username & password", + "8d1223fa5c": "Username", + "be9eba0a1b": "username", + "70035652d7": "Password", + "c50abbf340": "Jira account password", + "d8737db691": "Use your Jira Server or Data Center account username and password." } } }, @@ -13119,6 +13207,7 @@ "title": "Could not load conversation", "subtitle": "The transcript could not be read. Toggle back to the terminal to keep working." }, + "pairHost": "Pair a host to view agent chat history.", "notAgent": { "title": "No conversation here", "subtitle": "This terminal is not running a recognized coding agent." diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 6c4707b5349..9261f9971d0 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -31,6 +31,10 @@ "portsToggleDescription": "Muestra los puertos activos de los espacios de trabajo. Haz clic para ver los puertos de cada espacio de trabajo y los puertos externos.", "antigravityToggleDescription": "Muestra el uso de suscripción de Antigravity para el espacio de trabajo activo.", "grokToggleDescription": "Muestra el uso de créditos de suscripción de Grok cuando has iniciado sesión con Grok CLI." + }, + "menuBarIcon": { + "title": "Mostrar icono en la barra de menús", + "description": "Mantén un acceso directo de Orca e indicador de actividad en la barra de menús de macOS." } } }, @@ -67,7 +71,8 @@ "quit": "Salir", "minimizeNotice": { "body": "Orca sigue ejecutándose en la bandeja del sistema" - } + }, + "activityWaiting": "Orca: actividad en espera" }, "worktreeJumpPalette": { "matchLabel": { @@ -3219,7 +3224,11 @@ "42fdd4da1d": "Se está actualizando el inicio de sesión de Claude. Las sesiones de agentes pueden seguir iniciadas.", "c06c1d215d": "No se pudo actualizar el uso de Claude porque falló la solicitud de red.", "cabdc2a9e0": "No se pudieron leer las credenciales de inicio de sesión de Claude.", - "a7517cccb6": "El uso de Claude no está disponible ahora." + "a7517cccb6": "El uso de Claude no está disponible ahora.", + "e2c6a4f917": "Run Grok to refresh", + "d1b7f509ac": "Run grok in a terminal on the computer running Orca and wait for it to start. If prompted, complete sign-in, then retry usage. You do not need to send a chat message.", + "f90b3d7a16": "Run Kimi to refresh", + "a37e8c15d4": "Run kimi in a terminal on the computer running Orca and wait for it to start, then retry usage." }, "SshTargetStatusRow": { "sshHost": "Host SSH" @@ -3596,6 +3605,64 @@ "cd7893fbc1": "Escaneando skills", "35b9a724a0": "Disponible", "0c74e7ff34": "Local" + }, + "SkillFreshnessNudge": { + "titleOne": "Una skill de Orca instalada está desactualizada", + "titleMany": "{{value0}} skills de Orca instaladas están desactualizadas", + "updateOne": "Actualizar skill", + "updateMany": "Actualizar skills", + "description": "Actualiza {{value0}} para que los Agents sigan las instrucciones actuales de esta versión de Orca." + }, + "SkillFreshnessRow": { + "statusUpdateAvailable": "Actualización disponible", + "statusCantUpdate": "No se puede actualizar", + "cantUpdateReason": "Esta skill está instalada en un lugar que Orca no puede actualizar de forma segura, así que el comando npx skills update la deja sin cambios.", + "chipCurrent": "Actual", + "chipUnrecognized": "No reconocida", + "chipInaccessible": "Inaccesible", + "chipDuplicate": "Duplicada", + "chipExternalLink": "Enlace externo", + "chipBrokenLink": "Enlace roto", + "chipReadOnly": "Solo lectura", + "chipInRepo": "En un repo", + "chipPluginCache": "Caché de plugin", + "tipCurrent": "La skill aquí ya está actualizada; la actualización no la cambiará.", + "tipUnrecognized": "El contenido de la skill aquí no coincide con ninguna versión oficial, así que Orca no puede actualizarla de forma segura. Elimina o reemplaza lo que hay aquí para permitir las actualizaciones.", + "tipInaccessible": "Orca no pudo leer la skill aquí (un error de permisos o de archivo), así que no puede comprobarla ni actualizarla.", + "tipDuplicate": "La skill también está instalada aquí, aparte de la principal, así que el comando npx skills update no puede alcanzarla. Elimínala para permitir las actualizaciones.", + "tipExternalLink": "Es un acceso directo que apunta fuera de las carpetas de skills de Orca; la actualización no lo seguirá.", + "tipBrokenLink": "Es un acceso directo a algo que ya no existe; puedes eliminarlo sin problema.", + "tipReadOnly": "La skill aquí está en una ubicación de solo lectura, así que no se puede actualizar hasta que cambies sus permisos.", + "tipInRepo": "La skill aquí vive dentro de un proyecto, no en tus skills globales; Orca solo actualiza las globales.", + "tipPluginCache": "La skill aquí la gestiona un plugin; actualiza el plugin en su lugar.", + "skippedReasonUnrecognized": "The copy here doesn’t match the official version — it may be modified, or a different skill with the same name. Orca left it out of the update so it won’t overwrite it. Remove it if you want Orca to update this skill.", + "skippedReasonReadOnly": "This copy is in a read-only location, so Orca left it out of the update. Change its permissions to let Orca update it.", + "skippedReasonInaccessible": "Orca couldn’t read this copy, so it left the skill out of the update.", + "skippedReasonInRepo": "This is a project skill, not a global one — Orca only updates your global skills, so it left this out of the update.", + "skippedReasonPluginCache": "A plugin manages this skill, so Orca left it out of the update — update the plugin instead.", + "skippedReasonExternalLink": "This copy is a shortcut pointing outside Orca’s skill folders, so Orca left it out of the update.", + "skippedReasonBrokenLink": "This copy is a shortcut to something that no longer exists, so Orca left it out — you can safely delete it." + }, + "SkillFreshnessUpdateDialog": { + "title": "Actualizar skills", + "checking": "Comprobando las skills de Orca instaladas…", + "none": "No se encontraron skills de Orca instaladas.", + "updateOne": "Se puede actualizar 1 skill de forma segura", + "updateMany": "Se pueden actualizar {{value0}} skills de forma segura", + "success": "Todas las skills de Orca instaladas están actualizadas.", + "attention": "Algunas skills de Orca instaladas no se pueden actualizar automáticamente.", + "attentionDescription": "Abre Detalles de la actualización para ver por qué no se puede actualizar cada una.", + "details": "Detalles de la actualización", + "terminalTitle": "Actualizar skills de Orca", + "terminalDescription": "Revisa el comando pre-rellenado y pulsa Intro para ejecutarlo.", + "terminalAria": "Terminal de actualización de skills de Orca", + "checkNow": "Comprobar ahora", + "close": "Cerrar" + }, + "SkillFreshnessStatusPill": { + "updateAvailable": "Actualización disponible", + "upToDate": "Actualizado", + "installed": "Instalado" } }, "sidebar": { @@ -8670,7 +8737,9 @@ "fe9231215b": "Comprobando el acceso a Linear antes de mostrar las acciones de configuración.", "e1f5e6424c": "Conexión completada para {{value0}} workspace{{value1}}", "disconnect_all": "Desconectar todo", - "account_scope_prefix": "Alcance de la cuenta" + "account_scope_prefix": "Alcance de la cuenta", + "2d60ec7921": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are sent to the selected remote runtime and stored there with runtime-supported encryption.", + "977e360b71": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are stored locally and encrypted when local runtime storage supports it." } } } @@ -9030,7 +9099,9 @@ "b7e2d9f0a3": "El mismo porcentaje de créditos semanales que la pantalla grok /usage en la terminal.", "c6d1a8f4e2": "Se restablece {{when}}", "e6dadc1e2b": "Monthly usage", - "75e396bf42": "Included monthly usage for Grok unified-billing accounts." + "75e396bf42": "Included monthly usage for Grok unified-billing accounts.", + "b36fa2c908": "Signed in. Orca reads the Grok CLI session stored on disk.", + "f08c41de73": "Session expired — run grok on the computer running Orca and wait for it to start. If prompted, complete sign-in, then click Refresh usage. No chat message is needed." }, "AppearanceWindowSidebarSection": { "usagePercentageDisplayUsed": "Usado", @@ -12692,7 +12763,24 @@ "70fcd360c4": "https://example.atlassian.net", "e176f9d0c5": "URL del sitio de Jira Cloud", "d785c42b8b": "Usa una URL de Jira Cloud, email de Atlassian y token API para explorar issues.", - "8388bdea2b": "Conectar sitio de Jira" + "8388bdea2b": "Conectar sitio de Jira", + "2e2b69e48e": "Use a self-hosted Jira base URL and a personal access token to browse issues.", + "b67e919bd5": "Jira instance type", + "17787d6e4b": "Atlassian Cloud", + "bc7a831773": "Self-hosted", + "3489e186d6": "Jira site URL", + "cbc27fa599": "https://jira.example.com", + "730d973bae": "Personal access token", + "8b9c7b9e7b": "Jira personal access token", + "ccfb086d3e": "Create a personal access token in your Jira profile under Personal Access Tokens.", + "1d947a07ab": "Use a self-hosted Jira base URL, username, and password to browse issues.", + "f49708c369": "Jira authentication method", + "84a810dd0e": "Username & password", + "8d1223fa5c": "Username", + "be9eba0a1b": "username", + "70035652d7": "Password", + "c50abbf340": "Jira account password", + "d8737db691": "Use your Jira Server or Data Center account username and password." } } }, @@ -13119,6 +13207,7 @@ "title": "No se pudo cargar la conversación", "subtitle": "No se pudo leer la transcripción. Vuelva a la terminal para seguir trabajando." }, + "pairHost": "Empareje un host para ver el historial de chat del agente.", "notAgent": { "title": "No hay conversación aquí", "subtitle": "Este terminal no está ejecutando un agente de código reconocido." diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index a9c01fdbe14..e1cf3313cdc 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -31,6 +31,10 @@ "portsToggleDescription": "ライブワークスペースポートを表示します。ワークスペーススコープのポートと外部リスナーの場合はこれをクリックします。", "antigravityToggleDescription": "アクティブなワークスペースの Antigravity サブスクリプション使用量を表示します。", "grokToggleDescription": "Grok CLI でサインインしている場合に Grok サブスクリプションのクレジット使用量を表示します。" + }, + "menuBarIcon": { + "title": "メニューバーアイコンを表示", + "description": "macOS メニューバーに Orca のショートカットとアクティビティインジケーターを表示します。" } } }, @@ -67,7 +71,8 @@ "quit": "終了", "minimizeNotice": { "body": "Orcaはシステムトレイで実行中です" - } + }, + "activityWaiting": "Orca - アクティビティ待機中" }, "worktreeJumpPalette": { "matchLabel": { @@ -3219,7 +3224,11 @@ "42fdd4da1d": "Claude サインインを更新中です。エージェントセッションはまだサインイン済みの場合があります。", "c06c1d215d": "ネットワークリクエストに失敗したため、Claude 使用量を更新できませんでした。", "cabdc2a9e0": "Claude サインイン認証情報を読み取れませんでした。", - "a7517cccb6": "Claude 使用量は現在利用できません。" + "a7517cccb6": "Claude 使用量は現在利用できません。", + "e2c6a4f917": "Run Grok to refresh", + "d1b7f509ac": "Run grok in a terminal on the computer running Orca and wait for it to start. If prompted, complete sign-in, then retry usage. You do not need to send a chat message.", + "f90b3d7a16": "Run Kimi to refresh", + "a37e8c15d4": "Run kimi in a terminal on the computer running Orca and wait for it to start, then retry usage." }, "SshTargetStatusRow": { "sshHost": "SSH ホスト" @@ -3596,6 +3605,64 @@ "cd7893fbc1": "スキャンスキル", "35b9a724a0": "利用可能", "0c74e7ff34": "ローカル" + }, + "SkillFreshnessNudge": { + "titleOne": "インストール済みの Orca スキルが古くなっています", + "titleMany": "インストール済みの Orca スキル {{value0}} 件が古くなっています", + "updateOne": "スキルを更新", + "updateMany": "スキルを更新", + "description": "Agents がこのバージョンの Orca の最新の手順に従うように、{{value0}} を更新してください。" + }, + "SkillFreshnessRow": { + "statusUpdateAvailable": "更新あり", + "statusCantUpdate": "更新できません", + "cantUpdateReason": "このスキルは Orca が安全に更新できない場所にインストールされているため、npx skills update コマンドは変更しません。", + "chipCurrent": "最新", + "chipUnrecognized": "未認識", + "chipInaccessible": "アクセス不可", + "chipDuplicate": "重複", + "chipExternalLink": "外部リンク", + "chipBrokenLink": "リンク切れ", + "chipReadOnly": "読み取り専用", + "chipInRepo": "リポジトリ内", + "chipPluginCache": "プラグインキャッシュ", + "tipCurrent": "ここのスキルはすでに最新です。更新しても変更されません。", + "tipUnrecognized": "ここのスキルの内容が公式バージョンのいずれとも一致しないため、Orca は安全に更新できません。更新を許可するには、ここにあるものを削除または置き換えてください。", + "tipInaccessible": "Orca はここのスキルを読み取れなかったため(権限またはファイルのエラー)、確認も更新もできません。", + "tipDuplicate": "このスキルはメインとは別に、ここにもインストールされています。そのため npx skills update コマンドは到達できません。更新を許可するには削除してください。", + "tipExternalLink": "これは Orca のスキルフォルダーの外を指すショートカットです。更新では追跡されません。", + "tipBrokenLink": "これは存在しないものを指すショートカットです。削除しても問題ありません。", + "tipReadOnly": "ここのスキルは読み取り専用の場所にあるため、権限を変更するまで更新できません。", + "tipInRepo": "ここのスキルはグローバルスキルではなくプロジェクト内にあります。Orca はグローバルなものだけを更新します。", + "tipPluginCache": "ここのスキルはプラグインによって管理されています。代わりにプラグインを更新してください。", + "skippedReasonUnrecognized": "The copy here doesn’t match the official version — it may be modified, or a different skill with the same name. Orca left it out of the update so it won’t overwrite it. Remove it if you want Orca to update this skill.", + "skippedReasonReadOnly": "This copy is in a read-only location, so Orca left it out of the update. Change its permissions to let Orca update it.", + "skippedReasonInaccessible": "Orca couldn’t read this copy, so it left the skill out of the update.", + "skippedReasonInRepo": "This is a project skill, not a global one — Orca only updates your global skills, so it left this out of the update.", + "skippedReasonPluginCache": "A plugin manages this skill, so Orca left it out of the update — update the plugin instead.", + "skippedReasonExternalLink": "This copy is a shortcut pointing outside Orca’s skill folders, so Orca left it out of the update.", + "skippedReasonBrokenLink": "This copy is a shortcut to something that no longer exists, so Orca left it out — you can safely delete it." + }, + "SkillFreshnessUpdateDialog": { + "title": "スキルを更新", + "checking": "インストール済みの Orca スキルを確認中…", + "none": "インストール済みの Orca スキルは見つかりませんでした。", + "updateOne": "1 件のスキルを安全に更新できます", + "updateMany": "{{value0}} 件のスキルを安全に更新できます", + "success": "インストール済みの Orca スキルはすべて最新です。", + "attention": "一部のインストール済み Orca スキルは自動的に更新できません。", + "attentionDescription": "更新できない理由は、更新の詳細を開いて確認してください。", + "details": "更新の詳細", + "terminalTitle": "Orca スキルを更新", + "terminalDescription": "入力済みのコマンドを確認し、Enter キーを押して実行してください。", + "terminalAria": "Orca スキル更新ターミナル", + "checkNow": "今すぐ確認", + "close": "閉じる" + }, + "SkillFreshnessStatusPill": { + "updateAvailable": "更新があります", + "upToDate": "最新です", + "installed": "インストール済み" } }, "sidebar": { @@ -8670,7 +8737,9 @@ "fe9231215b": "Checking Linear access before showing setup actions.", "e1f5e6424c": "{{value0}} workspace{{value1}} connected", "disconnect_all": "切断", - "account_scope_prefix": "Account scope" + "account_scope_prefix": "Account scope", + "2d60ec7921": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are sent to the selected remote runtime and stored there with runtime-supported encryption.", + "977e360b71": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are stored locally and encrypted when local runtime storage supports it." } } } @@ -9030,7 +9099,9 @@ "b7e2d9f0a3": "ターミナルの grok /usage 画面と同じ週次クレジット率です。", "c6d1a8f4e2": "{{when}} にリセット", "e6dadc1e2b": "Monthly usage", - "75e396bf42": "Included monthly usage for Grok unified-billing accounts." + "75e396bf42": "Included monthly usage for Grok unified-billing accounts.", + "b36fa2c908": "Signed in. Orca reads the Grok CLI session stored on disk.", + "f08c41de73": "Session expired — run grok on the computer running Orca and wait for it to start. If prompted, complete sign-in, then click Refresh usage. No chat message is needed." }, "AppearanceWindowSidebarSection": { "usagePercentageDisplayUsed": "使用済み", @@ -12692,7 +12763,24 @@ "70fcd360c4": "https://example.atlassian.net", "e176f9d0c5": "Jira Cloud site URL", "d785c42b8b": "Use a Jira Cloud site URL, Atlassian email, and API token to browse issues.", - "8388bdea2b": "Connect Jira site" + "8388bdea2b": "Connect Jira site", + "2e2b69e48e": "Use a self-hosted Jira base URL and a personal access token to browse issues.", + "b67e919bd5": "Jira instance type", + "17787d6e4b": "Atlassian Cloud", + "bc7a831773": "Self-hosted", + "3489e186d6": "Jira site URL", + "cbc27fa599": "https://jira.example.com", + "730d973bae": "Personal access token", + "8b9c7b9e7b": "Jira personal access token", + "ccfb086d3e": "Create a personal access token in your Jira profile under Personal Access Tokens.", + "1d947a07ab": "Use a self-hosted Jira base URL, username, and password to browse issues.", + "f49708c369": "Jira authentication method", + "84a810dd0e": "Username & password", + "8d1223fa5c": "Username", + "be9eba0a1b": "username", + "70035652d7": "Password", + "c50abbf340": "Jira account password", + "d8737db691": "Use your Jira Server or Data Center account username and password." } } }, @@ -13119,6 +13207,7 @@ "title": "会話を読み込めませんでした", "subtitle": "トランスクリプトを読み取ることができませんでした。作業を続けるには、terminal に切り替えてください。" }, + "pairHost": "ホストをペアリングして、エージェントのチャット履歴を表示します。", "notAgent": { "title": "ここでは会話はありません", "subtitle": "この terminal は認識されたコーディング agent を実行していません。" diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index cb10041dfaa..d22ad9a5db8 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -31,6 +31,10 @@ "portsToggleDescription": "라이브 워크스페이스 포트를 표시합니다. 워크스페이스 범위 포트 및 외부 수신기를 보려면 클릭하세요.", "antigravityToggleDescription": "활성 워크스페이스에 대한 Antigravity 구독 사용량을 표시합니다.", "grokToggleDescription": "Grok CLI로 로그인한 경우 Grok 구독 크레딧 사용량을 표시합니다." + }, + "menuBarIcon": { + "title": "메뉴 바 아이콘 표시", + "description": "macOS 메뉴 바에 Orca 바로가기 및 활동 표시기를 유지합니다." } } }, @@ -67,7 +71,8 @@ "quit": "종료", "minimizeNotice": { "body": "Orca가 시스템 트레이에서 계속 실행 중입니다" - } + }, + "activityWaiting": "Orca - 활동 대기 중" }, "worktreeJumpPalette": { "matchLabel": { @@ -3219,7 +3224,11 @@ "42fdd4da1d": "Claude 로그인을 새로 고치는 중입니다. 에이전트 세션은 여전히 로그인되어 있을 수 있습니다.", "c06c1d215d": "네트워크 요청 실패로 Claude 사용량을 새로 고칠 수 없습니다.", "cabdc2a9e0": "Claude 로그인 자격 증명을 읽을 수 없습니다.", - "a7517cccb6": "현재 Claude 사용량을 사용할 수 없습니다." + "a7517cccb6": "현재 Claude 사용량을 사용할 수 없습니다.", + "e2c6a4f917": "Run Grok to refresh", + "d1b7f509ac": "Run grok in a terminal on the computer running Orca and wait for it to start. If prompted, complete sign-in, then retry usage. You do not need to send a chat message.", + "f90b3d7a16": "Run Kimi to refresh", + "a37e8c15d4": "Run kimi in a terminal on the computer running Orca and wait for it to start, then retry usage." }, "SshTargetStatusRow": { "sshHost": "SSH 호스트" @@ -3596,6 +3605,64 @@ "cd7893fbc1": "스킬 스캔 중", "35b9a724a0": "사용 가능", "0c74e7ff34": "로컬" + }, + "SkillFreshnessNudge": { + "titleOne": "설치된 Orca 스킬이 오래되었습니다", + "titleMany": "설치된 Orca 스킬 {{value0}}개가 오래되었습니다", + "updateOne": "스킬 업데이트", + "updateMany": "스킬 업데이트", + "description": "Agents가 이 버전의 Orca에 대한 최신 지침을 따르도록 {{value0}}을(를) 업데이트하세요." + }, + "SkillFreshnessRow": { + "statusUpdateAvailable": "업데이트 있음", + "statusCantUpdate": "업데이트할 수 없음", + "cantUpdateReason": "이 스킬은 Orca가 안전하게 업데이트할 수 없는 위치에 설치되어 있어, npx skills update 명령이 변경하지 않습니다.", + "chipCurrent": "최신", + "chipUnrecognized": "인식 안 됨", + "chipInaccessible": "액세스 불가", + "chipDuplicate": "중복", + "chipExternalLink": "외부 링크", + "chipBrokenLink": "깨진 링크", + "chipReadOnly": "읽기 전용", + "chipInRepo": "저장소 내", + "chipPluginCache": "플러그인 캐시", + "tipCurrent": "여기 있는 스킬은 이미 최신이므로 업데이트해도 변경되지 않습니다.", + "tipUnrecognized": "여기 있는 스킬의 콘텐츠가 어떤 공식 버전과도 일치하지 않아 Orca가 안전하게 업데이트할 수 없습니다. 업데이트를 허용하려면 여기 있는 항목을 제거하거나 교체하세요.", + "tipInaccessible": "Orca가 여기 있는 스킬을 읽지 못해(권한 또는 파일 오류) 확인하거나 업데이트할 수 없습니다.", + "tipDuplicate": "이 스킬은 기본 위치와 별개로 여기에도 설치되어 있어 npx skills update 명령이 접근할 수 없습니다. 업데이트를 허용하려면 제거하세요.", + "tipExternalLink": "Orca의 스킬 폴더 바깥을 가리키는 바로 가기입니다. 업데이트가 이를 따라가지 않습니다.", + "tipBrokenLink": "더 이상 존재하지 않는 대상을 가리키는 바로 가기입니다. 삭제해도 됩니다.", + "tipReadOnly": "여기 있는 스킬은 읽기 전용 위치에 있어 권한을 변경해야 업데이트할 수 있습니다.", + "tipInRepo": "여기 있는 스킬은 전역 스킬이 아니라 프로젝트 안에 있습니다. Orca는 전역 스킬만 업데이트합니다.", + "tipPluginCache": "여기 있는 스킬은 플러그인이 관리합니다. 대신 플러그인을 업데이트하세요.", + "skippedReasonUnrecognized": "The copy here doesn’t match the official version — it may be modified, or a different skill with the same name. Orca left it out of the update so it won’t overwrite it. Remove it if you want Orca to update this skill.", + "skippedReasonReadOnly": "This copy is in a read-only location, so Orca left it out of the update. Change its permissions to let Orca update it.", + "skippedReasonInaccessible": "Orca couldn’t read this copy, so it left the skill out of the update.", + "skippedReasonInRepo": "This is a project skill, not a global one — Orca only updates your global skills, so it left this out of the update.", + "skippedReasonPluginCache": "A plugin manages this skill, so Orca left it out of the update — update the plugin instead.", + "skippedReasonExternalLink": "This copy is a shortcut pointing outside Orca’s skill folders, so Orca left it out of the update.", + "skippedReasonBrokenLink": "This copy is a shortcut to something that no longer exists, so Orca left it out — you can safely delete it." + }, + "SkillFreshnessUpdateDialog": { + "title": "스킬 업데이트", + "checking": "설치된 Orca 스킬을 확인하는 중…", + "none": "설치된 Orca 스킬을 찾을 수 없습니다.", + "updateOne": "스킬 1개를 안전하게 업데이트할 수 있습니다", + "updateMany": "스킬 {{value0}}개를 안전하게 업데이트할 수 있습니다", + "success": "설치된 Orca 스킬이 모두 최신 상태입니다.", + "attention": "일부 설치된 Orca 스킬은 자동으로 업데이트할 수 없습니다.", + "attentionDescription": "각 항목을 업데이트할 수 없는 이유는 업데이트 세부 정보를 열어 확인하세요.", + "details": "업데이트 세부 정보", + "terminalTitle": "Orca 스킬 업데이트", + "terminalDescription": "미리 입력된 명령을 검토한 후 Enter 키를 눌러 실행하세요.", + "terminalAria": "Orca 스킬 업데이트 터미널", + "checkNow": "지금 확인", + "close": "닫기" + }, + "SkillFreshnessStatusPill": { + "updateAvailable": "업데이트 가능", + "upToDate": "최신 상태", + "installed": "설치됨" } }, "sidebar": { @@ -8670,7 +8737,9 @@ "fe9231215b": "설정 작업을 표시하기 전에 Linear 액세스를 확인하는 중입니다.", "e1f5e6424c": "{{value0}}개 워크스페이스{{value1}} 연결됨", "disconnect_all": "연결 해제", - "account_scope_prefix": "계정 범위" + "account_scope_prefix": "계정 범위", + "2d60ec7921": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are sent to the selected remote runtime and stored there with runtime-supported encryption.", + "977e360b71": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are stored locally and encrypted when local runtime storage supports it." } } } @@ -9030,7 +9099,9 @@ "b7e2d9f0a3": "터미널의 grok /usage 화면과 같은 주간 크레딧 비율입니다.", "c6d1a8f4e2": "{{when}}에 재설정", "e6dadc1e2b": "Monthly usage", - "75e396bf42": "Included monthly usage for Grok unified-billing accounts." + "75e396bf42": "Included monthly usage for Grok unified-billing accounts.", + "b36fa2c908": "Signed in. Orca reads the Grok CLI session stored on disk.", + "f08c41de73": "Session expired — run grok on the computer running Orca and wait for it to start. If prompted, complete sign-in, then click Refresh usage. No chat message is needed." }, "AppearanceWindowSidebarSection": { "usagePercentageDisplayUsed": "사용", @@ -12692,7 +12763,24 @@ "70fcd360c4": "https://example.atlassian.net", "e176f9d0c5": "Jira Cloud 사이트 URL", "d785c42b8b": "Jira Cloud 사이트 URL, Atlassian 이메일, API 토큰을 사용해 이슈를 탐색합니다.", - "8388bdea2b": "Jira 사이트 연결" + "8388bdea2b": "Jira 사이트 연결", + "2e2b69e48e": "Use a self-hosted Jira base URL and a personal access token to browse issues.", + "b67e919bd5": "Jira instance type", + "17787d6e4b": "Atlassian Cloud", + "bc7a831773": "Self-hosted", + "3489e186d6": "Jira site URL", + "cbc27fa599": "https://jira.example.com", + "730d973bae": "Personal access token", + "8b9c7b9e7b": "Jira personal access token", + "ccfb086d3e": "Create a personal access token in your Jira profile under Personal Access Tokens.", + "1d947a07ab": "Use a self-hosted Jira base URL, username, and password to browse issues.", + "f49708c369": "Jira authentication method", + "84a810dd0e": "Username & password", + "8d1223fa5c": "Username", + "be9eba0a1b": "username", + "70035652d7": "Password", + "c50abbf340": "Jira account password", + "d8737db691": "Use your Jira Server or Data Center account username and password." } } }, @@ -13119,6 +13207,7 @@ "title": "대화를 로드할 수 없습니다.", "subtitle": "스크립트를 읽을 수 없습니다. 계속 작업하려면 terminal로 다시 전환하세요." }, + "pairHost": "호스트를 페어링하여 에이전트 채팅 기록을 확인하세요.", "notAgent": { "title": "여기에는 대화가 없습니다.", "subtitle": "이 terminal은 인식된 코딩 agent를 실행하고 있지 않습니다." diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index f8a6e220407..b9fe5b678d6 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -31,6 +31,10 @@ "portsToggleDescription": "显示实时工作区端口。单击可查看工作区范围的端口和外部监听器。", "antigravityToggleDescription": "显示当前工作区的 Antigravity 订阅使用量。", "grokToggleDescription": "通过 Grok CLI 登录后显示 Grok 订阅额度使用量。" + }, + "menuBarIcon": { + "title": "显示菜单栏图标", + "description": "在 macOS 菜单栏中保留 Orca 快捷方式和活动指示器。" } } }, @@ -67,7 +71,8 @@ "quit": "退出", "minimizeNotice": { "body": "Orca 仍在系统托盘中运行" - } + }, + "activityWaiting": "Orca - 活动等待中" }, "worktreeJumpPalette": { "matchLabel": { @@ -3219,7 +3224,11 @@ "42fdd4da1d": "正在刷新 Claude 登录。智能体会话可能仍处于登录状态。", "c06c1d215d": "由于网络请求失败,无法刷新 Claude 用量。", "cabdc2a9e0": "无法读取 Claude 登录凭据。", - "a7517cccb6": "Claude 用量目前不可用。" + "a7517cccb6": "Claude 用量目前不可用。", + "e2c6a4f917": "Run Grok to refresh", + "d1b7f509ac": "Run grok in a terminal on the computer running Orca and wait for it to start. If prompted, complete sign-in, then retry usage. You do not need to send a chat message.", + "f90b3d7a16": "Run Kimi to refresh", + "a37e8c15d4": "Run kimi in a terminal on the computer running Orca and wait for it to start, then retry usage." }, "SshTargetStatusRow": { "sshHost": "SSH 主机" @@ -3596,6 +3605,64 @@ "cd7893fbc1": "正在扫描技能", "35b9a724a0": "可用的", "0c74e7ff34": "当地的" + }, + "SkillFreshnessNudge": { + "titleOne": "已安装的 Orca 技能已过期", + "titleMany": "{{value0}} 个已安装的 Orca 技能已过期", + "updateOne": "更新技能", + "updateMany": "更新技能", + "description": "更新 {{value0}},让 Agents 遵循此版本 Orca 的最新说明。" + }, + "SkillFreshnessRow": { + "statusUpdateAvailable": "有更新", + "statusCantUpdate": "无法更新", + "cantUpdateReason": "此技能安装在 Orca 无法安全更新的位置,因此 npx skills update 命令不会更改它。", + "chipCurrent": "最新", + "chipUnrecognized": "无法识别", + "chipInaccessible": "无法访问", + "chipDuplicate": "重复", + "chipExternalLink": "外部链接", + "chipBrokenLink": "失效链接", + "chipReadOnly": "只读", + "chipInRepo": "在仓库中", + "chipPluginCache": "插件缓存", + "tipCurrent": "这里的技能已是最新,更新不会更改它。", + "tipUnrecognized": "这里的技能内容与任何官方版本都不匹配,因此 Orca 无法安全更新它。请移除或替换此处的内容以允许更新。", + "tipInaccessible": "Orca 无法读取这里的技能(权限或文件错误),因此无法检查或更新它。", + "tipDuplicate": "此技能除主副本外还安装在这里,因此 npx skills update 命令无法访问它。请移除它以允许更新。", + "tipExternalLink": "这是一个指向 Orca 技能文件夹之外的快捷方式;更新不会跟随它。", + "tipBrokenLink": "这是一个指向已不存在内容的快捷方式,可以安全删除。", + "tipReadOnly": "这里的技能位于只读位置,需更改其权限后才能更新。", + "tipInRepo": "这里的技能位于某个项目内,而非全局技能;Orca 只更新全局技能。", + "tipPluginCache": "这里的技能由插件管理;请改为更新插件。", + "skippedReasonUnrecognized": "The copy here doesn’t match the official version — it may be modified, or a different skill with the same name. Orca left it out of the update so it won’t overwrite it. Remove it if you want Orca to update this skill.", + "skippedReasonReadOnly": "This copy is in a read-only location, so Orca left it out of the update. Change its permissions to let Orca update it.", + "skippedReasonInaccessible": "Orca couldn’t read this copy, so it left the skill out of the update.", + "skippedReasonInRepo": "This is a project skill, not a global one — Orca only updates your global skills, so it left this out of the update.", + "skippedReasonPluginCache": "A plugin manages this skill, so Orca left it out of the update — update the plugin instead.", + "skippedReasonExternalLink": "This copy is a shortcut pointing outside Orca’s skill folders, so Orca left it out of the update.", + "skippedReasonBrokenLink": "This copy is a shortcut to something that no longer exists, so Orca left it out — you can safely delete it." + }, + "SkillFreshnessUpdateDialog": { + "title": "更新技能", + "checking": "正在检查已安装的 Orca 技能…", + "none": "未找到已安装的 Orca 技能。", + "updateOne": "可以安全更新 1 个技能", + "updateMany": "可以安全更新 {{value0}} 个技能", + "success": "所有已安装的 Orca 技能均为最新。", + "attention": "部分已安装的 Orca 技能无法自动更新。", + "attentionDescription": "打开“更新详情”查看为何每一项都无法更新。", + "details": "更新详情", + "terminalTitle": "更新 Orca 技能", + "terminalDescription": "检查预填的命令,然后按 Enter 键运行。", + "terminalAria": "Orca 技能更新终端", + "checkNow": "立即检查", + "close": "关闭" + }, + "SkillFreshnessStatusPill": { + "updateAvailable": "有可用更新", + "upToDate": "已是最新", + "installed": "已安装" } }, "sidebar": { @@ -8670,7 +8737,9 @@ "fe9231215b": "在显示设置操作前正在检查 Linear 访问权限。", "e1f5e6424c": "{{value0}} 个工作区{{value1}} 已连接", "disconnect_all": "断开连接", - "account_scope_prefix": "账户范围" + "account_scope_prefix": "账户范围", + "2d60ec7921": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are sent to the selected remote runtime and stored there with runtime-supported encryption.", + "977e360b71": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are stored locally and encrypted when local runtime storage supports it." } } } @@ -9030,7 +9099,9 @@ "b7e2d9f0a3": "与终端中 grok /usage 屏幕显示的每周额度百分比相同。", "c6d1a8f4e2": "{{when}} 重置", "e6dadc1e2b": "Monthly usage", - "75e396bf42": "Included monthly usage for Grok unified-billing accounts." + "75e396bf42": "Included monthly usage for Grok unified-billing accounts.", + "b36fa2c908": "Signed in. Orca reads the Grok CLI session stored on disk.", + "f08c41de73": "Session expired — run grok on the computer running Orca and wait for it to start. If prompted, complete sign-in, then click Refresh usage. No chat message is needed." }, "AppearanceWindowSidebarSection": { "usagePercentageDisplayUsed": "已用", @@ -12692,7 +12763,24 @@ "70fcd360c4": "https://example.atlassian.net", "e176f9d0c5": "Jira Cloud 站点 URL", "d785c42b8b": "使用 Jira Cloud 站点 URL、Atlassian 邮箱和 API token 来浏览议题。", - "8388bdea2b": "连接 Jira 站点" + "8388bdea2b": "连接 Jira 站点", + "2e2b69e48e": "Use a self-hosted Jira base URL and a personal access token to browse issues.", + "b67e919bd5": "Jira instance type", + "17787d6e4b": "Atlassian Cloud", + "bc7a831773": "Self-hosted", + "3489e186d6": "Jira site URL", + "cbc27fa599": "https://jira.example.com", + "730d973bae": "Personal access token", + "8b9c7b9e7b": "Jira personal access token", + "ccfb086d3e": "Create a personal access token in your Jira profile under Personal Access Tokens.", + "1d947a07ab": "Use a self-hosted Jira base URL, username, and password to browse issues.", + "f49708c369": "Jira authentication method", + "84a810dd0e": "Username & password", + "8d1223fa5c": "Username", + "be9eba0a1b": "username", + "70035652d7": "Password", + "c50abbf340": "Jira account password", + "d8737db691": "Use your Jira Server or Data Center account username and password." } } }, @@ -13119,6 +13207,7 @@ "title": "无法加载对话", "subtitle": "无法读取记录。请切回终端继续工作。" }, + "pairHost": "配对主机以查看智能体聊天记录。", "notAgent": { "title": "这里没有对话", "subtitle": "此终端未运行可识别的编码智能体。" diff --git a/src/renderer/src/lib/activity-thread-display.test.ts b/src/renderer/src/lib/activity-thread-display.test.ts index c553ceb02f3..1728c8ab5ca 100644 --- a/src/renderer/src/lib/activity-thread-display.test.ts +++ b/src/renderer/src/lib/activity-thread-display.test.ts @@ -112,7 +112,11 @@ describe('getActivityThreadTaskTitle', () => { }) it('keeps orchestration labels across terse follow-ups but yields to new work', () => { - const orchestration = { taskId: 'task-1', dispatchId: 'ctx-1', displayName: 'Fix checkout race' } + const orchestration = { + taskId: 'task-1', + dispatchId: 'ctx-1', + displayName: 'Fix checkout race' + } // Terse follow-up → still the same orchestration task. expect( getActivityThreadTaskTitle({ diff --git a/src/renderer/src/lib/agent-status.ts b/src/renderer/src/lib/agent-status.ts index 141da5c2a3a..aaf3fc4795c 100644 --- a/src/renderer/src/lib/agent-status.ts +++ b/src/renderer/src/lib/agent-status.ts @@ -105,36 +105,9 @@ export function getWorkingAgentsPerWorktree({ return result } -const WELL_KNOWN_LABELS: Record = { - claude: 'Claude', - openclaude: 'OpenClaude', - codex: 'Codex', - gemini: 'Gemini', - antigravity: 'Antigravity', - amp: 'Amp', - copilot: 'GitHub Copilot', - opencode: 'OpenCode', - 'mimo-code': 'MiMo Code', - cursor: 'Cursor', - aider: 'Aider', - pi: 'Pi', - omp: 'OMP', - droid: 'Droid', - 'command-code': 'Command Code', - grok: 'Grok', - hermes: 'Hermes', - devin: 'Devin', - ante: 'Ante', - kimi: 'Kimi' -} - -export function formatAgentTypeLabel(agentType: AgentType | null | undefined): string { - if (!agentType || agentType === 'unknown') { - return 'Agent' - } - // Capitalize well-known names nicely; pass through custom names as-is - return WELL_KNOWN_LABELS[agentType] ?? agentType -} +// Re-exported from shared so mobile shows the same agent labels (one source of +// truth). Kept re-exported here so existing `@/lib/agent-status` importers work. +export { formatAgentTypeLabel } from '../../../shared/agent-type-label' // Why: AgentIcon expects a TuiAgent, but AgentType is a broader union // (WellKnownAgentType | (string & {})) that includes 'unknown' and arbitrary diff --git a/src/renderer/src/lib/linear-linked-work-item.test.ts b/src/renderer/src/lib/linear-linked-work-item.test.ts index d6f3da3de84..10c11a9dde1 100644 --- a/src/renderer/src/lib/linear-linked-work-item.test.ts +++ b/src/renderer/src/lib/linear-linked-work-item.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from 'vitest' import type { LinearIssue } from '../../../shared/types' -import { buildLinearIssueLinkedWorkItem, isLinearLinkedWorkItem } from './linear-linked-work-item' +import { + buildLinearIssueLinkedWorkItem, + getLinearLinkedWorkItemBranchName, + isLinearLinkedWorkItem +} from './linear-linked-work-item' function makeIssue(patch: Partial = {}): LinearIssue { return { @@ -42,6 +46,20 @@ describe('buildLinearIssueLinkedWorkItem', () => { expect(item.linearWorkspaceId).toBe('ws-1') }) + + it('carries a normalized usable Linear branch name', () => { + const item = buildLinearIssueLinkedWorkItem( + makeIssue({ branchName: ' team/eng-123-fix-launch-context ' }) + ) + + expect(item.linearBranchName).toBe('team/eng-123-fix-launch-context') + }) + + it('omits unusable Linear branch names', () => { + const item = buildLinearIssueLinkedWorkItem(makeIssue({ branchName: ' ' })) + + expect(item).not.toHaveProperty('linearBranchName') + }) }) describe('isLinearLinkedWorkItem', () => { @@ -52,4 +70,20 @@ describe('isLinearLinkedWorkItem', () => { expect(isLinearLinkedWorkItem({})).toBe(false) expect(isLinearLinkedWorkItem(null)).toBe(false) }) + + it('only resolves branch overrides from Linear-linked items', () => { + expect( + getLinearLinkedWorkItemBranchName({ + provider: 'linear', + linearIdentifier: 'ENG-123', + linearBranchName: ' team/eng-123-fix ' + }) + ).toBe('team/eng-123-fix') + expect( + getLinearLinkedWorkItemBranchName({ + provider: 'github', + linearBranchName: 'team/eng-123-fix' + }) + ).toBeUndefined() + }) }) diff --git a/src/renderer/src/lib/linear-linked-work-item.ts b/src/renderer/src/lib/linear-linked-work-item.ts index 5bb212f2b48..d1219bad8dc 100644 --- a/src/renderer/src/lib/linear-linked-work-item.ts +++ b/src/renderer/src/lib/linear-linked-work-item.ts @@ -1,6 +1,9 @@ import type { LinearIssue } from '../../../shared/types' import type { LinkedWorkItemSummary } from '@/lib/new-workspace' -import { buildLinearWorkspaceSource } from '../../../shared/new-workspace/workspace-source' +import { + buildLinearWorkspaceSource, + getUsableLinearBranchName +} from '../../../shared/new-workspace/workspace-source' export function isLinearLinkedWorkItem( item: Pick | null | undefined @@ -8,6 +11,17 @@ export function isLinearLinkedWorkItem( return item?.provider === 'linear' || Boolean(item?.linearIdentifier?.trim()) } +export function getLinearLinkedWorkItemBranchName( + item: + | Pick + | null + | undefined +): string | undefined { + return isLinearLinkedWorkItem(item) + ? getUsableLinearBranchName(item?.linearBranchName) + : undefined +} + export function buildLinearIssueLinkedWorkItem(issue: LinearIssue): LinkedWorkItemSummary { return buildLinearWorkspaceSource(issue) } diff --git a/src/renderer/src/lib/mobile-terminal-tab-mount.test.ts b/src/renderer/src/lib/mobile-terminal-tab-mount.test.ts new file mode 100644 index 00000000000..6529ebca17f --- /dev/null +++ b/src/renderer/src/lib/mobile-terminal-tab-mount.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AppState } from '@/store/types' +import { planMobileTerminalTabMount } from './mobile-terminal-tab-mount' + +type PlannerState = Pick + +function state(tabCount = 1): PlannerState { + return { + tabsByWorktree: { + wt: Array.from({ length: tabCount }, (_, index) => ({ + id: `tab-${index}`, + ptyId: `wt@@${index}` + })) + } as unknown as AppState['tabsByWorktree'], + terminalLayoutsByTabId: {} + } +} + +describe('planMobileTerminalTabMount', () => { + it('keeps real-tab requests targeted to exactly one tab', () => { + expect(planMobileTerminalTabMount(state(), { worktreeId: 'wt', tabId: 'tab-0' })).toEqual({ + worktreeId: 'wt', + tabIds: ['tab-0'] + }) + }) + + it('resolves synthetic handles to exactly one owning tab at workspace scale', () => { + expect(planMobileTerminalTabMount(state(200), { worktreeId: 'wt', ptyId: 'wt@@173' })).toEqual({ + worktreeId: 'wt', + tabIds: ['tab-173'] + }) + }) + + it('does not mount the whole worktree when a stale pty id has no owner', () => { + expect( + planMobileTerminalTabMount(state(200), { worktreeId: 'wt', ptyId: 'wt@@missing' }) + ).toBeNull() + }) + + it('does not mount either tab when stale persistence has duplicate pty ownership', () => { + const s = state(200) + s.terminalLayoutsByTabId['tab-199'] = { + root: null, + activeLeafId: null, + expandedLeafId: null, + ptyIdsByLeafId: { leaf: 'wt@@173' } + } + + expect(planMobileTerminalTabMount(s, { worktreeId: 'wt', ptyId: 'wt@@173' })).toBeNull() + }) + + it('does not mount a hidden worktree for a stale direct tab id', () => { + const isTabMounted = vi.fn() + + expect( + planMobileTerminalTabMount( + state(200), + { worktreeId: 'wt', tabId: 'tab-missing' }, + { isTabMounted } + ) + ).toBeNull() + expect(isTabMounted).not.toHaveBeenCalled() + }) + + it('does not schedule hidden layout work for an already-mounted tab', () => { + const isTabMounted = vi.fn().mockReturnValue(true) + + expect( + planMobileTerminalTabMount( + state(200), + { worktreeId: 'wt', ptyId: 'wt@@173' }, + { isTabMounted } + ) + ).toBeNull() + expect(isTabMounted).toHaveBeenCalledTimes(1) + expect(isTabMounted).toHaveBeenCalledWith('tab-173') + }) +}) diff --git a/src/renderer/src/lib/mobile-terminal-tab-mount.ts b/src/renderer/src/lib/mobile-terminal-tab-mount.ts new file mode 100644 index 00000000000..28431cc5ad6 --- /dev/null +++ b/src/renderer/src/lib/mobile-terminal-tab-mount.ts @@ -0,0 +1,41 @@ +import type { AppState } from '@/store/types' +import type { BackgroundMountTerminalWorktreeDetail } from '@/constants/terminal' +import { resolveTerminalTabIdForPtyId } from './terminal-tab-for-pty-id' + +export type MobileTerminalTabMountRequest = { + worktreeId: string + tabId?: string + ptyId?: string +} + +type MobileTerminalTabMountOptions = { + isTabMounted?: (tabId: string) => boolean +} + +/** Why: exact-tab planning prevents a stale ptyId from mounting every saved xterm (#8597). */ +export function planMobileTerminalTabMount( + state: Pick, + request: MobileTerminalTabMountRequest, + options: MobileTerminalTabMountOptions = {} +): BackgroundMountTerminalWorktreeDetail | null { + if (!request.worktreeId) { + return null + } + const requestedTabExists = request.tabId + ? (state.tabsByWorktree[request.worktreeId] ?? []).some((tab) => tab.id === request.tabId) + : false + // Why: stale real-tab handles must fail closed like stale synthetic handles; + // otherwise they mount and measure a hidden worktree with no pane to recover. + const tabId = request.tabId + ? requestedTabExists + ? request.tabId + : null + : request.ptyId + ? resolveTerminalTabIdForPtyId(state, request.worktreeId, request.ptyId) + : null + // Why: replaying the background-mount event for a live pane restarts its + // three-second hidden measurement window on every mobile reconnect. + return tabId && !options.isTabMounted?.(tabId) + ? { worktreeId: request.worktreeId, tabIds: [tabId] } + : null +} diff --git a/src/renderer/src/lib/native-chat-supported-agent.ts b/src/renderer/src/lib/native-chat-supported-agent.ts index a376fe0d5ec..84135bba8f4 100644 --- a/src/renderer/src/lib/native-chat-supported-agent.ts +++ b/src/renderer/src/lib/native-chat-supported-agent.ts @@ -1,19 +1,4 @@ -import type { AgentType } from '../../../shared/agent-status-types' -import type { TuiAgent } from '../../../shared/types' - -/** Agents whose transcripts the native chat view can parse and render. */ -export const NATIVE_CHAT_SUPPORTED_AGENTS: ReadonlySet = new Set([ - 'claude', - 'openclaude', - 'codex', - // Why: Grok writes `~/.grok/sessions///chat_history.jsonl` and - // Orca already reads that for hooks/AI Vault — native chat reuses the same - // path + a Grok JSONL decoder rather than leaving Grok TUI-only. - 'grok' -]) - -export function isNativeChatSupportedAgent( - agent: TuiAgent | AgentType | null | undefined -): boolean { - return agent != null && NATIVE_CHAT_SUPPORTED_AGENTS.has(agent) -} +export { + isNativeChatSupportedAgent, + NATIVE_CHAT_SUPPORTED_AGENTS +} from '../../../shared/native-chat-agent-support' diff --git a/src/renderer/src/lib/new-workspace.ts b/src/renderer/src/lib/new-workspace.ts index 0a7505762d0..4ca67db446e 100644 --- a/src/renderer/src/lib/new-workspace.ts +++ b/src/renderer/src/lib/new-workspace.ts @@ -59,6 +59,7 @@ export type LinkedWorkItemSummary = Omit provider?: FolderWorkspaceLinkedTask['provider'] linearWorkspaceId?: string linearOrganizationUrlKey?: string + linearBranchName?: string linkedContext?: LinkedWorkItemContext } diff --git a/src/renderer/src/lib/pane-manager/pane-divider-capture-loss.test.ts b/src/renderer/src/lib/pane-manager/pane-divider-capture-loss.test.ts new file mode 100644 index 00000000000..a863b503fe6 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-divider-capture-loss.test.ts @@ -0,0 +1,154 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createDivider } from './pane-divider' + +type PaneElement = HTMLElement & { style: Record } + +type DividerDragHarness = { + divider: HTMLElement + dividerListeners: Map + windowListeners: Map + capturedPointerIds: Set + previousPane: PaneElement + nextPane: PaneElement + onLayoutChanged: ReturnType + flushAnimationFrames: () => void +} + +function createPaneElement(width: number): PaneElement { + return { + style: {}, + classList: { contains: vi.fn(() => false) }, + dispatchEvent: vi.fn(() => true), + getBoundingClientRect: vi.fn(() => ({ + left: 0, + top: 0, + right: width, + bottom: 200, + width, + height: 200 + })), + querySelectorAll: vi.fn(() => []) + } as unknown as PaneElement +} + +function createPointerEvent(args: Partial): PointerEvent { + return { + preventDefault: vi.fn(), + pointerId: 1, + clientX: 0, + clientY: 0, + ...args + } as unknown as PointerEvent +} + +function createDividerDragHarness(): DividerDragHarness { + const dividerListeners = new Map() + const windowListeners = new Map() + const capturedPointerIds = new Set() + const animationFrames = new Map() + const previousPane = createPaneElement(100) + const nextPane = createPaneElement(300) + const divider = { + style: { setProperty: vi.fn() }, + classList: { add: vi.fn(), remove: vi.fn() }, + addEventListener: vi.fn((event: string, listener: EventListener) => { + dividerListeners.set(event, listener) + }), + removeEventListener: vi.fn((event: string, listener: EventListener) => { + if (dividerListeners.get(event) === listener) { + dividerListeners.delete(event) + } + }), + setPointerCapture: vi.fn((pointerId: number) => capturedPointerIds.add(pointerId)), + hasPointerCapture: vi.fn((pointerId: number) => capturedPointerIds.has(pointerId)), + releasePointerCapture: vi.fn((pointerId: number) => capturedPointerIds.delete(pointerId)), + previousElementSibling: previousPane, + nextElementSibling: nextPane + } as unknown as HTMLElement + vi.stubGlobal('document', { createElement: vi.fn(() => divider) }) + vi.stubGlobal('window', { + addEventListener: vi.fn((event: string, listener: EventListener) => { + windowListeners.set(event, listener) + }), + removeEventListener: vi.fn((event: string, listener: EventListener) => { + if (windowListeners.get(event) === listener) { + windowListeners.delete(event) + } + }) + }) + let nextFrameId = 0 + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn((callback: FrameRequestCallback) => { + nextFrameId += 1 + animationFrames.set(nextFrameId, callback) + return nextFrameId + }) + ) + vi.stubGlobal( + 'cancelAnimationFrame', + vi.fn((frameId: number) => animationFrames.delete(frameId)) + ) + const onLayoutChanged = vi.fn() + createDivider(true, {}, { refitPanesUnder: vi.fn(), onLayoutChanged }) + + return { + divider, + dividerListeners, + windowListeners, + capturedPointerIds, + previousPane, + nextPane, + onLayoutChanged, + flushAnimationFrames: () => { + for (const [frameId, callback] of animationFrames) { + animationFrames.delete(frameId) + callback(16) + } + } + } +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('divider pointer capture loss', () => { + it('continues through window events and commits the final drag position', () => { + const harness = createDividerDragHarness() + harness.dividerListeners.get('pointerdown')?.( + createPointerEvent({ pointerId: 9, clientX: 100 }) + ) + harness.windowListeners.get('pointermove')?.(createPointerEvent({ pointerId: 9, clientX: 180 })) + harness.flushAnimationFrames() + + expect(harness.previousPane.style.flex).toBe('180 1 0%') + expect(harness.dividerListeners.has('lostpointercapture')).toBe(false) + + harness.capturedPointerIds.delete(9) + harness.windowListeners.get('pointermove')?.(createPointerEvent({ pointerId: 9, clientX: 220 })) + harness.flushAnimationFrames() + harness.windowListeners.get('pointerup')?.(createPointerEvent({ pointerId: 9, clientX: 220 })) + + expect(harness.previousPane.style.flex).toBe('220 1 0%') + expect(harness.nextPane.style.flex).toBe('180 1 0%') + expect(harness.onLayoutChanged).toHaveBeenCalledTimes(1) + }) + + it('still restores the original layout when the window loses focus', () => { + const harness = createDividerDragHarness() + harness.previousPane.style.flex = '2 1 0%' + harness.nextPane.style.flex = '3 1 0%' + harness.dividerListeners.get('pointerdown')?.( + createPointerEvent({ pointerId: 9, clientX: 100 }) + ) + harness.windowListeners.get('pointermove')?.(createPointerEvent({ pointerId: 9, clientX: 180 })) + harness.flushAnimationFrames() + + harness.windowListeners.get('blur')?.({} as Event) + + expect(harness.previousPane.style.flex).toBe('2 1 0%') + expect(harness.nextPane.style.flex).toBe('3 1 0%') + expect(harness.onLayoutChanged).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/pane-manager/pane-divider-drag.ts b/src/renderer/src/lib/pane-manager/pane-divider-drag.ts index 0b552ccc4ca..8fd5d3443ca 100644 --- a/src/renderer/src/lib/pane-manager/pane-divider-drag.ts +++ b/src/renderer/src/lib/pane-manager/pane-divider-drag.ts @@ -93,6 +93,8 @@ export function attachDividerDrag( if (windowListenersAttached || typeof window === 'undefined') { return } + // Why: Chromium can transiently lose capture while the button remains held, + // so window events keep ownership until pointerup, pointercancel, or blur. windowListenersAttached = true window.addEventListener('pointermove', onPointerMove, true) window.addEventListener('pointerup', onPointerUp, true) @@ -264,12 +266,6 @@ export function attachDividerDrag( } } - const onLostPointerCapture = (): void => { - if (dragging) { - finishActiveDrag(false) - } - } - const onWindowBlur = (): void => { finishActiveDrag(false) } @@ -278,7 +274,6 @@ export function attachDividerDrag( divider.addEventListener('pointermove', onPointerMove) divider.addEventListener('pointerup', onPointerUp) divider.addEventListener('pointercancel', onPointerCancel) - divider.addEventListener('lostpointercapture', onLostPointerCapture) divider.addEventListener('dblclick', onDoubleClick) dividerDragCleanups.set(divider, () => { finishActiveDrag(false) @@ -286,7 +281,6 @@ export function attachDividerDrag( divider.removeEventListener('pointermove', onPointerMove) divider.removeEventListener('pointerup', onPointerUp) divider.removeEventListener('pointercancel', onPointerCancel) - divider.removeEventListener('lostpointercapture', onLostPointerCapture) divider.removeEventListener('dblclick', onDoubleClick) }) } diff --git a/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts b/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts index daf76998950..e9c891baabc 100644 --- a/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts @@ -5,6 +5,11 @@ import { detachPaneFitResizeObserver, requestStablePaneFit } from './pane-fit-resize-observer' +import { + beginTerminalScrollIntentBufferRebuild, + endTerminalScrollIntentBufferRebuild +} from './terminal-scroll-intent-rebuild' +import { safeFitAndThen } from './pane-tree-ops' type ResizeObserverCallbackLike = ConstructorParameters[0] @@ -174,6 +179,39 @@ describe('attachPaneFitResizeObserver', () => { expect(onSettled).toHaveBeenCalledTimes(1) }) + it('releases a hidden reattach continuation when the stable grid already matches', async () => { + const pane = createPane() + vi.mocked(pane.fitAddon.proposeDimensions).mockReturnValue(undefined) + const continuation = vi.fn() + const pending = safeFitAndThen(pane, 'reattach-pty-resize', continuation) + + expect(continuation).not.toHaveBeenCalled() + vi.mocked(pane.fitAddon.proposeDimensions).mockReturnValue({ cols: 79, rows: 24 }) + requestStablePaneFit(pane) + flushAnimationFrames() + + await expect(pending.completion).resolves.toBe(true) + expect(continuation).toHaveBeenCalledTimes(1) + expect(pane.fitAddon.fit).not.toHaveBeenCalled() + }) + + it('does not notify settled callbacks until a replay-deferred fit completes', async () => { + const onSettled = vi.fn() + const pane = createPane() + beginTerminalScrollIntentBufferRebuild(pane.terminal) + + requestStablePaneFit(pane, onSettled) + flushAnimationFrames() + + expect(pane.fitAddon.fit).not.toHaveBeenCalled() + expect(onSettled).not.toHaveBeenCalled() + endTerminalScrollIntentBufferRebuild(pane.terminal) + await Promise.resolve() + + expect(pane.fitAddon.fit).toHaveBeenCalledTimes(1) + expect(onSettled).toHaveBeenCalledTimes(1) + }) + it('skips observer fits while the terminal has no visible geometry', () => { const pane = createPane(() => ({ cols: 80, rows: 24 }), { rect: { width: 0, height: 0 } diff --git a/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.ts b/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.ts index 867a2b5dd18..53d2f6b6f11 100644 --- a/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.ts +++ b/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.ts @@ -1,5 +1,5 @@ import type { ManagedPane, ManagedPaneInternal } from './pane-manager-types' -import { safeFit } from './pane-tree-ops' +import { cancelPendingSafeFitContinuations, safeFitAndThen } from './pane-tree-ops' type ProposedDimensions = { cols: number @@ -74,12 +74,11 @@ function flushStableFitCallbacks(pane: StableFitPane): void { } } -function finishStableFit(pane: StableFitPane, shouldFit: boolean): void { +function finishStableFit(pane: StableFitPane): void { setPendingObservedFitRafId(pane, null) - if (shouldFit) { - safeFit(pane) - } - flushStableFitCallbacks(pane) + // Why: an equal grid still proves a restored pane is measurable, so it must + // release reattach continuations that were parked while the tab was hidden. + safeFitAndThen(pane, 'stable-pane-fit', () => flushStableFitCallbacks(pane)) } export function requestStablePaneFit(pane: StableFitPane, onSettled?: () => void): void { @@ -112,23 +111,23 @@ export function requestStablePaneFit(pane: StableFitPane, onSettled?: () => void frameCount += 1 if (!next) { - finishStableFit(pane, true) + finishStableFit(pane) return } if (terminalDimensionsEqual(pane, next)) { - finishStableFit(pane, false) + finishStableFit(pane) return } if (dimensionsEqual(previous, next)) { - finishStableFit(pane, true) + finishStableFit(pane) return } previous = next if (frameCount >= MAX_STABILITY_FRAMES) { - finishStableFit(pane, true) + finishStableFit(pane) return } @@ -164,4 +163,5 @@ export function detachPaneFitResizeObserver(pane: ManagedPaneInternal): void { setPendingObservedFitRafId(pane, null) } stableFitCallbacks.delete(pane) + cancelPendingSafeFitContinuations(pane) } diff --git a/src/renderer/src/lib/pane-manager/pane-fit.ts b/src/renderer/src/lib/pane-manager/pane-fit.ts new file mode 100644 index 00000000000..573ba6ab7ed --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-fit.ts @@ -0,0 +1,257 @@ +import type { ManagedPane, ManagedPaneInternal, ScrollState } from './pane-manager-types' +import { getFitOverrideForPty } from './mobile-fit-overrides' +import { + captureTerminalStructuralScrollIntent, + isTerminalStructuralScrollIntentCurrent, + markTerminalPinnedViewport, + restoreTerminalStructuralScrollIntent +} from './terminal-scroll-intent' +import { + captureScrollState, + releaseScrollStateMarker, + restoreScrollStateAfterFit, + resumePendingFitScrollRestoreAfterFit +} from './pane-scroll' +import { + deferTerminalGeometryMutationDuringRebuild, + isTerminalScrollIntentRebuildInFlight +} from './terminal-scroll-intent-rebuild' + +const MIN_PANE_FIT_WIDTH_PX = 48 +const MIN_PANE_FIT_HEIGHT_PX = 24 +const MIN_PANE_FIT_COLS = 8 +const MIN_PANE_FIT_ROWS = 4 + +export type SafeFitContinuationHandle = { + completion: Promise + cancel: () => void +} + +type PendingSafeFitContinuation = { + continuation: () => void + shouldContinue: () => boolean + resolve: (completed: boolean) => void +} + +const pendingSafeFitContinuations = new WeakMap< + ManagedPane, + Map +>() + +function getProposedDimensions(pane: ManagedPane): { cols: number; rows: number } | null { + try { + return pane.fitAddon.proposeDimensions() ?? null + } catch { + return null + } +} + +function canMeasurePaneForFit(pane: ManagedPane): boolean { + const measure = pane.container?.getBoundingClientRect + if (typeof measure === 'function') { + const rect = measure.call(pane.container) + if (rect.width < MIN_PANE_FIT_WIDTH_PX || rect.height < MIN_PANE_FIT_HEIGHT_PX) { + return false + } + } + const dims = getProposedDimensions(pane) + if (!dims) { + return false + } + // Why: worktree switches can briefly measure a near-zero overlay before + // fallback positioning lands. Fitting there pins the PTY at ~2 cols. + return dims.cols >= MIN_PANE_FIT_COLS && dims.rows >= MIN_PANE_FIT_ROWS +} + +function canPreserveScrollIntentForFit(pane: ManagedPane): boolean { + // Why: split reparent has its own delayed restore; restoring here can fight that timer. + return !( + 'pendingSplitScrollState' in pane && (pane as ManagedPaneInternal).pendingSplitScrollState + ) +} + +function performSafeFit(pane: ManagedPane): boolean { + if (deferTerminalGeometryMutationDuringRebuild(pane.terminal, 'safe-fit', () => safeFit(pane))) { + return false + } + if (!canMeasurePaneForFit(pane)) { + return false + } + let scrollIntent = null as ReturnType + let pinnedScrollState: ScrollState | null = null + let shouldRestoreScroll = false + const captureScrollForFit = (): void => { + scrollIntent = captureTerminalStructuralScrollIntent(pane.terminal) + // Why: fit can reflow and renumber every buffer row; a marker tracks the + // pinned content itself, while a numeric line would point elsewhere after. + pinnedScrollState = + scrollIntent?.kind === 'pinnedViewport' ? captureScrollState(pane.terminal) : null + shouldRestoreScroll = true + } + try { + // Why: a mobile-owned PTY must stay at its phone grid on passive desktop panes. + const ptyId = pane.container?.dataset?.ptyId + const override = ptyId ? getFitOverrideForPty(ptyId) : null + if (override) { + if (pane.terminal.cols !== override.cols || pane.terminal.rows !== override.rows) { + if (canPreserveScrollIntentForFit(pane)) { + captureScrollForFit() + } + pane.terminal.resize(override.cols, override.rows) + } else { + resumePendingFitScrollRestoreAfterFit(pane.terminal) + } + return true + } + + const dims = getProposedDimensions(pane) + if (dims && dims.cols === pane.terminal.cols && dims.rows === pane.terminal.rows) { + // Why: divider drags often stay within one cell; avoid needless clear/refresh churn. + resumePendingFitScrollRestoreAfterFit(pane.terminal) + return true + } + if (canPreserveScrollIntentForFit(pane)) { + captureScrollForFit() + } + pane.fitAddon.fit() + return true + } catch { + // Container may not have dimensions yet. + return false + } finally { + if (shouldRestoreScroll) { + try { + if (resumePendingFitScrollRestoreAfterFit(pane.terminal)) { + } else if (pinnedScrollState) { + const state: ScrollState = pinnedScrollState + pinnedScrollState = null + restoreScrollStateAfterFit(pane.terminal, state, { + onRestored: () => { + // Why: do not replace a durable pre-replay pin with transient 0/0 geometry. + if (!state.wasAtBottom) { + markTerminalPinnedViewport(pane.terminal) + } + }, + shouldRestore: () => + !isTerminalScrollIntentRebuildInFlight(pane.terminal) && + isTerminalStructuralScrollIntentCurrent(pane.terminal, scrollIntent) + }) + } else { + restoreTerminalStructuralScrollIntent(pane.terminal, scrollIntent) + } + } catch { + // Why: SSH reattach can briefly expose xterm without renderer dimensions. + } finally { + if (pinnedScrollState) { + releaseScrollStateMarker(pinnedScrollState) + } + } + } + } +} + +function settlePendingSafeFitContinuation( + pane: ManagedPane, + operationKey: string, + pending: PendingSafeFitContinuation, + completed: boolean +): void { + const operations = pendingSafeFitContinuations.get(pane) + if (operations?.get(operationKey) !== pending) { + return + } + operations.delete(operationKey) + if (operations.size === 0) { + pendingSafeFitContinuations.delete(pane) + } + pending.resolve(completed) +} + +function flushPendingSafeFitContinuations(pane: ManagedPane): void { + const operations = pendingSafeFitContinuations.get(pane) + if (!operations) { + return + } + for (const [operationKey, pending] of operations) { + if (!pending.shouldContinue()) { + settlePendingSafeFitContinuation(pane, operationKey, pending, false) + continue + } + try { + pending.continuation() + settlePendingSafeFitContinuation(pane, operationKey, pending, true) + } catch { + settlePendingSafeFitContinuation(pane, operationKey, pending, false) + } + } +} + +export function safeFit(pane: ManagedPane): boolean { + const completed = performSafeFit(pane) + if (completed) { + // Why: replay transactions may be waiting for renderer dimensions; any + // successful ordinary fit is the event that makes their PTY grid authoritative. + flushPendingSafeFitContinuations(pane) + } + return completed +} + +export function cancelPendingSafeFitContinuations(pane: ManagedPane): void { + const operations = pendingSafeFitContinuations.get(pane) + if (!operations) { + return + } + pendingSafeFitContinuations.delete(pane) + for (const pending of operations.values()) { + pending.resolve(false) + } +} + +// Why: callers that forward xterm's grid to a PTY must wait for a measurable +// fit or explicit lifecycle cancellation instead of observing replay dimensions. +export function safeFitAndThen( + pane: ManagedPane, + operationKey: string, + continuation: () => void, + options: { shouldContinue?: () => boolean } = {} +): SafeFitContinuationHandle { + const operations = pendingSafeFitContinuations.get(pane) ?? new Map() + const replaced = operations.get(operationKey) + if (replaced) { + settlePendingSafeFitContinuation(pane, operationKey, replaced, false) + } + let resolveCompletion = (_completed: boolean): void => {} + const completion = new Promise((resolve) => { + resolveCompletion = resolve + }) + const pending: PendingSafeFitContinuation = { + continuation, + shouldContinue: options.shouldContinue ?? (() => true), + resolve: resolveCompletion + } + const currentOperations = pendingSafeFitContinuations.get(pane) ?? operations + currentOperations.set(operationKey, pending) + pendingSafeFitContinuations.set(pane, currentOperations) + const cancel = (): void => { + settlePendingSafeFitContinuation(pane, operationKey, pending, false) + } + if (!pending.shouldContinue()) { + cancel() + return { completion, cancel } + } + if ( + deferTerminalGeometryMutationDuringRebuild( + pane.terminal, + `safe-fit-and-then:${operationKey}`, + () => { + if (pendingSafeFitContinuations.get(pane)?.get(operationKey) === pending) { + safeFit(pane) + } + } + ) + ) { + return { completion, cancel } + } + safeFit(pane) + return { completion, cancel } +} diff --git a/src/renderer/src/lib/pane-manager/pane-initial-fit-lifecycle.test.ts b/src/renderer/src/lib/pane-manager/pane-initial-fit-lifecycle.test.ts index 4d08013372f..81baa63f5c6 100644 --- a/src/renderer/src/lib/pane-manager/pane-initial-fit-lifecycle.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-initial-fit-lifecycle.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { ManagedPaneInternal } from './pane-manager-types' import { disposePane } from './pane-lifecycle' +import { restoreScrollStateAfterFit } from './pane-scroll' function createPane(pendingInitialFitRafId: number | null): ManagedPaneInternal { const leafId = '11111111-1111-4111-8111-111111111111' as never @@ -54,4 +55,31 @@ describe('pane initial fit lifecycle', () => { expect(pane.pendingInitialFitRafId).toBeNull() expect(panes.has(pane.id)).toBe(false) }) + + it('cancels pending fit scroll restoration before terminal disposal', () => { + const cancelAnimationFrame = vi.fn() + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn(() => 23) + ) + vi.stubGlobal('cancelAnimationFrame', cancelAnimationFrame) + const pane = createPane(null) + const marker = { line: 42, isDisposed: false, dispose: vi.fn() } + + restoreScrollStateAfterFit( + pane.terminal, + { + bufferType: 'normal', + wasAtBottom: false, + viewportY: 42, + baseY: 100, + firstVisibleLineMarker: marker as never + }, + { onRestored: vi.fn(), shouldRestore: () => true } + ) + disposePane(pane, new Map([[pane.id, pane]])) + + expect(cancelAnimationFrame).toHaveBeenCalledWith(23) + expect(marker.dispose).toHaveBeenCalledTimes(1) + }) }) 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 4b0fef324ac..a18e432d065 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts @@ -46,6 +46,7 @@ function createPane(): ManagedPaneInternal { loadAddon: vi.fn(), attachCustomWheelEventHandler: vi.fn(), refresh: vi.fn(), + cols: 80, rows: 24 } as never, container: {} as never, @@ -57,7 +58,8 @@ function createPane(): ManagedPaneInternal { webglDisabledAfterContextLoss: false, hasComplexScriptOutput: false, fitAddon: { - fit: vi.fn() + fit: vi.fn(), + proposeDimensions: vi.fn(() => ({ cols: 80, rows: 23 })) } as never, fitResizeObserver: null, pendingObservedFitRafId: null, diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts index adc43af01c1..ea938f9a351 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts @@ -5,9 +5,10 @@ import { detachPaneFitResizeObserver } from './pane-fit-resize-observer' import { clearPendingSplitScrollRestore } from './pane-split-scroll' +import { cancelDeferredScrollRestore } from './pane-scroll' import { activateOrcaTerminalUnicodeProvider } from '../../../../shared/terminal-unicode-provider' import { attachTerminalMouseWheelMultiplier } from './pane-terminal-mouse-wheel' -import { attachTerminalScrollIntentTracking } from './terminal-scroll-intent' +import { attachTerminalScrollIntentTracking } from './terminal-scroll-intent-dom-tracking' import { attachDomRendererFocusClassSync } from './pane-dom-focus-class-sync' import { attachWebgl, cancelPendingWebglRefresh, disposeWebgl } from './pane-webgl-renderer' import { configureLazyArabicShapingJoiner } from './terminal-arabic-shaping-joiner' @@ -244,6 +245,13 @@ export function disposePane( } catch { /* ignore */ } + try { + // Why: fit retries own xterm markers and frame callbacks independently of + // split restoration; both must be released before terminal disposal. + cancelDeferredScrollRestore(pane.terminal) + } catch { + /* ignore */ + } try { pane.ligaturesAddon?.dispose() } catch { 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 8218de5e856..5f2c3d5fcdb 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-types.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-types.ts @@ -124,6 +124,8 @@ export type ScrollState = { viewportY: number baseY: number firstVisibleLineMarker?: IMarker + firstVisibleLogicalLineMarker?: IMarker + firstVisibleLogicalCellOffset?: number } export type ManagedPaneInternal = { diff --git a/src/renderer/src/lib/pane-manager/pane-scroll.test.ts b/src/renderer/src/lib/pane-manager/pane-scroll.test.ts index 9d1317d8ed4..9378945e40d 100644 --- a/src/renderer/src/lib/pane-manager/pane-scroll.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-scroll.test.ts @@ -1,10 +1,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import { Terminal as HeadlessTerminal } from '@xterm/headless' import type { IMarker, Terminal } from '@xterm/xterm' import { captureScrollState, getTerminalOutputEpoch, recordTerminalOutput, restoreScrollState, + restoreScrollStateAfterFit, restoreScrollStateAfterLayout } from './pane-scroll' import type { ScrollState } from './pane-manager-types' @@ -59,6 +61,24 @@ function setMarkerLine(marker: IMarker, line: number): void { mutableMarker.line = line } +function writeHeadless(terminal: HeadlessTerminal, data: string): Promise { + return new Promise((resolve) => terminal.write(data, resolve)) +} + +function findBufferLineContaining(terminal: HeadlessTerminal, text: string): number { + for (let lineY = 0; lineY < terminal.buffer.active.length; lineY += 1) { + if (terminal.buffer.active.getLine(lineY)?.translateToString(true).includes(text)) { + return lineY + } + } + return -1 +} + +function makeHeadlessRestorable(terminal: HeadlessTerminal): Terminal { + Object.defineProperty(terminal, 'element', { configurable: true, value: {} }) + return terminal as unknown as Terminal +} + describe('scroll state', () => { afterEach(() => { vi.useRealTimers() @@ -286,6 +306,61 @@ describe('scroll state', () => { expect(terminal.buffer.active.viewportY).toBe(30) }) + it('releases fit markers when restoration throws an unexpected error', () => { + const terminal = createTerminal({ viewportY: 10, baseY: 100 }) + const marker = createMarker(42) + vi.mocked(terminal.scrollToLine).mockImplementation(() => { + throw new Error('unexpected renderer failure') + }) + const state: ScrollState = { + bufferType: 'normal', + wasAtBottom: false, + viewportY: 42, + baseY: 100, + firstVisibleLineMarker: marker + } + + expect(() => + restoreScrollStateAfterFit(terminal, state, { + onRestored: vi.fn(), + shouldRestore: () => true + }) + ).toThrow('unexpected renderer failure') + expect(marker.isDisposed).toBe(true) + }) + + it('releases fit markers when an asynchronous retry throws', () => { + const frameCallbacks: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frameCallbacks.push(callback) + return frameCallbacks.length + }) + const terminal = createTerminal({ viewportY: 10, baseY: 100 }) + const marker = createMarker(42) + vi.mocked(terminal.scrollToLine) + .mockImplementationOnce(() => { + throw new TypeError("Cannot read properties of undefined (reading 'dimensions')") + }) + .mockImplementationOnce(() => { + throw new Error('unexpected asynchronous renderer failure') + }) + + restoreScrollStateAfterFit( + terminal, + { + bufferType: 'normal', + wasAtBottom: false, + viewportY: 42, + baseY: 100, + firstVisibleLineMarker: marker + }, + { onRestored: vi.fn(), shouldRestore: () => true } + ) + + expect(() => frameCallbacks.shift()?.(0)).toThrow('unexpected asynchronous renderer failure') + expect(marker.isDisposed).toBe(true) + }) + it('scrolls to the current bottom when the pane was previously at bottom', () => { const terminal = createTerminal({ viewportY: 10, baseY: 250 }) const state: ScrollState = { @@ -316,4 +391,142 @@ describe('scroll state', () => { expect(terminal.scrollToLine).not.toHaveBeenCalled() expect(terminal.buffer.active.viewportY).toBe(10) }) + + it.each([ + { + fromCols: 10, + toCols: 20, + pinnedText: 'abcdefghij', + expectedTop: 'ABCDEFGHIJabcdefghij' + }, + { fromCols: 20, toCols: 7, pinnedText: 'KLMNOPQRSTuvwxyz', expectedTop: 'efghijK' } + ])( + 'restores the same logical cells through real xterm reflow ($fromCols->$toCols)', + async ({ fromCols, toCols, pinnedText, expectedTop }) => { + const headless = new HeadlessTerminal({ + cols: fromCols, + rows: 5, + scrollback: 1000, + allowProposedApi: true + }) + try { + await writeHeadless(headless, 'prefix\r\n') + await writeHeadless(headless, 'ABCDEFGHIJabcdefghijKLMNOPQRSTuvwxyz\r\n') + for (let index = 0; index < 10; index += 1) { + await writeHeadless(headless, `tail-${index}\r\n`) + } + const pinnedLine = findBufferLineContaining(headless, pinnedText) + expect(pinnedLine).toBeGreaterThan(0) + headless.scrollToLine(pinnedLine) + const terminal = makeHeadlessRestorable(headless) + const state = captureScrollState(terminal) + + headless.resize(toCols, 5) + expect(state.firstVisibleLogicalLineMarker?.isDisposed).toBe(false) + expect(restoreScrollState(terminal, state)).toBe(true) + + expect( + headless.buffer.active.getLine(headless.buffer.active.viewportY)?.translateToString(true) + ).toBe(expectedTop) + } finally { + headless.dispose() + } + } + ) + + it('uses a logical marker for backend-only ConPTY compatibility', async () => { + const headless = new HeadlessTerminal({ + cols: 10, + rows: 5, + scrollback: 1000, + allowProposedApi: true, + windowsPty: { backend: 'conpty' } + }) + try { + await writeHeadless(headless, 'prefix\r\n') + await writeHeadless(headless, 'ABCDEFGHIJabcdefghijKLMNOPQRSTuvwxyz\r\n') + for (let index = 0; index < 10; index += 1) { + await writeHeadless(headless, `tail-${index}\r\n`) + } + const pinnedLine = findBufferLineContaining(headless, 'abcdefghij') + headless.scrollToLine(pinnedLine) + const terminal = makeHeadlessRestorable(headless) + const state = captureScrollState(terminal) + + expect(state.firstVisibleLogicalLineMarker).toBeDefined() + headless.resize(20, 5) + expect(restoreScrollState(terminal, state)).toBe(true) + expect( + headless.buffer.active.getLine(headless.buffer.active.viewportY)?.translateToString(true) + ).toBe('ABCDEFGHIJabcdefghij') + } finally { + headless.dispose() + } + }) + + it('keeps a physical marker for the default non-reflowing cursor line', async () => { + const headless = new HeadlessTerminal({ + cols: 10, + rows: 3, + scrollback: 100, + allowProposedApi: true + }) + try { + await writeHeadless(headless, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789') + const terminal = makeHeadlessRestorable(headless) + headless.scrollLines(-1) + const state = captureScrollState(terminal) + + expect(state.firstVisibleLineMarker).toBeDefined() + expect(state.firstVisibleLogicalLineMarker).toBeUndefined() + headless.resize(20, 3) + expect(restoreScrollState(terminal, state)).toBe(true) + } finally { + headless.dispose() + } + }) + + it('uses physical markers for legacy ConPTY and logical markers for modern ConPTY', async () => { + const captureForBuild = async (buildNumber: number): Promise => { + const headless = new HeadlessTerminal({ + cols: 10, + rows: 3, + scrollback: 100, + allowProposedApi: true, + windowsPty: { backend: 'conpty', buildNumber } + }) + await writeHeadless(headless, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789\r\n') + await writeHeadless(headless, 'tail-1\r\ntail-2\r\ntail-3\r\n') + const pinnedLine = findBufferLineContaining(headless, 'KLMNOPQRST') + headless.scrollToLine(pinnedLine) + const state = captureScrollState(makeHeadlessRestorable(headless)) + headless.dispose() + return state + } + + const legacy = await captureForBuild(19045) + const modern = await captureForBuild(26100) + + expect(legacy.firstVisibleLogicalLineMarker).toBeUndefined() + expect(modern.firstVisibleLogicalLineMarker).toBeDefined() + }) + + it('counts a wide glyph wrap placeholder as zero logical cells', async () => { + const headless = new HeadlessTerminal({ + cols: 10, + rows: 3, + scrollback: 100, + allowProposedApi: true + }) + try { + await writeHeadless(headless, '123456789界abcdefghij\r\ntail-1\r\ntail-2\r\ntail-3\r\n') + const pinnedLine = findBufferLineContaining(headless, '界') + headless.scrollToLine(pinnedLine) + const state = captureScrollState(makeHeadlessRestorable(headless)) + + expect(state.firstVisibleLogicalCellOffset).toBe(9) + } finally { + headless.dispose() + } + }) }) diff --git a/src/renderer/src/lib/pane-manager/pane-scroll.ts b/src/renderer/src/lib/pane-manager/pane-scroll.ts index 083c41b4d31..bb79faa4556 100644 --- a/src/renderer/src/lib/pane-manager/pane-scroll.ts +++ b/src/renderer/src/lib/pane-manager/pane-scroll.ts @@ -1,9 +1,14 @@ import type { Terminal } from '@xterm/xterm' import type { ScrollState } from './pane-manager-types' +import { + captureLogicalLineAnchor, + resolveLogicalCellOffsetLine +} from './terminal-reflow-scroll-anchor' +import { forceTerminalViewportScrollbarSync } from './terminal-viewport-scrollbar-sync' const terminalOutputEpochs = new WeakMap() const deferredScrollRestores = new WeakMap< - Terminal, + object, { cancelled: boolean rafIds: number[] @@ -11,6 +16,19 @@ const deferredScrollRestores = new WeakMap< timeoutIds: ReturnType[] } >() +const pendingFitScrollRestores = new WeakMap< + object, + { + cancelled: boolean + rafId: number | null + retryAfterFit: () => boolean + shouldRestore: () => boolean + state: ScrollState + } +>() +const FIT_SCROLL_RESTORE_MAX_FRAMES = 2 + +type ScrollRestoreResult = 'restored' | 'retry' | 'skipped' export function recordTerminalOutput(terminal: Terminal): void { terminalOutputEpochs.set(terminal, getTerminalOutputEpoch(terminal) + 1) @@ -20,7 +38,8 @@ export function getTerminalOutputEpoch(terminal: Terminal): number { return terminalOutputEpochs.get(terminal) ?? 0 } -export function cancelDeferredScrollRestore(terminal: Terminal): void { +export function cancelDeferredScrollRestore(terminal: object): void { + cancelPendingFitScrollRestore(terminal) const pending = deferredScrollRestores.get(terminal) if (!pending) { return @@ -42,24 +61,138 @@ export function captureScrollState(terminal: Terminal): ScrollState { const buf = terminal.buffer.active const viewportY = buf.viewportY const wasAtBottom = viewportY >= buf.baseY + const logicalAnchor = + !wasAtBottom && buf.type === 'normal' + ? captureLogicalLineAnchor(terminal, viewportY) + : undefined + const firstVisibleLineMarker = + !wasAtBottom && buf.type === 'normal' + ? terminal.registerMarker?.(viewportY - (buf.baseY + buf.cursorY)) + : undefined return { bufferType: buf.type, wasAtBottom, viewportY, baseY: buf.baseY, - // Why: xterm markers track the same buffer line through resize reflow; - // a numeric viewport line alone can point at different content afterward. - firstVisibleLineMarker: - !wasAtBottom && buf.type === 'normal' - ? terminal.registerMarker?.(viewportY - (buf.baseY + buf.cursorY)) - : undefined + // Why: continuation-row markers can be deleted or drift during reflow. + // Keep the physical marker for no-reflow ConPTY/cursor-line cases, and + // anchor reflowing content at the logical line's stable first row. + firstVisibleLineMarker, + firstVisibleLogicalLineMarker: + logicalAnchor?.lineY === viewportY + ? firstVisibleLineMarker + : logicalAnchor + ? terminal.registerMarker?.(logicalAnchor.lineY - (buf.baseY + buf.cursorY)) + : undefined, + firstVisibleLogicalCellOffset: logicalAnchor?.cellOffset } } -export function restoreScrollState(terminal: Terminal, state: ScrollState): void { +export function restoreScrollState(terminal: Terminal, state: ScrollState): boolean { cancelDeferredScrollRestore(terminal) - restoreScrollStateNow(terminal, state) - releaseScrollStateMarker(state) + try { + return restoreScrollStateNow(terminal, state) === 'restored' + } finally { + releaseScrollStateMarker(state) + } +} + +export function restoreScrollStateAfterFit( + terminal: Terminal, + state: ScrollState, + options: { onRestored: () => void; shouldRestore: () => boolean } +): void { + cancelDeferredScrollRestore(terminal) + if (!options.shouldRestore()) { + releaseScrollStateMarker(state) + return + } + let initialResult: ScrollRestoreResult + try { + initialResult = restoreScrollStateNow(terminal, state) + } catch (error) { + releaseScrollStateMarker(state) + throw error + } + if (initialResult !== 'retry' || typeof requestAnimationFrame !== 'function') { + releaseScrollStateMarker(state) + if (initialResult === 'restored') { + options.onRestored() + } + return + } + + const pending = { + cancelled: false, + rafId: null as number | null, + retryAfterFit: (): boolean => false, + shouldRestore: options.shouldRestore, + state + } + let remainingFrames = FIT_SCROLL_RESTORE_MAX_FRAMES + const finish = (restored: boolean): void => { + if (pending.cancelled) { + return + } + pending.cancelled = true + pendingFitScrollRestores.delete(terminal) + releaseScrollStateMarker(state) + if (restored && options.shouldRestore()) { + options.onRestored() + } + } + const retry = (): boolean => { + pending.rafId = null + if (pending.cancelled || !options.shouldRestore()) { + finish(false) + return false + } + let result: ScrollRestoreResult + try { + result = restoreScrollStateNow(terminal, state) + } catch (error) { + finish(false) + throw error + } + if (result === 'restored') { + finish(true) + return true + } + remainingFrames -= 1 + if (result !== 'retry') { + finish(false) + return false + } + if (remainingFrames <= 0) { + // Why: background/WebGL teardown can outlast a bounded frame retry. + // Keep the content marker parked for the next real fit/reveal. + return true + } + pending.rafId = requestAnimationFrame(retry) + return true + } + pending.retryAfterFit = () => { + if (pending.rafId !== null && typeof cancelAnimationFrame === 'function') { + cancelAnimationFrame(pending.rafId) + pending.rafId = null + } + remainingFrames = FIT_SCROLL_RESTORE_MAX_FRAMES + 1 + return retry() + } + pendingFitScrollRestores.set(terminal, pending) + pending.rafId = requestAnimationFrame(retry) +} + +export function resumePendingFitScrollRestoreAfterFit(terminal: Terminal): boolean { + const pending = pendingFitScrollRestores.get(terminal) + if (!pending) { + return false + } + if (!pending.shouldRestore()) { + cancelPendingFitScrollRestore(terminal) + return false + } + return pending.retryAfterFit() } export function restoreScrollStateAfterLayout(terminal: Terminal, state: ScrollState): void { @@ -114,13 +247,13 @@ export function restoreScrollStateAfterLayout(terminal: Terminal, state: ScrollS deferredScrollRestores.set(terminal, pending) } -function restoreScrollStateNow(terminal: Terminal, state: ScrollState): void { +function restoreScrollStateNow(terminal: Terminal, state: ScrollState): ScrollRestoreResult { if (!terminal.element) { - return + return 'retry' } const buf = terminal.buffer.active if (state.bufferType === 'alternate' || buf.type !== state.bufferType) { - return + return 'skipped' } // Why: WebGL suspend disposes xterm's render service while leaving @@ -129,24 +262,42 @@ function restoreScrollStateNow(terminal: Terminal, state: ScrollState): void { // window quietly — the next visibility flip re-fits and re-restores. if (state.wasAtBottom) { if (safeScrollCall(() => terminal.scrollToBottom())) { - forceViewportScrollbarSync(terminal) + forceTerminalViewportScrollbarSync(terminal) + return 'restored' } - return + return 'retry' } + const logicalMarkerLine = + state.firstVisibleLogicalLineMarker && !state.firstVisibleLogicalLineMarker.isDisposed + ? state.firstVisibleLogicalLineMarker.line + : -1 const markerLine = state.firstVisibleLineMarker && !state.firstVisibleLineMarker.isDisposed ? state.firstVisibleLineMarker.line : -1 - const targetLine = Math.min(markerLine >= 0 ? markerLine : state.viewportY, buf.baseY) + const logicalTargetLine = + logicalMarkerLine >= 0 && state.firstVisibleLogicalCellOffset !== undefined + ? resolveLogicalCellOffsetLine( + terminal, + logicalMarkerLine, + state.firstVisibleLogicalCellOffset + ) + : null + const targetLine = Math.min( + logicalTargetLine ?? (markerLine >= 0 ? markerLine : state.viewportY), + buf.baseY + ) state.viewportY = targetLine // Why: deferred rAF/timeout restores re-invoke this function after xterm // reflow settles; keep the marker alive so each call consults the live // line. Callers (restoreScrollState, the timeout in // restoreScrollStateAfterLayout, cancelDeferredScrollRestore) own disposal. if (safeScrollCall(() => terminal.scrollToLine(targetLine))) { - forceViewportScrollbarSync(terminal) + forceTerminalViewportScrollbarSync(terminal) + return 'restored' } + return 'retry' } function safeScrollCall(fn: () => void): boolean { @@ -166,23 +317,21 @@ function safeScrollCall(fn: () => void): boolean { export function releaseScrollStateMarker(state: ScrollState): void { state.firstVisibleLineMarker?.dispose() - state.firstVisibleLineMarker = undefined + if (state.firstVisibleLogicalLineMarker !== state.firstVisibleLineMarker) { + state.firstVisibleLogicalLineMarker?.dispose() + } + state.firstVisibleLineMarker = state.firstVisibleLogicalLineMarker = undefined } -// Why: xterm 6 can leave its scrollbar thumb stale when ydisp is unchanged. -// A synchronous one-line jiggle updates the scrollbar without a visible paint. -function forceViewportScrollbarSync(terminal: Terminal): void { - const buf = terminal.buffer.active - if (buf.viewportY >= buf.baseY) { - // Why: jiggle-scrolling at bottom makes xterm stop following active output - // after split-pane resizes; scrollToBottom already places the thumb there. +function cancelPendingFitScrollRestore(terminal: object): void { + const pending = pendingFitScrollRestores.get(terminal) + if (!pending) { return } - if (buf.viewportY > 0) { - safeScrollCall(() => terminal.scrollLines(-1)) - safeScrollCall(() => terminal.scrollLines(1)) - } else if (buf.viewportY < buf.baseY) { - safeScrollCall(() => terminal.scrollLines(1)) - safeScrollCall(() => terminal.scrollLines(-1)) + pending.cancelled = true + if (pending.rafId !== null && typeof cancelAnimationFrame === 'function') { + cancelAnimationFrame(pending.rafId) } + releaseScrollStateMarker(pending.state) + pendingFitScrollRestores.delete(terminal) } 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 cd5c046b226..c57175bb463 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 @@ -7,10 +7,6 @@ import { writeForegroundTerminalChunk, type ForegroundTerminalOutputTarget } from './pane-terminal-foreground-render-settle' -import { - captureTerminalWriteScrollIntent, - enforceTerminalWriteScrollIntent -} from './terminal-scroll-intent' import { runGuardedWriteCompletionStep } from './xterm-write-callback-guard' import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder' import { @@ -833,6 +829,9 @@ function hasDrainableBacklog(): boolean { return false } +// Why no per-write scroll enforcement: xterm's BufferService.isUserScrolling +// natively owns live follow/pin semantics. App-side intent enforcement is +// limited to structural operations xterm cannot identify, such as replay. function writeBackgroundTerminalChunk( terminal: TerminalOutputTarget, data: string, @@ -848,29 +847,13 @@ function writeBackgroundTerminalChunk( const runOnWriteFailure = onWriteFailure ? (): void => runGuardedWriteCompletionStep('background-on-write-failure', onWriteFailure) : undefined - const scrollIntent = captureTerminalWriteScrollIntent(terminal) try { - if (!scrollIntent) { - if (!runOnParsed || terminal.write.length < 2) { - terminal.write(data) - runOnParsed?.() - return true - } - terminal.write(data, runOnParsed) - return true - } - const runScrollIntentThenParsed = (): void => { - runGuardedWriteCompletionStep('background-scroll-intent', () => - enforceTerminalWriteScrollIntent(terminal, scrollIntent) - ) - runOnParsed?.() - } - if (terminal.write.length < 2) { + if (!runOnParsed || terminal.write.length < 2) { terminal.write(data) - runScrollIntentThenParsed() + runOnParsed?.() return true } - terminal.write(data, runScrollIntentThenParsed) + terminal.write(data, runOnParsed) return true } catch { runOnWriteFailure?.() @@ -878,32 +861,6 @@ function writeBackgroundTerminalChunk( } } -function writeForegroundTerminalChunkWithIntent( - terminal: TerminalOutputTarget, - data: string, - options: { - forceViewportRefresh: boolean - followupViewportRefresh: boolean - shouldRefreshViewportSynchronously: ForegroundRefreshSyncResolver - onParsed?: TerminalOutputParsedCallback - onWriteFailure?: () => void - } -): boolean { - const scrollIntent = captureTerminalWriteScrollIntent(terminal) - return writeForegroundTerminalChunk(terminal, data, { - forceViewportRefresh: options.forceViewportRefresh, - followupViewportRefresh: options.followupViewportRefresh, - shouldRefreshViewportSynchronously: options.shouldRefreshViewportSynchronously, - onParsed: () => { - // Why: recovery must repaint from the scrolled buffer state that xterm - // will keep, not from a pre-intent-restored viewport snapshot. - enforceTerminalWriteScrollIntent(terminal, scrollIntent) - options.onParsed?.() - }, - onWriteFailure: options.onWriteFailure - }) -} - function takeNextDrainableEntry(): QueueEntry | null { let largeBacklogEntry: QueueEntry | null = null for (const entry of queuedByTerminal.values()) { @@ -1015,7 +972,7 @@ function writeQueuedChunk(entry: QueueEntry): 'foreground' | 'background' | null try { queuedWrite.beforeWrite?.(queuedWrite.data) const writeAccepted = queuedWrite.foreground - ? writeForegroundTerminalChunkWithIntent( + ? writeForegroundTerminalChunk( entry.terminal, queuedWrite.stripTransientCursorShows ? removeTransientCursorShowSequences(queuedWrite.data) @@ -1052,8 +1009,8 @@ function writeQueuedChunk(entry: QueueEntry): 'foreground' | 'background' | null return null } } catch { - // Why: beforeWrite or pre-write viewport capture can fail before xterm owns - // the bytes. Cancel the armed watch without claiming parser failure. + // Why: beforeWrite or write setup can fail before xterm owns the bytes. + // Cancel the armed watch without claiming parser failure. cancelTerminalWriteStallWatch(entry.terminal) ackCreditsParsed?.() fireQueuedAckCredits(entry) @@ -1305,7 +1262,7 @@ export function writeTerminalOutput( }) try { options.beforeWrite?.(data) - writeForegroundTerminalChunkWithIntent( + writeForegroundTerminalChunk( terminal, options.stripTransientCursorShows ? removeTransientCursorShowSequences(data) : data, { @@ -1399,7 +1356,7 @@ export function flushTerminalOutput( try { queuedWrite.beforeWrite?.(queuedWrite.data) const writeAccepted = queuedWrite.foreground - ? writeForegroundTerminalChunkWithIntent( + ? writeForegroundTerminalChunk( terminal, queuedWrite.stripTransientCursorShows ? removeTransientCursorShowSequences(queuedWrite.data) @@ -1431,7 +1388,7 @@ export function flushTerminalOutput( return } } catch { - // Why: pre-write hooks/capture failed before xterm owned these bytes. + // Why: pre-write hooks/setup failed before xterm owned these bytes. // Cancel the watch; consumed + abandoned chunks still credit delivery. cancelTerminalWriteStallWatch(terminal) ackCreditsParsed?.() diff --git a/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts b/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts index 16a4e0092b6..66c3ed3a17e 100644 --- a/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts @@ -1,7 +1,23 @@ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' -import { equalizePaneSplitSizes, safeFit } from './pane-tree-ops' +import { + cancelPendingSafeFitContinuations, + equalizePaneSplitSizes, + safeFit, + safeFitAndThen +} from './pane-tree-ops' import type { ManagedPaneInternal, ScrollState } from './pane-manager-types' import { setFitOverride, hydrateOverrides } from './mobile-fit-overrides' +import { + captureTerminalStructuralScrollIntent, + enforceTerminalCurrentScrollIntent, + markTerminalPinnedViewport, + restoreTerminalStructuralScrollIntent +} from './terminal-scroll-intent' +import { + beginTerminalScrollIntentBufferRebuild, + cancelTerminalScrollIntentBufferRebuildCompletions, + endTerminalScrollIntentBufferRebuild +} from './terminal-scroll-intent-rebuild' class MockHTMLElement { classList: { contains: (cls: string) => boolean } @@ -21,6 +37,7 @@ beforeAll(() => { afterEach(() => { hydrateOverrides([]) + vi.unstubAllGlobals() }) function createPane({ @@ -178,6 +195,54 @@ describe('safeFit', () => { expect(pane.fitAddon.fit).toHaveBeenCalledTimes(1) }) + it('coalesces fits until replay parsing and scroll restoration complete', async () => { + const pane = createPane({ + proposedCols: 100, + proposedRows: 32, + terminalCols: 120, + terminalRows: 32 + }) + const activeBuffer = pane.terminal.buffer.active as { viewportY: number; baseY: number } + activeBuffer.viewportY = 80 + activeBuffer.baseY = 100 + markTerminalPinnedViewport(pane.terminal) + const intent = captureTerminalStructuralScrollIntent(pane.terminal) + beginTerminalScrollIntentBufferRebuild(pane.terminal) + activeBuffer.viewportY = 0 + activeBuffer.baseY = 0 + + safeFit(pane) + safeFit(pane) + expect(pane.fitAddon.fit).not.toHaveBeenCalled() + + activeBuffer.viewportY = 200 + activeBuffer.baseY = 200 + vi.mocked(pane.fitAddon.fit).mockImplementation(() => { + expect(activeBuffer.viewportY).toBe(180) + }) + endTerminalScrollIntentBufferRebuild(pane.terminal) + restoreTerminalStructuralScrollIntent(pane.terminal, intent, { restoreBy: 'bottomOffset' }) + await Promise.resolve() + + expect(pane.fitAddon.fit).toHaveBeenCalledTimes(1) + }) + + it('drops a deferred fit when a replay rebuild is canceled', async () => { + const pane = createPane({ + proposedCols: 100, + proposedRows: 32, + terminalCols: 120, + terminalRows: 32 + }) + beginTerminalScrollIntentBufferRebuild(pane.terminal) + safeFit(pane) + cancelTerminalScrollIntentBufferRebuildCompletions(pane.terminal) + endTerminalScrollIntentBufferRebuild(pane.terminal) + await Promise.resolve() + + expect(pane.fitAddon.fit).not.toHaveBeenCalled() + }) + it('restores the viewport if fit clobbers it during resize', () => { const pane = createPane({ proposedCols: 100, @@ -231,6 +296,66 @@ describe('safeFit', () => { expect(marker.dispose).toHaveBeenCalled() }) + it('records the restored post-reflow pin when widening lowers baseY', () => { + const pane = createPane({ + proposedCols: 160, + proposedRows: 32, + terminalCols: 80, + terminalRows: 32 + }) + const activeBuffer = pane.terminal.buffer.active as { + viewportY: number + baseY: number + cursorY?: number + } + activeBuffer.viewportY = 42 + activeBuffer.baseY = 100 + activeBuffer.cursorY = 0 + const marker = { line: 42, isDisposed: false, dispose: vi.fn() } + ;(pane.terminal as unknown as { registerMarker: unknown }).registerMarker = vi.fn(() => marker) + markTerminalPinnedViewport(pane.terminal) + vi.mocked(pane.fitAddon.fit).mockImplementation(() => { + activeBuffer.baseY = 70 + activeBuffer.viewportY = 0 + marker.line = 30 + }) + + safeFit(pane) + activeBuffer.viewportY = 0 + vi.mocked(pane.terminal.scrollToLine).mockClear() + enforceTerminalCurrentScrollIntent(pane.terminal) + + expect(pane.terminal.scrollToLine).toHaveBeenLastCalledWith(30) + }) + + it('preserves a durable pin when the remounted fit buffer is still empty', () => { + const pane = createPane({ + proposedCols: 100, + proposedRows: 32, + terminalCols: 80, + terminalRows: 24 + }) + const activeBuffer = pane.terminal.buffer.active as { + viewportY: number + baseY: number + cursorY?: number + } + activeBuffer.viewportY = 42 + activeBuffer.baseY = 100 + activeBuffer.cursorY = 0 + markTerminalPinnedViewport(pane.terminal) + + activeBuffer.viewportY = 0 + activeBuffer.baseY = 0 + safeFit(pane) + + activeBuffer.viewportY = 0 + activeBuffer.baseY = 80 + vi.mocked(pane.terminal.scrollToLine).mockClear() + enforceTerminalCurrentScrollIntent(pane.terminal) + expect(pane.terminal.scrollToLine).toHaveBeenLastCalledWith(22) + }) + it('keeps a follow-output pane at the bottom through fit', () => { const pane = createPane({ proposedCols: 100, @@ -251,22 +376,236 @@ describe('safeFit', () => { expect(pane.terminal.scrollToBottom).toHaveBeenCalled() }) - it('does not throw when xterm rejects scroll restoration during layout', () => { + it('retries a transient dimensions failure before recording the post-fit pin', () => { + const frameCallbacks: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frameCallbacks.push(callback) + return frameCallbacks.length + }) const pane = createPane({ proposedCols: 100, proposedRows: 32, terminalCols: 120, terminalRows: 32 }) - const activeBuffer = pane.terminal.buffer.active as { viewportY: number; baseY: number } + const activeBuffer = pane.terminal.buffer.active as { + viewportY: number + baseY: number + cursorY?: number + } activeBuffer.viewportY = 42 activeBuffer.baseY = 100 - vi.mocked(pane.terminal.scrollToLine).mockImplementation(() => { - throw new TypeError("Cannot read properties of undefined (reading 'dimensions')") + activeBuffer.cursorY = 0 + const marker = { line: 42, isDisposed: false, dispose: vi.fn() } + ;(pane.terminal as unknown as { registerMarker: unknown }).registerMarker = vi.fn(() => marker) + markTerminalPinnedViewport(pane.terminal) + vi.mocked(pane.fitAddon.fit).mockImplementation(() => { + activeBuffer.baseY = 70 + activeBuffer.viewportY = 0 + marker.line = 30 }) + vi.mocked(pane.terminal.scrollToLine) + .mockImplementationOnce(() => { + throw new TypeError("Cannot read properties of undefined (reading 'dimensions')") + }) + .mockImplementation((line: number) => { + activeBuffer.viewportY = line + }) expect(() => safeFit(pane)).not.toThrow() expect(pane.fitAddon.fit).toHaveBeenCalledTimes(1) + expect(activeBuffer.viewportY).toBe(0) + expect(marker.dispose).not.toHaveBeenCalled() + + frameCallbacks.shift()?.(0) + expect(activeBuffer.viewportY).toBe(30) + expect(marker.dispose).toHaveBeenCalled() + + activeBuffer.viewportY = 0 + vi.mocked(pane.terminal.scrollToLine).mockClear() + enforceTerminalCurrentScrollIntent(pane.terminal) + expect(pane.terminal.scrollToLine).toHaveBeenLastCalledWith(30) + }) + + it('cancels a pending fit retry when snapshot replay starts', () => { + const frameCallbacks: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frameCallbacks.push(callback) + return frameCallbacks.length + }) + const pane = createPane({ + proposedCols: 100, + proposedRows: 32, + terminalCols: 120, + terminalRows: 32 + }) + const activeBuffer = pane.terminal.buffer.active as { + viewportY: number + baseY: number + cursorY?: number + } + activeBuffer.viewportY = 42 + activeBuffer.baseY = 100 + activeBuffer.cursorY = 0 + const marker = { line: 30, isDisposed: false, dispose: vi.fn() } + ;(pane.terminal as unknown as { registerMarker: unknown }).registerMarker = vi.fn(() => marker) + markTerminalPinnedViewport(pane.terminal) + vi.mocked(pane.fitAddon.fit).mockImplementation(() => { + activeBuffer.baseY = 70 + activeBuffer.viewportY = 0 + }) + vi.mocked(pane.terminal.scrollToLine).mockImplementationOnce(() => { + throw new TypeError("Cannot read properties of undefined (reading 'dimensions')") + }) + + safeFit(pane) + beginTerminalScrollIntentBufferRebuild(pane.terminal) + frameCallbacks.shift()?.(0) + + expect(pane.terminal.scrollToLine).toHaveBeenCalledTimes(1) + expect(marker.dispose).toHaveBeenCalledTimes(1) + endTerminalScrollIntentBufferRebuild(pane.terminal) + }) + + it('carries the original fit marker across another fit before retry', () => { + const frameCallbacks: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frameCallbacks.push(callback) + return frameCallbacks.length + }) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) + const pane = createPane({ + proposedCols: 100, + proposedRows: 32, + terminalCols: 120, + terminalRows: 32 + }) + const activeBuffer = pane.terminal.buffer.active as { + viewportY: number + baseY: number + cursorY?: number + } + activeBuffer.viewportY = 42 + activeBuffer.baseY = 100 + activeBuffer.cursorY = 0 + const originalMarker = { line: 30, isDisposed: false, dispose: vi.fn() } + const replacementMarker = { line: 5, isDisposed: false, dispose: vi.fn() } + ;(pane.terminal as unknown as { registerMarker: unknown }).registerMarker = vi + .fn() + .mockReturnValueOnce(originalMarker) + .mockReturnValueOnce(replacementMarker) + vi.mocked(pane.fitAddon.fit).mockImplementation(() => { + activeBuffer.baseY = 70 + activeBuffer.viewportY = 0 + }) + vi.mocked(pane.terminal.scrollToLine) + .mockImplementationOnce(() => { + throw new TypeError("Cannot read properties of undefined (reading 'dimensions')") + }) + .mockImplementation((line: number) => { + activeBuffer.viewportY = line + }) + markTerminalPinnedViewport(pane.terminal) + + safeFit(pane) + safeFit(pane) + + expect(activeBuffer.viewportY).toBe(30) + expect(originalMarker.dispose).toHaveBeenCalledTimes(1) + expect(replacementMarker.dispose).toHaveBeenCalledTimes(1) + expect(frameCallbacks).toHaveLength(1) + }) + + it('resumes an exhausted dimensions retry on a same-grid reveal fit', () => { + const frameCallbacks: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frameCallbacks.push(callback) + return frameCallbacks.length + }) + const pane = createPane({ + proposedCols: 100, + proposedRows: 32, + terminalCols: 120, + terminalRows: 32 + }) + const activeBuffer = pane.terminal.buffer.active as { + viewportY: number + baseY: number + cursorY?: number + } + activeBuffer.viewportY = 42 + activeBuffer.baseY = 100 + activeBuffer.cursorY = 0 + const marker = { line: 30, isDisposed: false, dispose: vi.fn() } + ;(pane.terminal as unknown as { registerMarker: unknown }).registerMarker = vi.fn(() => marker) + vi.mocked(pane.fitAddon.fit).mockImplementation(() => { + activeBuffer.baseY = 70 + activeBuffer.viewportY = 0 + }) + vi.mocked(pane.terminal.scrollToLine).mockImplementation(() => { + throw new TypeError("Cannot read properties of undefined (reading 'dimensions')") + }) + markTerminalPinnedViewport(pane.terminal) + + safeFit(pane) + frameCallbacks.shift()?.(0) + frameCallbacks.shift()?.(0) + expect(marker.dispose).not.toHaveBeenCalled() + + vi.mocked(pane.terminal.scrollToLine).mockImplementation((line: number) => { + activeBuffer.viewportY = line + }) + ;(pane.terminal as unknown as { cols: number }).cols = 100 + safeFit(pane) + expect(activeBuffer.viewportY).toBe(30) + expect(marker.dispose).toHaveBeenCalledTimes(1) + }) + + it('releases a replacement marker when a resumed retry throws', () => { + const frameCallbacks: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frameCallbacks.push(callback) + return frameCallbacks.length + }) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) + const pane = createPane({ + proposedCols: 100, + proposedRows: 32, + terminalCols: 120, + terminalRows: 32 + }) + const activeBuffer = pane.terminal.buffer.active as { + viewportY: number + baseY: number + cursorY?: number + } + activeBuffer.viewportY = 42 + activeBuffer.baseY = 100 + activeBuffer.cursorY = 0 + const originalMarker = { line: 30, isDisposed: false, dispose: vi.fn() } + const replacementMarker = { line: 5, isDisposed: false, dispose: vi.fn() } + ;(pane.terminal as unknown as { registerMarker: unknown }).registerMarker = vi + .fn() + .mockReturnValueOnce(originalMarker) + .mockReturnValueOnce(replacementMarker) + vi.mocked(pane.fitAddon.fit).mockImplementation(() => { + activeBuffer.baseY = 70 + activeBuffer.viewportY = 0 + }) + vi.mocked(pane.terminal.scrollToLine) + .mockImplementationOnce(() => { + throw new TypeError("Cannot read properties of undefined (reading 'dimensions')") + }) + .mockImplementationOnce(() => { + throw new Error('unexpected resumed restore failure') + }) + markTerminalPinnedViewport(pane.terminal) + + safeFit(pane) + expect(() => safeFit(pane)).not.toThrow() + + expect(originalMarker.dispose).toHaveBeenCalledTimes(1) + expect(replacementMarker.dispose).toHaveBeenCalledTimes(1) }) it('still refits when a split-scroll lock is active and the grid changed', () => { @@ -396,6 +735,73 @@ describe('safeFit', () => { expect(paneB.fitAddon.fit).toHaveBeenCalledTimes(1) expect(paneB.terminal.resize).not.toHaveBeenCalled() }) + + it('runs an authoritative continuation only after a deferred replay fit', async () => { + const pane = createPane({ + proposedCols: 100, + proposedRows: 32, + terminalCols: 80, + terminalRows: 24 + }) + vi.mocked(pane.fitAddon.fit).mockImplementation(() => { + pane.terminal.resize(100, 32) + }) + const observedDimensions: { cols: number; rows: number }[] = [] + beginTerminalScrollIntentBufferRebuild(pane.terminal) + + safeFitAndThen(pane, 'pty-resize', () => { + observedDimensions.push({ cols: pane.terminal.cols, rows: pane.terminal.rows }) + }) + + expect(pane.fitAddon.fit).not.toHaveBeenCalled() + expect(observedDimensions).toEqual([]) + endTerminalScrollIntentBufferRebuild(pane.terminal) + await Promise.resolve() + + expect(pane.fitAddon.fit).toHaveBeenCalledTimes(1) + expect(observedDimensions).toEqual([{ cols: 100, rows: 32 }]) + }) + + it('retains an authoritative continuation until a later measurable fit succeeds', async () => { + const pane = createPane({ + proposedCols: 100, + proposedRows: 32, + terminalCols: 80, + terminalRows: 24 + }) + vi.mocked(pane.fitAddon.proposeDimensions).mockReturnValue(undefined) + const continuation = vi.fn() + + const pending = safeFitAndThen(pane, 'pty-resize', continuation) + + expect(continuation).not.toHaveBeenCalled() + vi.mocked(pane.fitAddon.proposeDimensions).mockReturnValue({ cols: 100, rows: 32 }) + safeFit(pane) + + await expect(pending.completion).resolves.toBe(true) + expect(continuation).toHaveBeenCalledTimes(1) + }) + + it('cancels an authoritative fit continuation disposed before its post-replay microtask', async () => { + const pane = createPane({ + proposedCols: 100, + proposedRows: 32, + terminalCols: 80, + terminalRows: 24 + }) + const continuation = vi.fn() + beginTerminalScrollIntentBufferRebuild(pane.terminal) + const pending = safeFitAndThen(pane, 'pty-resize', continuation) + + endTerminalScrollIntentBufferRebuild(pane.terminal) + cancelTerminalScrollIntentBufferRebuildCompletions(pane.terminal) + cancelPendingSafeFitContinuations(pane) + await Promise.resolve() + + expect(pane.fitAddon.fit).not.toHaveBeenCalled() + expect(continuation).not.toHaveBeenCalled() + await expect(pending.completion).resolves.toBe(false) + }) }) describe('equalizePaneSplitSizes', () => { diff --git a/src/renderer/src/lib/pane-manager/pane-tree-ops.ts b/src/renderer/src/lib/pane-manager/pane-tree-ops.ts index d069244cf4f..833eb1ff822 100644 --- a/src/renderer/src/lib/pane-manager/pane-tree-ops.ts +++ b/src/renderer/src/lib/pane-manager/pane-tree-ops.ts @@ -6,16 +6,15 @@ import type { PaneStyleOptions } from './pane-manager-types' import { createDivider, disposeDivider } from './pane-divider' -import { getFitOverrideForPty } from './mobile-fit-overrides' import { disposeWebgl, attachWebgl } from './pane-webgl-renderer' -import { - captureTerminalWriteScrollIntent, - enforceTerminalWriteScrollIntent, - syncTerminalScrollIntentFromViewport -} from './terminal-scroll-intent' -import { captureScrollState, releaseScrollStateMarker, restoreScrollState } from './pane-scroll' -import type { ScrollState } from './pane-manager-types' +import { safeFit } from './pane-fit' +export { + cancelPendingSafeFitContinuations, + safeFit, + safeFitAndThen, + type SafeFitContinuationHandle +} from './pane-fit' export { captureScrollState, restoreScrollState } from './pane-scroll' // --------------------------------------------------------------------------- @@ -33,111 +32,6 @@ type TreeOpsCallbacks = { requestPaneReparentFrame?: (callback: FrameRequestCallback) => void } -const MIN_PANE_FIT_WIDTH_PX = 48 -const MIN_PANE_FIT_HEIGHT_PX = 24 -const MIN_PANE_FIT_COLS = 8 -const MIN_PANE_FIT_ROWS = 4 - -function getProposedDimensions(pane: ManagedPane): { cols: number; rows: number } | null { - try { - return pane.fitAddon.proposeDimensions() ?? null - } catch { - return null - } -} - -function canMeasurePaneForFit(pane: ManagedPane): boolean { - const measure = pane.container.getBoundingClientRect - if (typeof measure === 'function') { - const rect = measure.call(pane.container) - if (rect.width < MIN_PANE_FIT_WIDTH_PX || rect.height < MIN_PANE_FIT_HEIGHT_PX) { - return false - } - } - const dims = getProposedDimensions(pane) - if (!dims) { - return false - } - // Why: worktree switches can briefly measure a near-zero overlay before - // fallback positioning lands. Fitting there pins the PTY at ~2 cols until - // the next user-driven resize. - return dims.cols >= MIN_PANE_FIT_COLS && dims.rows >= MIN_PANE_FIT_ROWS -} - -function canPreserveScrollIntentForFit(pane: ManagedPane): boolean { - // Why: split reparent has its own delayed restore; restoring here can fight that timer. - return !( - 'pendingSplitScrollState' in pane && (pane as ManagedPaneInternal).pendingSplitScrollState - ) -} - -export function safeFit(pane: ManagedPane): void { - if (!canMeasurePaneForFit(pane)) { - return - } - let scrollIntent = null as ReturnType - let pinnedScrollState: ScrollState | null = null - let shouldRestoreScroll = false - const captureScrollForFit = (): void => { - scrollIntent = captureTerminalWriteScrollIntent(pane.terminal) - // Why: fit can reflow and renumber every buffer row; a marker tracks the - // pinned content itself, while a numeric line would point elsewhere after. - pinnedScrollState = - scrollIntent?.kind === 'pinnedViewport' ? captureScrollState(pane.terminal) : null - shouldRestoreScroll = true - } - try { - // Why: when a mobile client has resized this PTY to phone dimensions, - // the desktop must keep xterm at those dimensions instead of fitting to - // the desktop pane geometry. This prevents desktop auto-fit from undoing - // the mobile resize. Uses data-pty-id (set by bindPanePtyId) to look up - // the override by ptyId directly, avoiding pane ID collisions across tabs. - const ptyId = pane.container.dataset.ptyId - const override = ptyId ? getFitOverrideForPty(ptyId) : null - if (override) { - if (pane.terminal.cols !== override.cols || pane.terminal.rows !== override.rows) { - if (canPreserveScrollIntentForFit(pane)) { - captureScrollForFit() - } - pane.terminal.resize(override.cols, override.rows) - } - return - } - - const dims = getProposedDimensions(pane) - if (dims && dims.cols === pane.terminal.cols && dims.rows === pane.terminal.rows) { - // Why: divider drags fire refits every frame, but most frames do not - // cross a cell boundary. Skipping those avoids FitAddon.clear()+refresh() - // churn, which was causing visible terminal blinking while resizing. - return - } - if (canPreserveScrollIntentForFit(pane)) { - captureScrollForFit() - } - pane.fitAddon.fit() - } catch { - // Container may not have dimensions yet - } finally { - if (shouldRestoreScroll) { - try { - if (pinnedScrollState) { - restoreScrollState(pane.terminal, pinnedScrollState) - syncTerminalScrollIntentFromViewport(pane.terminal) - } else { - enforceTerminalWriteScrollIntent(pane.terminal, scrollIntent) - } - } catch { - // Why: xterm can temporarily expose a terminal whose renderer has not - // initialized dimensions yet during SSH reattach/layout. Fit is best-effort. - } finally { - if (pinnedScrollState) { - releaseScrollStateMarker(pinnedScrollState) - } - } - } - } -} - export function fitAllPanesInternal(panes: Map): void { for (const pane of panes.values()) { safeFit(pane) diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts b/src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts index 91b95f2caf2..f2a07f8751f 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts @@ -3,6 +3,10 @@ import type { ManagedPaneInternal } from './pane-manager-types' import { disposePane } from './pane-lifecycle' import { suspendPaneRendering } from './pane-rendering-control' import { disposeWebgl } from './pane-webgl-renderer' +import { + beginTerminalScrollIntentBufferRebuild, + endTerminalScrollIntentBufferRebuild +} from './terminal-scroll-intent-rebuild' function createPane( overrides: Partial> = {} @@ -14,11 +18,17 @@ function createPane( stablePaneId: leafId, terminal: { element: null, + cols: 80, rows: 24, + buffer: { active: { type: 'normal', viewportY: 0, baseY: 0 } }, refresh: vi.fn(), + resize: vi.fn(), dispose: vi.fn() } as never, - container: {} as never, + container: { + dataset: {}, + getBoundingClientRect: () => ({ width: 800, height: 600 }) + } as never, xtermContainer: {} as never, linkTooltip: {} as never, terminalGpuAcceleration: 'off', @@ -28,6 +38,7 @@ function createPane( hasComplexScriptOutput: false, fitAddon: { fit: vi.fn(), + proposeDimensions: vi.fn(() => ({ cols: 100, rows: 24 })), dispose: vi.fn() } as never, fitResizeObserver: null, @@ -66,6 +77,26 @@ describe('pane WebGL refresh lifecycle', () => { expect(pane.pendingWebglRefreshRafId).toBe(29) }) + it('defers the DOM-renderer refit until structural replay completes', async () => { + const refreshFrame: { current: FrameRequestCallback | null } = { current: null } + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + refreshFrame.current = callback + return 29 + }) + const pane = createPane() + beginTerminalScrollIntentBufferRebuild(pane.terminal) + + disposeWebgl(pane, { refreshDimensions: true }) + refreshFrame.current?.(0) + expect(pane.fitAddon.fit).not.toHaveBeenCalled() + expect(pane.terminal.refresh).not.toHaveBeenCalled() + + endTerminalScrollIntentBufferRebuild(pane.terminal) + await Promise.resolve() + expect(pane.fitAddon.fit).toHaveBeenCalledTimes(1) + expect(pane.terminal.refresh).toHaveBeenCalledTimes(1) + }) + it('actively releases the xterm WebGL context before disposing the addon', () => { const loseContext = vi.fn() const canvas = { width: 120, height: 40 } diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts index 7ae295b4a9d..790ce3a74d7 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts @@ -6,6 +6,7 @@ import { getTerminalWebglAutoDecision, resetTerminalWebglAutoDecision } from './terminal-webgl-auto-policy' +import { safeFitAndThen } from './pane-fit' export const ENABLE_WEBGL_RENDERER = true let suggestedRendererType: 'dom' | undefined @@ -90,8 +91,11 @@ export function disposeWebgl( pane.pendingWebglRefreshRafId = requestAnimationFrame(() => { pane.pendingWebglRefreshRafId = null try { - pane.fitAddon.fit() - pane.terminal.refresh(0, pane.terminal.rows - 1) + // Why: context loss can coincide with snapshot parsing; refresh only + // after the replay-aware fit has authoritative renderer dimensions. + safeFitAndThen(pane, 'webgl-fallback-refresh', () => { + pane.terminal.refresh(0, pane.terminal.rows - 1) + }) } catch { /* ignore — pane may have been disposed in the meantime */ } diff --git a/src/renderer/src/lib/pane-manager/terminal-linkifier-hover-reset.test.ts b/src/renderer/src/lib/pane-manager/terminal-linkifier-hover-reset.test.ts new file mode 100644 index 00000000000..aca9b7034d5 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-linkifier-hover-reset.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import type { Terminal } from '@xterm/xterm' +import { resetTerminalLinkifierHoverState } from './terminal-linkifier-hover-reset' + +type FakeLinkifier = { _lastBufferCell?: unknown; _activeLine?: number } + +function createTerminal(linkifier: FakeLinkifier | null | undefined, hasCore = true): Terminal { + const core = hasCore ? { linkifier: linkifier ?? undefined } : undefined + return { _core: core } as unknown as Terminal +} + +describe('resetTerminalLinkifierHoverState', () => { + it('clears the hover cell cache and active line so the next mousemove re-linkifies', () => { + const linkifier: FakeLinkifier = { _lastBufferCell: { x: 5, y: 5 }, _activeLine: 5 } + resetTerminalLinkifierHoverState(createTerminal(linkifier)) + + expect(linkifier._lastBufferCell).toBeUndefined() + expect(linkifier._activeLine).toBe(-1) + }) + + it('is a no-op when the linkifier or core internals are unavailable', () => { + expect(() => resetTerminalLinkifierHoverState(createTerminal(null))).not.toThrow() + expect(() => resetTerminalLinkifierHoverState(createTerminal(undefined, false))).not.toThrow() + }) + + it('only touches fields that exist so a renamed xterm internal degrades safely', () => { + const linkifier: FakeLinkifier = {} + resetTerminalLinkifierHoverState(createTerminal(linkifier)) + + expect('_lastBufferCell' in linkifier).toBe(false) + expect('_activeLine' in linkifier).toBe(false) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/terminal-linkifier-hover-reset.ts b/src/renderer/src/lib/pane-manager/terminal-linkifier-hover-reset.ts new file mode 100644 index 00000000000..2c93f354ac0 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-linkifier-hover-reset.ts @@ -0,0 +1,46 @@ +import type { Terminal } from '@xterm/xterm' + +type LinkifierHoverCache = { + _lastBufferCell?: unknown + _activeLine?: number +} + +type TerminalCoreWithLinkifier = { + _core?: { + linkifier?: LinkifierHoverCache + } +} + +/** + * Force xterm's linkifier to re-run link providers on the next mousemove. + * + * Why: when a terminal is hidden (worktree/tab switch) the browser fires + * `mouseleave`, which clears the linkifier's current link but leaves its + * `_lastBufferCell` cache set. On reveal the pointer usually returns to the + * same cell, so xterm's mousemove handler short-circuits (position unchanged) + * and never re-linkifies — the link (file path, URL, term_* handle, OSC-8) + * stays dead until a scroll shifts the buffer position. Clearing the cell/line + * cache makes the next mousemove re-evaluate providers so the link and its + * hover underline recover without a scroll. + * + * Reaches into xterm internals (`@xterm/xterm` 6.1.0-beta.287 `Linkifier`) + * because there is no public API to invalidate the hover cache. Guarded so a + * future xterm build that renames these fields degrades to the pre-fix + * behavior (link recovers on the next genuine cell change) instead of throwing. + */ +export function resetTerminalLinkifierHoverState(terminal: Terminal): void { + try { + const linkifier = (terminal as unknown as TerminalCoreWithLinkifier)._core?.linkifier + if (!linkifier) { + return + } + if ('_lastBufferCell' in linkifier) { + linkifier._lastBufferCell = undefined + } + if ('_activeLine' in linkifier) { + linkifier._activeLine = -1 + } + } catch { + /* linkifier internals unavailable — link recovers on the next cell change */ + } +} diff --git a/src/renderer/src/lib/pane-manager/terminal-reflow-scroll-anchor.ts b/src/renderer/src/lib/pane-manager/terminal-reflow-scroll-anchor.ts new file mode 100644 index 00000000000..c7ed2bcce17 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-reflow-scroll-anchor.ts @@ -0,0 +1,137 @@ +import type { Terminal } from '@xterm/xterm' + +type ReflowLineReader = { + getCellMetrics: (lineY: number, column: number) => { code: number; width: number } | undefined + isWrapped: (lineY: number) => boolean +} + +type TerminalWithInternalBufferLines = Terminal & { + _core?: { + _bufferService?: { + buffer?: { + lines?: { + get: (lineY: number) => + | { + getCodePoint: (column: number) => number + getWidth: (column: number) => number + isWrapped: boolean + length: number + } + | undefined + } + } + } + } +} + +export function captureLogicalLineAnchor( + terminal: Terminal, + viewportY: number +): { cellOffset: number; lineY: number } | undefined { + const buf = terminal.buffer.active + if (typeof buf.getLine !== 'function' || shouldKeepPhysicalResizeAnchor(terminal)) { + return undefined + } + const lines = createReflowLineReader(terminal) + let lineY = viewportY + while (lineY > 0 && lines.isWrapped(lineY)) { + lineY -= 1 + } + const cursorLineY = buf.baseY + buf.cursorY + if (terminal.options?.reflowCursorLine !== true && lineContainsLine(lines, lineY, cursorLineY)) { + return undefined + } + let cellOffset = 0 + for (let currentLineY = lineY; currentLineY < viewportY; currentLineY += 1) { + cellOffset += readReflowedRowCellCount(terminal, lines, currentLineY) + } + return { cellOffset, lineY } +} + +function shouldKeepPhysicalResizeAnchor(terminal: Terminal): boolean { + const windowsPty = terminal.options?.windowsPty + if (!windowsPty?.buildNumber) { + return false + } + // Why: xterm disables reflow only when an explicit legacy build is present; + // Orca's backend-only fallback for an unknown Windows build still reflows. + return windowsPty.backend !== 'conpty' || windowsPty.buildNumber < 21376 +} + +function lineContainsLine( + lines: ReflowLineReader, + logicalStartY: number, + targetY: number +): boolean { + if (targetY < logicalStartY) { + return false + } + for (let lineY = logicalStartY + 1; lineY <= targetY; lineY += 1) { + if (!lines.isWrapped(lineY)) { + return false + } + } + return true +} + +export function resolveLogicalCellOffsetLine( + terminal: Terminal, + logicalStartY: number, + cellOffset: number +): number { + const buf = terminal.buffer.active + const lines = createReflowLineReader(terminal) + let lineY = logicalStartY + let remainingCells = cellOffset + while (lineY < buf.baseY && lines.isWrapped(lineY + 1)) { + const rowCells = readReflowedRowCellCount(terminal, lines, lineY) + if (remainingCells < rowCells) { + break + } + remainingCells -= rowCells + lineY += 1 + } + return lineY +} + +function readReflowedRowCellCount( + terminal: Terminal, + lines: ReflowLineReader, + lineY: number +): number { + const cols = Math.max(terminal.cols, 1) + const lastCell = lines.getCellMetrics(lineY, cols - 1) + const nextFirstCell = lines.getCellMetrics(lineY + 1, 0) + // Why: xterm wraps a width-2 glyph one cell early when only the last column + // remains. That placeholder is not part of the logical cell offset. + return lastCell?.code === 0 && lastCell.width === 1 && nextFirstCell?.width === 2 + ? cols - 1 + : cols +} + +function createReflowLineReader(terminal: Terminal): ReflowLineReader { + const internalLines = (terminal as TerminalWithInternalBufferLines)._core?._bufferService?.buffer + ?.lines + if (internalLines) { + // Why: public getLine/getCell allocate wrapper objects per row/cell. The + // pinned xterm core exposes the same active lines without resize-path GC. + return { + isWrapped: (lineY) => internalLines.get(lineY)?.isWrapped ?? false, + getCellMetrics: (lineY, column) => { + const line = internalLines.get(lineY) + if (!line || column < 0 || column >= line.length) { + return undefined + } + return { code: line.getCodePoint(column), width: line.getWidth(column) } + } + } + } + const buffer = terminal.buffer.active + return { + isWrapped: (lineY) => buffer.getLine(lineY)?.isWrapped ?? false, + getCellMetrics: (lineY, column) => { + const cell = buffer.getLine(lineY)?.getCell(column) + return cell ? { code: cell.getCode(), width: cell.getWidth() } : undefined + } + } +} diff --git a/src/renderer/src/lib/pane-manager/terminal-scroll-buffer-snapshot.ts b/src/renderer/src/lib/pane-manager/terminal-scroll-buffer-snapshot.ts new file mode 100644 index 00000000000..730adc5a97b --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-scroll-buffer-snapshot.ts @@ -0,0 +1,53 @@ +export type TerminalScrollBufferType = 'normal' | 'alternate' + +export type TerminalScrollBufferTarget = { + buffer?: { + active?: { + type?: string + viewportY?: number + baseY?: number + } + } +} + +export type TerminalScrollBufferSnapshot = { + bufferType: TerminalScrollBufferType + viewportY: number + baseY: number +} + +export function readTerminalScrollBufferSnapshot( + terminal: TerminalScrollBufferTarget +): TerminalScrollBufferSnapshot | null { + const buffer = terminal.buffer?.active + const viewportY = buffer?.viewportY + const baseY = buffer?.baseY + if (typeof viewportY !== 'number' || typeof baseY !== 'number') { + return null + } + return { + bufferType: buffer?.type === 'alternate' ? 'alternate' : 'normal', + viewportY, + baseY + } +} + +export function isTerminalViewportAtBottom(viewportY: number, baseY: number): boolean { + return viewportY >= baseY +} + +export function clampTerminalViewportY(viewportY: number, baseY: number): number { + return Math.max(0, Math.min(viewportY, baseY)) +} + +export function safeTerminalScrollCall(scroll: () => void): boolean { + try { + scroll() + return true + } catch (err) { + if (err instanceof TypeError && /dimensions/.test(err.message)) { + return false + } + throw err + } +} diff --git a/src/renderer/src/lib/pane-manager/terminal-scroll-intent-dom-tracking.ts b/src/renderer/src/lib/pane-manager/terminal-scroll-intent-dom-tracking.ts new file mode 100644 index 00000000000..9545615705c --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-scroll-intent-dom-tracking.ts @@ -0,0 +1,254 @@ +import type { IDisposable } from '@xterm/xterm' +import { + bindTerminalScrollIntentKey, + enforceTerminalCurrentScrollIntent, + getTerminalScrollIntentKind, + isTerminalScrollIntentKeyBindingCurrent, + markTerminalPinnedViewport, + syncTerminalScrollIntentFromViewport +} from './terminal-scroll-intent' +import { syncTerminalScrollIntentSoon } from './terminal-scroll-intent-settle' +import type { TerminalScrollIntentKey, TerminalScrollIntentTarget } from './terminal-scroll-intent' +import { + isTerminalScrollIntentRebuildInFlight, + onTerminalScrollIntentBufferRebuildComplete +} from './terminal-scroll-intent-rebuild' + +const XTERM_SCROLL_INTENT_POINTER_TARGET_CLASSES = [ + 'xterm-viewport', + 'xterm-scrollbar', + 'xterm-slider' +] as const +const XTERM_SCROLL_INTENT_POINTER_TARGET_SELECTOR = XTERM_SCROLL_INTENT_POINTER_TARGET_CLASSES.map( + (className) => `.${className}` +).join(',') + +function isTerminalScrollIntentPointerTarget(target: EventTarget | null): target is Element { + if (typeof Element === 'undefined' || !(target instanceof Element)) { + return false + } + // xterm's custom scrollbar uses separate thumb/track nodes from the viewport. + return target.closest(XTERM_SCROLL_INTENT_POINTER_TARGET_SELECTOR) !== null +} + +type TerminalWithOnData = { + onData?: (listener: (data: string) => void) => { dispose?: unknown } | undefined + _core?: { + coreService?: { + onUserInput?: (listener: () => void) => { dispose?: unknown } | undefined + } + } +} + +// Mouse reports (SGR "\x1b[ boolean, + captureInteractionRevision: () => number, + resyncUserInput: (interactionRevision: number) => void +): { dispose: () => void } | null { + const terminalWithInput = terminal as TerminalWithOnData + const onData = terminalWithInput.onData + if (typeof onData !== 'function') { + return null + } + const onUserInput = terminalWithInput._core?.coreService?.onUserInput + let pendingUserInputRevision: number | null = null + try { + const dataSubscription = onData((data: string) => { + if (isMouseReportInput(data)) { + pendingUserInputRevision = null + if (isActive() && getTerminalScrollIntentKind(terminal) === 'pinnedViewport') { + // Why: xterm treats mouse reports as user input and scrolls bottom + // before onData. Restore the reading position before output follows. + enforceTerminalCurrentScrollIntent(terminal) + } + return + } + if (typeof onUserInput === 'function') { + const interactionRevision = pendingUserInputRevision + pendingUserInputRevision = null + if (interactionRevision !== null && isActive()) { + resyncUserInput(interactionRevision) + } + } else if (isActive()) { + // Compatibility fallback for test doubles or an unexpected xterm + // shape; pinned production xterm uses onUserInput below. + resyncUserInput(captureInteractionRevision()) + } + }) + const userInputSubscription = onUserInput?.(() => { + // Why: xterm emits onUserInput immediately before its matching onData. + // Reserve order here, then let onData classify typing versus mouse. + pendingUserInputRevision = captureInteractionRevision() + }) + return { + dispose: () => { + if (dataSubscription && typeof dataSubscription.dispose === 'function') { + dataSubscription.dispose() + } + if (userInputSubscription && typeof userInputSubscription.dispose === 'function') { + userInputSubscription.dispose() + } + } + } + } catch { + return null + } +} + +/** Wires the user-driven scroll signals (wheel, scrollbar pointer drags) that + * are allowed to change a terminal's scroll intent. Output-driven scroll + * events deliberately do not update intent (see terminal-scroll-intent.ts). */ +export function attachTerminalScrollIntentTracking( + terminal: TerminalScrollIntentTarget, + host: HTMLElement, + intentKey?: TerminalScrollIntentKey +): IDisposable { + if (!bindTerminalScrollIntentKey(terminal, intentKey)) { + syncTerminalScrollIntentFromViewport(terminal) + } + let disposed = false + const isActive = (): boolean => !disposed + let pointerScrollActive = false + let cancelPostRebuildSync: (() => void) | null = null + let nextInteractionRevision = 0 + let latestCommittedInteractionRevision = 0 + let postRebuildSync: { revision: number; mode: 'sample' | 'preservePinnedAtBottom' } | null = null + const captureInteractionRevision = (): number => (nextInteractionRevision += 1) + + const syncFromViewportOrAfterRebuild = ( + mode: 'sample' | 'preservePinnedAtBottom' = 'sample', + interactionRevision = captureInteractionRevision() + ): boolean => { + if (interactionRevision < latestCommittedInteractionRevision) { + return false + } + latestCommittedInteractionRevision = interactionRevision + if (!isTerminalScrollIntentRebuildInFlight(terminal)) { + syncTerminalScrollIntentFromViewport(terminal, { allowBufferShrink: true }) + return true + } + postRebuildSync = { revision: interactionRevision, mode } + if (!cancelPostRebuildSync) { + cancelPostRebuildSync = onTerminalScrollIntentBufferRebuildComplete(terminal, (completed) => { + cancelPostRebuildSync = null + const pendingSync = postRebuildSync + postRebuildSync = null + if ( + completed && + isActive() && + pendingSync && + pendingSync.revision === latestCommittedInteractionRevision + ) { + // Why: wheel/scrollbar movement during replay must be sampled from + // the completed buffer, never from its transient cleared rows. + const preservePinnedAtBottom = pendingSync.mode === 'preservePinnedAtBottom' + if ( + preservePinnedAtBottom && + getTerminalScrollIntentKind(terminal) !== 'pinnedViewport' + ) { + markTerminalPinnedViewport(terminal) + } + syncTerminalScrollIntentFromViewport(terminal, { + allowBufferShrink: true, + preservePinnedAtBottom + }) + if (preservePinnedAtBottom) { + // Why: an upward wheel or scrollbar gesture against the cleared 0/0 + // buffer must not erase the durable pin. Settle after restoration so + // a real move wins and a no-op gesture can still return to follow. + syncTerminalScrollIntentSoon(terminal, { + allowBufferShrink: true, + preservePinnedAtBottom: true, + shouldSync: isActive + }) + } + } + }) + } + return false + } + const userInputResync = subscribeScrollIntentUserInputResync( + terminal, + isActive, + captureInteractionRevision, + (interactionRevision) => syncFromViewportOrAfterRebuild('sample', interactionRevision) + ) + + const onWheel = (event: WheelEvent): void => { + if (!syncFromViewportOrAfterRebuild(event.deltaY < 0 ? 'preservePinnedAtBottom' : 'sample')) { + return + } + if (event.deltaY < 0) { + markTerminalPinnedViewport(terminal) + syncTerminalScrollIntentSoon(terminal, { + preservePinnedAtBottom: true, + shouldSync: isActive + }) + return + } + syncTerminalScrollIntentSoon(terminal, { shouldSync: isActive }) + } + + const onPointerDown = (event: PointerEvent): void => { + pointerScrollActive = isTerminalScrollIntentPointerTarget(event.target) + } + + const onPointerDone = (): void => { + if (!pointerScrollActive) { + return + } + pointerScrollActive = false + syncFromViewportOrAfterRebuild('preservePinnedAtBottom') + } + + const onScroll = (): void => { + if (pointerScrollActive) { + syncFromViewportOrAfterRebuild('preservePinnedAtBottom') + } + } + + host.addEventListener('wheel', onWheel, { capture: true, passive: true }) + host.addEventListener('pointerdown', onPointerDown, true) + host.addEventListener('scroll', onScroll, true) + globalThis.addEventListener?.('pointerup', onPointerDone, true) + globalThis.addEventListener?.('pointercancel', onPointerDone, true) + return { + dispose: () => { + // Why: native pinned output can grow baseY without a DOM scroll event; + // persist that geometry before remount, but never let an old instance + // overwrite a successor already bound to the same leaf key. + if (isTerminalScrollIntentKeyBindingCurrent(terminal)) { + syncTerminalScrollIntentFromViewport(terminal) + } + disposed = true + cancelPostRebuildSync?.() + cancelPostRebuildSync = null + postRebuildSync = null + userInputResync?.dispose() + host.removeEventListener('wheel', onWheel, true) + host.removeEventListener('pointerdown', onPointerDown, true) + host.removeEventListener('scroll', onScroll, true) + globalThis.removeEventListener?.('pointerup', onPointerDone, true) + globalThis.removeEventListener?.('pointercancel', onPointerDone, true) + } + } +} diff --git a/src/renderer/src/lib/pane-manager/terminal-scroll-intent-input-resync.test.ts b/src/renderer/src/lib/pane-manager/terminal-scroll-intent-input-resync.test.ts new file mode 100644 index 00000000000..c3739b5c470 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-scroll-intent-input-resync.test.ts @@ -0,0 +1,298 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { attachTerminalScrollIntentTracking } from './terminal-scroll-intent-dom-tracking' +import { + captureTerminalStructuralScrollIntent, + enforceTerminalCurrentScrollIntent, + getTerminalScrollIntentKind, + markTerminalPinnedViewport, + restoreTerminalStructuralScrollIntent +} from './terminal-scroll-intent' +import { + beginTerminalScrollIntentBufferRebuild, + endTerminalScrollIntentBufferRebuild +} from './terminal-scroll-intent-rebuild' + +function createTerminal(viewportY: number, baseY: number) { + const terminal = { + buffer: { active: { type: 'normal' as const, viewportY, baseY } }, + scrollToBottom: vi.fn(() => { + terminal.buffer.active.viewportY = terminal.buffer.active.baseY + }), + scrollToLine: vi.fn((line: number) => { + terminal.buffer.active.viewportY = line + }), + onData: undefined as + | ((listener: (data: string) => void) => { dispose: () => void }) + | undefined, + _core: undefined as + | { coreService: { onUserInput: (listener: () => void) => { dispose: () => void } } } + | undefined + } + return terminal +} + +class TestElement extends EventTarget { + parentElement: TestElement | null = null + readonly classList = { + contains: (className: string): boolean => this.className.split(/\s+/).includes(className) + } + + constructor(public className = '') { + super() + } + + closest(selector: string): TestElement | null { + for (const candidate of selector.split(',')) { + const trimmed = candidate.trim() + if (trimmed.startsWith('.') && this.classList.contains(trimmed.slice(1))) { + return this + } + } + return this.parentElement?.closest(selector) ?? null + } +} + +function createTerminalWithInputCapture(viewportY: number, baseY: number) { + const capturedInput: { listener: ((data: string) => void) | null } = { listener: null } + const capturedUserInput: { listener: (() => void) | null } = { listener: null } + const terminal = createTerminal(viewportY, baseY) + terminal.onData = (listener: (data: string) => void) => { + capturedInput.listener = listener + return { dispose: vi.fn() } + } + terminal._core = { + coreService: { + onUserInput: (listener: () => void) => { + capturedUserInput.listener = listener + return { dispose: vi.fn() } + } + } + } + return { terminal, capturedInput, capturedUserInput } +} + +afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() +}) + +describe('terminal scroll-intent input resync', () => { + it('heals a stale pin when typing scrolls the terminal to the bottom', () => { + vi.stubGlobal('requestAnimationFrame', () => 0) + vi.stubGlobal('Element', TestElement) + const { terminal, capturedInput, capturedUserInput } = createTerminalWithInputCapture(42, 100) + const host = new TestElement() as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + + markTerminalPinnedViewport(terminal) + terminal.buffer.active.viewportY = terminal.buffer.active.baseY + capturedUserInput.listener?.() + capturedInput.listener?.('a') + + expect(getTerminalScrollIntentKind(terminal)).toBe('followOutput') + disposable.dispose() + }) + + it('heals a stale pre-reflow pin when typing reaches a shorter buffer bottom', () => { + vi.stubGlobal('requestAnimationFrame', () => 0) + vi.stubGlobal('Element', TestElement) + const { terminal, capturedInput, capturedUserInput } = createTerminalWithInputCapture(42, 100) + const host = new TestElement() as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + + markTerminalPinnedViewport(terminal) + terminal.buffer.active.baseY = 70 + terminal.buffer.active.viewportY = 70 + capturedUserInput.listener?.() + capturedInput.listener?.('a') + + expect(getTerminalScrollIntentKind(terminal)).toBe('followOutput') + disposable.dispose() + }) + + it('keeps a real pin when app-consumed input does not move the viewport', () => { + vi.stubGlobal('requestAnimationFrame', () => 0) + vi.stubGlobal('Element', TestElement) + const { terminal, capturedInput, capturedUserInput } = createTerminalWithInputCapture(42, 100) + const host = new TestElement() as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + + markTerminalPinnedViewport(terminal) + capturedUserInput.listener?.() + capturedInput.listener?.('\x1b[5~') + + expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport') + terminal.buffer.active.viewportY = 0 + enforceTerminalCurrentScrollIntent(terminal) + expect(terminal.scrollToLine).toHaveBeenLastCalledWith(42) + disposable.dispose() + }) + + it('does not reclassify a pin from mouse reports even when they scroll to bottom', () => { + vi.stubGlobal('requestAnimationFrame', () => 0) + vi.stubGlobal('Element', TestElement) + const { terminal, capturedInput, capturedUserInput } = createTerminalWithInputCapture(42, 100) + const host = new TestElement() as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + + markTerminalPinnedViewport(terminal) + terminal.buffer.active.viewportY = terminal.buffer.active.baseY + capturedUserInput.listener?.() + capturedInput.listener?.('\x1b[<35;10;5M') + + expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport') + expect(terminal.buffer.active.viewportY).toBe(42) + expect(terminal.scrollToLine).toHaveBeenLastCalledWith(42) + disposable.dispose() + }) + + it('does not let a focus reply make a following mouse report reclassify the pin', () => { + vi.stubGlobal('requestAnimationFrame', () => 0) + vi.stubGlobal('Element', TestElement) + const { terminal, capturedInput, capturedUserInput } = createTerminalWithInputCapture(42, 100) + const host = new TestElement() as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + + markTerminalPinnedViewport(terminal) + capturedInput.listener?.('\x1b[I') + terminal.buffer.active.viewportY = terminal.buffer.active.baseY + capturedUserInput.listener?.() + capturedInput.listener?.('\x1b[<0;10;5M') + + expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport') + disposable.dispose() + }) + + it('ignores a parser reply that has no matching user-input signal', () => { + vi.stubGlobal('requestAnimationFrame', () => 0) + vi.stubGlobal('Element', TestElement) + const { terminal, capturedInput } = createTerminalWithInputCapture(42, 100) + const host = new TestElement() as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + + markTerminalPinnedViewport(terminal) + terminal.buffer.active.viewportY = terminal.buffer.active.baseY + capturedInput.listener?.('\x1b[1;1R') + + expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport') + disposable.dispose() + }) + + it('does not apply input resync after tracking is disposed', () => { + vi.stubGlobal('requestAnimationFrame', () => 0) + vi.stubGlobal('Element', TestElement) + const { terminal, capturedInput, capturedUserInput } = createTerminalWithInputCapture(42, 100) + const host = new TestElement() as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + + markTerminalPinnedViewport(terminal) + capturedUserInput.listener?.() + disposable.dispose() + terminal.buffer.active.viewportY = terminal.buffer.active.baseY + capturedInput.listener?.('a') + + expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport') + }) + + it('defers real typing intent until a snapshot rebuild completes', () => { + vi.stubGlobal('requestAnimationFrame', () => 0) + vi.stubGlobal('Element', TestElement) + const { terminal, capturedInput, capturedUserInput } = createTerminalWithInputCapture(42, 100) + const host = new TestElement() as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + markTerminalPinnedViewport(terminal) + const staleIntent = captureTerminalStructuralScrollIntent(terminal) + beginTerminalScrollIntentBufferRebuild(terminal) + + terminal.buffer.active.baseY = 5 + terminal.buffer.active.viewportY = 5 + capturedUserInput.listener?.() + capturedInput.listener?.('a') + terminal.buffer.active.baseY = 200 + terminal.buffer.active.viewportY = 200 + endTerminalScrollIntentBufferRebuild(terminal) + restoreTerminalStructuralScrollIntent(terminal, staleIntent, { restoreBy: 'bottomOffset' }) + + expect(getTerminalScrollIntentKind(terminal)).toBe('followOutput') + disposable.dispose() + }) + + it('lets later typing supersede a wheel-up pin during snapshot replay', () => { + vi.stubGlobal('requestAnimationFrame', () => 0) + vi.stubGlobal('Element', TestElement) + const { terminal, capturedInput, capturedUserInput } = createTerminalWithInputCapture(80, 100) + const host = new TestElement() as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + markTerminalPinnedViewport(terminal) + const staleIntent = captureTerminalStructuralScrollIntent(terminal) + beginTerminalScrollIntentBufferRebuild(terminal) + + terminal.buffer.active.viewportY = 0 + terminal.buffer.active.baseY = 0 + const wheel = new Event('wheel') as WheelEvent + Object.defineProperty(wheel, 'deltaY', { value: -10 }) + host.dispatchEvent(wheel) + capturedUserInput.listener?.() + capturedInput.listener?.('a') + terminal.buffer.active.viewportY = 200 + terminal.buffer.active.baseY = 200 + endTerminalScrollIntentBufferRebuild(terminal) + restoreTerminalStructuralScrollIntent(terminal, staleIntent, { restoreBy: 'bottomOffset' }) + + expect(getTerminalScrollIntentKind(terminal)).toBe('followOutput') + disposable.dispose() + }) + + it('ignores parser auto-replies while a snapshot rebuild is partial', () => { + vi.stubGlobal('requestAnimationFrame', () => 0) + vi.stubGlobal('Element', TestElement) + const { terminal, capturedInput } = createTerminalWithInputCapture(42, 100) + const host = new TestElement() as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + markTerminalPinnedViewport(terminal) + const intent = captureTerminalStructuralScrollIntent(terminal) + beginTerminalScrollIntentBufferRebuild(terminal) + + terminal.buffer.active.baseY = 5 + terminal.buffer.active.viewportY = 5 + capturedInput.listener?.('\x1b[1;1R') + terminal.buffer.active.baseY = 200 + terminal.buffer.active.viewportY = 200 + endTerminalScrollIntentBufferRebuild(terminal) + restoreTerminalStructuralScrollIntent(terminal, intent, { restoreBy: 'bottomOffset' }) + + expect(terminal.scrollToLine).toHaveBeenLastCalledWith(142) + disposable.dispose() + }) + + it('supports bottom-offset restore for structural buffer rebuilds', () => { + const terminal = createTerminal(550, 600) + markTerminalPinnedViewport(terminal) + const snapshot = captureTerminalStructuralScrollIntent(terminal) + terminal.buffer.active.baseY = 80 + terminal.buffer.active.viewportY = 80 + + restoreTerminalStructuralScrollIntent(terminal, snapshot, { restoreBy: 'bottomOffset' }) + + expect(terminal.scrollToLine).toHaveBeenLastCalledWith(30) + expect(terminal.buffer.active.viewportY).toBe(30) + }) + + it('retains the intended bottom-offset pin when renderer dimensions reject restore', () => { + const terminal = createTerminal(80, 100) + markTerminalPinnedViewport(terminal) + const snapshot = captureTerminalStructuralScrollIntent(terminal) + terminal.buffer.active.baseY = 200 + terminal.buffer.active.viewportY = 200 + terminal.scrollToLine.mockImplementationOnce(() => { + throw new TypeError("Cannot read properties of undefined (reading 'dimensions')") + }) + + restoreTerminalStructuralScrollIntent(terminal, snapshot, { restoreBy: 'bottomOffset' }) + expect(terminal.buffer.active.viewportY).toBe(200) + + terminal.buffer.active.viewportY = 0 + enforceTerminalCurrentScrollIntent(terminal) + expect(terminal.scrollToLine).toHaveBeenLastCalledWith(180) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/terminal-scroll-intent-rebuild.ts b/src/renderer/src/lib/pane-manager/terminal-scroll-intent-rebuild.ts new file mode 100644 index 00000000000..3de8e79a9f1 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-scroll-intent-rebuild.ts @@ -0,0 +1,125 @@ +// Why: buffer rebuilds (snapshot replay clear + rewrite) parse asynchronously. +// Until the rebuild's bytes have parsed, viewportY/baseY describe a transient +// half-cleared buffer; any intent capture/enforce latched from it pins the +// terminal at line 0. Callers bracket the rebuild and re-apply intent once +// after parse (see terminal-scroll-intent.ts). +const terminalScrollIntentRebuilds = new WeakMap() +const terminalScrollIntentRebuildCompletions = new WeakMap< + object, + Set<(completed: boolean) => void> +>() +const deferredTerminalGeometryMutations = new WeakMap< + object, + { + mutations: Map void> + } +>() + +function notifyRebuildCompletions( + completions: Set<(completed: boolean) => void> | undefined, + completed: boolean +): void { + for (const completion of completions ?? []) { + try { + completion(completed) + } catch (error) { + // Why: one optional observer must not strand the rebuild or prevent the + // coordinator from restoring the authoritative viewport. + console.error('[terminal] scroll-intent rebuild completion failed', error) + } + } +} + +export function beginTerminalScrollIntentBufferRebuild(terminal: object): void { + terminalScrollIntentRebuilds.set(terminal, (terminalScrollIntentRebuilds.get(terminal) ?? 0) + 1) +} + +export function endTerminalScrollIntentBufferRebuild(terminal: object): void { + const count = terminalScrollIntentRebuilds.get(terminal) ?? 0 + if (count <= 1) { + terminalScrollIntentRebuilds.delete(terminal) + const completions = terminalScrollIntentRebuildCompletions.get(terminal) + terminalScrollIntentRebuildCompletions.delete(terminal) + notifyRebuildCompletions(completions, true) + return + } + terminalScrollIntentRebuilds.set(terminal, count - 1) +} + +export function isTerminalScrollIntentRebuildInFlight(terminal: object): boolean { + return (terminalScrollIntentRebuilds.get(terminal) ?? 0) > 0 +} + +export function onTerminalScrollIntentBufferRebuildComplete( + terminal: object, + completion: (completed: boolean) => void +): () => void { + if (!isTerminalScrollIntentRebuildInFlight(terminal)) { + completion(true) + return () => {} + } + let completions = terminalScrollIntentRebuildCompletions.get(terminal) + if (!completions) { + completions = new Set() + terminalScrollIntentRebuildCompletions.set(terminal, completions) + } + completions.add(completion) + return () => { + completions?.delete(completion) + if (completions?.size === 0) { + terminalScrollIntentRebuildCompletions.delete(terminal) + } + } +} + +// Why: source-dimension replay must finish and restore its viewport before +// unrelated fit/resize work is allowed to reflow the rebuilt buffer. +export function deferTerminalGeometryMutationDuringRebuild( + terminal: object, + operationKey: string, + mutation: () => void +): boolean { + if (!isTerminalScrollIntentRebuildInFlight(terminal)) { + return false + } + const existing = deferredTerminalGeometryMutations.get(terminal) + if (existing) { + existing.mutations.set(operationKey, mutation) + return true + } + const mutations = new Map([[operationKey, mutation]]) + const deferred = { mutations } + deferredTerminalGeometryMutations.set(terminal, deferred) + onTerminalScrollIntentBufferRebuildComplete(terminal, (completed) => { + if (deferredTerminalGeometryMutations.get(terminal) !== deferred) { + return + } + if (!completed) { + deferredTerminalGeometryMutations.delete(terminal) + return + } + // Why: rebuild completion listeners run before the coordinator restores + // intent; the microtask makes every geometry mutation post-restore. + queueMicrotask(() => { + if (deferredTerminalGeometryMutations.get(terminal) !== deferred) { + return + } + // Keep the entry cancellable until execution begins; disposal may land + // after rebuild completion but before this post-restore microtask. + deferredTerminalGeometryMutations.delete(terminal) + for (const [key, pendingMutation] of mutations) { + if (!deferTerminalGeometryMutationDuringRebuild(terminal, key, pendingMutation)) { + pendingMutation() + } + } + }) + }) + return true +} + +export function cancelTerminalScrollIntentBufferRebuildCompletions(terminal: object): void { + const completions = terminalScrollIntentRebuildCompletions.get(terminal) + terminalScrollIntentRebuildCompletions.delete(terminal) + notifyRebuildCompletions(completions, false) + deferredTerminalGeometryMutations.delete(terminal) +} diff --git a/src/renderer/src/lib/pane-manager/terminal-scroll-intent-settle.ts b/src/renderer/src/lib/pane-manager/terminal-scroll-intent-settle.ts new file mode 100644 index 00000000000..a4962dff8cf --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-scroll-intent-settle.ts @@ -0,0 +1,33 @@ +import { + syncTerminalScrollIntentFromViewport, + type TerminalScrollIntentTarget +} from './terminal-scroll-intent' + +export function syncTerminalScrollIntentSoon( + terminal: TerminalScrollIntentTarget, + options: { + allowBufferShrink?: boolean + preservePinnedAtBottom?: boolean + shouldSync?: () => boolean + } = {} +): void { + const sync = (): void => { + if (options.shouldSync?.() === false) { + return + } + syncTerminalScrollIntentFromViewport(terminal, options) + } + queueMicrotask(sync) + requestAnimationFrame(sync) + requestAnimationFrame(() => requestAnimationFrame(sync)) + // Why: preservePinnedAtBottom only bridges xterm's async scroll application. + // The settle tick must reclassify from the real viewport, otherwise a wheel + // the viewport never followed latches a phantom pin at the bottom. + setTimeout(() => { + if (options.shouldSync?.() !== false) { + syncTerminalScrollIntentFromViewport(terminal, { + allowBufferShrink: options.allowBufferShrink + }) + } + }, 80) +} diff --git a/src/renderer/src/lib/pane-manager/terminal-scroll-intent-structural-transitions.test.ts b/src/renderer/src/lib/pane-manager/terminal-scroll-intent-structural-transitions.test.ts new file mode 100644 index 00000000000..4e6c2b66626 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-scroll-intent-structural-transitions.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it, vi } from 'vitest' +import { + bindTerminalScrollIntentKey, + captureTerminalStructuralScrollIntent, + markTerminalFollowOutput, + markTerminalPinnedViewport, + restoreTerminalStructuralScrollIntent +} from './terminal-scroll-intent' + +type BufferType = 'normal' | 'alternate' + +function createTerminal(viewportY: number, baseY: number, type: BufferType = 'normal') { + const terminal = { + buffer: { active: { type, viewportY, baseY } }, + scrollToBottom: vi.fn(() => { + terminal.buffer.active.viewportY = terminal.buffer.active.baseY + }), + scrollToLine: vi.fn((line: number) => { + terminal.buffer.active.viewportY = line + }) + } + return terminal +} + +describe('terminal structural scroll-intent transitions', () => { + it.each([ + { + name: 'live pinned growth', + storedKind: 'pinnedViewport' as const, + live: { viewportY: 76, baseY: 120, type: 'normal' as const }, + expected: { kind: 'pinnedViewport', viewportY: 76, baseY: 120, bufferType: 'normal' } + }, + { + name: 'empty pinned remount', + storedKind: 'pinnedViewport' as const, + live: { viewportY: 0, baseY: 0, type: 'normal' as const }, + expected: { kind: 'pinnedViewport', viewportY: 76, baseY: 100, bufferType: 'normal' } + }, + { + name: 'shorter pinned remount', + storedKind: 'pinnedViewport' as const, + live: { viewportY: 20, baseY: 30, type: 'normal' as const }, + expected: { kind: 'pinnedViewport', viewportY: 76, baseY: 100, bufferType: 'normal' } + }, + { + name: 'alternate buffer entered from a normal-buffer pin', + storedKind: 'pinnedViewport' as const, + live: { viewportY: 0, baseY: 0, type: 'alternate' as const }, + expected: { kind: 'pinnedViewport', viewportY: 76, baseY: 100, bufferType: 'normal' } + }, + { + name: 'untracked return to bottom', + storedKind: 'pinnedViewport' as const, + live: { viewportY: 100, baseY: 100, type: 'normal' as const }, + expected: { kind: 'followOutput', viewportY: 100, baseY: 100, bufferType: 'normal' } + }, + { + name: 'empty follow-output remount', + storedKind: 'followOutput' as const, + live: { viewportY: 0, baseY: 0, type: 'normal' as const }, + expected: { kind: 'followOutput', viewportY: 0, baseY: 0, bufferType: 'normal' } + } + ])('captures the authoritative coordinates for $name', ({ storedKind, live, expected }) => { + const key = `structural-${storedKind}-${live.type}-${live.viewportY}-${live.baseY}` + const original = createTerminal(76, 100) + bindTerminalScrollIntentKey(original, key) + if (storedKind === 'pinnedViewport') { + markTerminalPinnedViewport(original) + } else { + original.buffer.active.viewportY = original.buffer.active.baseY + markTerminalFollowOutput(original) + } + + const current = createTerminal(live.viewportY, live.baseY, live.type) + bindTerminalScrollIntentKey(current, key) + + expect(captureTerminalStructuralScrollIntent(current)).toMatchObject(expected) + }) + + it('restores a durable remount pin by bottom offset without overwriting newer intent', () => { + const original = createTerminal(76, 100) + bindTerminalScrollIntentKey(original, 'structural-remount-revision') + markTerminalPinnedViewport(original) + const remounted = createTerminal(0, 0) + bindTerminalScrollIntentKey(remounted, 'structural-remount-revision') + const staleIntent = captureTerminalStructuralScrollIntent(remounted) + + remounted.buffer.active.viewportY = 200 + remounted.buffer.active.baseY = 200 + markTerminalFollowOutput(remounted) + restoreTerminalStructuralScrollIntent(remounted, staleIntent, { restoreBy: 'bottomOffset' }) + + expect(remounted.scrollToLine).not.toHaveBeenCalled() + expect(remounted.buffer.active.viewportY).toBe(200) + }) + + it('keeps a normal-buffer pin dormant while replay restores an alternate buffer', () => { + const original = createTerminal(76, 100) + bindTerminalScrollIntentKey(original, 'structural-buffer-switch') + markTerminalPinnedViewport(original) + const remounted = createTerminal(0, 0, 'alternate') + bindTerminalScrollIntentKey(remounted, 'structural-buffer-switch') + const intent = captureTerminalStructuralScrollIntent(remounted) + + remounted.buffer.active.baseY = 40 + remounted.buffer.active.viewportY = 40 + restoreTerminalStructuralScrollIntent(remounted, intent, { restoreBy: 'bottomOffset' }) + expect(remounted.scrollToLine).not.toHaveBeenCalled() + + remounted.buffer.active.type = 'normal' + remounted.buffer.active.baseY = 140 + remounted.buffer.active.viewportY = 140 + restoreTerminalStructuralScrollIntent(remounted, intent, { restoreBy: 'bottomOffset' }) + expect(remounted.scrollToLine).toHaveBeenLastCalledWith(116) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts b/src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts index 205bd8ecaa9..5d3c9f26aa9 100644 --- a/src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts +++ b/src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts @@ -1,15 +1,22 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { - attachTerminalScrollIntentTracking, - captureTerminalWriteScrollIntent, + bindTerminalScrollIntentKey, + captureTerminalStructuralScrollIntent, enforceTerminalCurrentScrollIntent, - enforceTerminalWriteScrollIntent, getTerminalScrollIntentKind, markTerminalFollowOutput, markTerminalPinnedViewport, syncTerminalScrollIntentFromViewport, - syncTerminalScrollIntentSoon + restoreTerminalStructuralScrollIntent } from './terminal-scroll-intent' +import { syncTerminalScrollIntentSoon } from './terminal-scroll-intent-settle' +import { clearTerminalScrollbackAndFollowOutput } from './terminal-scrollback-clear' +import { attachTerminalScrollIntentTracking } from './terminal-scroll-intent-dom-tracking' +import { + beginTerminalScrollIntentBufferRebuild, + cancelTerminalScrollIntentBufferRebuildCompletions, + endTerminalScrollIntentBufferRebuild +} from './terminal-scroll-intent-rebuild' function createTerminal({ viewportY, @@ -99,14 +106,39 @@ describe('terminal scroll intent', () => { expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport') }) + it('treats a viewport exactly one row above bottom as pinned', () => { + const terminal = createTerminal({ viewportY: 99, baseY: 100 }) + + expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport') + syncTerminalScrollIntentFromViewport(terminal) + expect(captureTerminalStructuralScrollIntent(terminal)?.kind).toBe('pinnedViewport') + }) + + it('clears a pinned scrollback into follow-output state', () => { + const terminal = { + ...createTerminal({ viewportY: 42, baseY: 100 }), + clear: vi.fn() + } + markTerminalPinnedViewport(terminal) + + clearTerminalScrollbackAndFollowOutput(terminal) + + expect(terminal.clear).toHaveBeenCalledOnce() + expect(terminal.scrollToBottom).toHaveBeenCalledOnce() + expect(terminal.clear.mock.invocationCallOrder[0]).toBeLessThan( + terminal.scrollToBottom.mock.invocationCallOrder[0] + ) + expect(getTerminalScrollIntentKind(terminal)).toBe('followOutput') + }) + it('preserves a pinned viewport after output moves xterm to bottom', () => { const terminal = createTerminal({ viewportY: 42, baseY: 100 }) markTerminalPinnedViewport(terminal) - const snapshot = captureTerminalWriteScrollIntent(terminal) + const snapshot = captureTerminalStructuralScrollIntent(terminal) terminal.buffer.active.baseY = 125 terminal.buffer.active.viewportY = 125 - enforceTerminalWriteScrollIntent(terminal, snapshot) + restoreTerminalStructuralScrollIntent(terminal, snapshot) expect(terminal.scrollToLine).toHaveBeenCalledWith(42) expect(terminal.buffer.active.viewportY).toBe(42) @@ -116,11 +148,11 @@ describe('terminal scroll intent', () => { it('follows output after output advances while following', () => { const terminal = createTerminal({ viewportY: 100, baseY: 100 }) markTerminalFollowOutput(terminal) - const snapshot = captureTerminalWriteScrollIntent(terminal) + const snapshot = captureTerminalStructuralScrollIntent(terminal) terminal.buffer.active.baseY = 125 terminal.buffer.active.viewportY = 0 - enforceTerminalWriteScrollIntent(terminal, snapshot) + restoreTerminalStructuralScrollIntent(terminal, snapshot) expect(terminal.scrollToBottom).toHaveBeenCalledTimes(1) expect(terminal.buffer.active.viewportY).toBe(125) @@ -129,16 +161,30 @@ describe('terminal scroll intent', () => { it('does not preserve across buffer type changes', () => { const terminal = createTerminal({ viewportY: 42, baseY: 100 }) markTerminalPinnedViewport(terminal) - const snapshot = captureTerminalWriteScrollIntent(terminal) + const snapshot = captureTerminalStructuralScrollIntent(terminal) terminal.buffer.active.type = 'alternate' terminal.buffer.active.viewportY = 0 - enforceTerminalWriteScrollIntent(terminal, snapshot) + restoreTerminalStructuralScrollIntent(terminal, snapshot) expect(terminal.scrollToLine).not.toHaveBeenCalled() expect(terminal.buffer.active.viewportY).toBe(0) }) + it('does not enforce a captured intent after newer user intent supersedes it', () => { + const terminal = createTerminal({ viewportY: 42, baseY: 100 }) + markTerminalPinnedViewport(terminal) + const staleSnapshot = captureTerminalStructuralScrollIntent(terminal) + + terminal.buffer.active.viewportY = terminal.buffer.active.baseY + markTerminalFollowOutput(terminal) + terminal.buffer.active.baseY = 125 + restoreTerminalStructuralScrollIntent(terminal, staleSnapshot) + + expect(terminal.scrollToLine).not.toHaveBeenCalled() + expect(getTerminalScrollIntentKind(terminal)).toBe('followOutput') + }) + it('syncs intent from the current viewport after user scroll settles', () => { const terminal = createTerminal({ viewportY: 100, baseY: 100 }) @@ -148,6 +194,20 @@ describe('terminal scroll intent', () => { expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport') }) + it('records xterm native scrollback-trim movement before structural enforcement', () => { + const terminal = createTerminal({ viewportY: 10, baseY: 20 }) + markTerminalPinnedViewport(terminal) + + // At scrollback capacity xterm keeps baseY fixed and walks viewportY up + // as old rows trim, preserving the visible content without app help. + terminal.buffer.active.viewportY = 5 + syncTerminalScrollIntentFromViewport(terminal) + terminal.buffer.active.viewportY = 0 + enforceTerminalCurrentScrollIntent(terminal) + + expect(terminal.scrollToLine).toHaveBeenLastCalledWith(5) + }) + it('tracks upward wheel immediately and records the settled viewport', async () => { const frameCallbacks: FrameRequestCallback[] = [] vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { @@ -253,6 +313,131 @@ describe('terminal scroll intent', () => { remountedDisposable.dispose() }) + it('captures durable pinned coordinates before replaying into an empty remount', () => { + vi.stubGlobal('Element', TestElement) + const firstTerminal = createTerminal({ viewportY: 76, baseY: 100 }) + const firstHost = new TestElement() as unknown as HTMLElement + const firstDisposable = attachTerminalScrollIntentTracking( + firstTerminal, + firstHost, + 'leaf-remount-replay' + ) + markTerminalPinnedViewport(firstTerminal) + + const remountedTerminal = createTerminal({ viewportY: 0, baseY: 0 }) + const remountedHost = new TestElement() as unknown as HTMLElement + const remountedDisposable = attachTerminalScrollIntentTracking( + remountedTerminal, + remountedHost, + 'leaf-remount-replay' + ) + const intent = captureTerminalStructuralScrollIntent(remountedTerminal) + + expect(intent).toMatchObject({ + kind: 'pinnedViewport', + viewportY: 76, + baseY: 100 + }) + remountedTerminal.buffer.active.viewportY = 100 + remountedTerminal.buffer.active.baseY = 100 + restoreTerminalStructuralScrollIntent(remountedTerminal, intent, { + restoreBy: 'bottomOffset' + }) + + expect(remountedTerminal.scrollToLine).toHaveBeenLastCalledWith(76) + expect(remountedTerminal.buffer.active.viewportY).toBe(76) + firstDisposable.dispose() + remountedDisposable.dispose() + }) + + it('refreshes pinned base geometry before a keyed empty remount', () => { + vi.stubGlobal('Element', TestElement) + const firstTerminal = createTerminal({ viewportY: 10, baseY: 20 }) + const firstHost = new TestElement() as unknown as HTMLElement + const firstDisposable = attachTerminalScrollIntentTracking( + firstTerminal, + firstHost, + 'leaf-growing-pin' + ) + markTerminalPinnedViewport(firstTerminal) + + firstTerminal.buffer.active.baseY = 30 + syncTerminalScrollIntentFromViewport(firstTerminal) + const remountedTerminal = createTerminal({ viewportY: 0, baseY: 0 }) + const remountedHost = new TestElement() as unknown as HTMLElement + const remountedDisposable = attachTerminalScrollIntentTracking( + remountedTerminal, + remountedHost, + 'leaf-growing-pin' + ) + const intent = captureTerminalStructuralScrollIntent(remountedTerminal) + remountedTerminal.buffer.active.viewportY = 30 + remountedTerminal.buffer.active.baseY = 30 + restoreTerminalStructuralScrollIntent(remountedTerminal, intent, { + restoreBy: 'bottomOffset' + }) + + expect(intent).toMatchObject({ viewportY: 10, baseY: 30 }) + expect(remountedTerminal.scrollToLine).toHaveBeenLastCalledWith(10) + firstDisposable.dispose() + remountedDisposable.dispose() + }) + + it('persists native pinned growth on disposal for the next keyed replay', () => { + vi.stubGlobal('Element', TestElement) + const firstTerminal = createTerminal({ viewportY: 76, baseY: 100 }) + const firstDisposable = attachTerminalScrollIntentTracking( + firstTerminal, + new TestElement() as unknown as HTMLElement, + 'leaf-dispose-growth' + ) + markTerminalPinnedViewport(firstTerminal) + firstTerminal.buffer.active.baseY = 120 + + firstDisposable.dispose() + + const remountedTerminal = createTerminal({ viewportY: 0, baseY: 0 }) + const remountedDisposable = attachTerminalScrollIntentTracking( + remountedTerminal, + new TestElement() as unknown as HTMLElement, + 'leaf-dispose-growth' + ) + const intent = captureTerminalStructuralScrollIntent(remountedTerminal) + remountedTerminal.buffer.active.viewportY = 200 + remountedTerminal.buffer.active.baseY = 200 + restoreTerminalStructuralScrollIntent(remountedTerminal, intent, { + restoreBy: 'bottomOffset' + }) + + expect(intent).toMatchObject({ viewportY: 76, baseY: 120 }) + expect(remountedTerminal.scrollToLine).toHaveBeenLastCalledWith(156) + remountedDisposable.dispose() + }) + + it('does not let an old terminal disposal overwrite its keyed successor', () => { + vi.stubGlobal('Element', TestElement) + const firstTerminal = createTerminal({ viewportY: 76, baseY: 100 }) + const firstDisposable = attachTerminalScrollIntentTracking( + firstTerminal, + new TestElement() as unknown as HTMLElement, + 'leaf-dispose-successor' + ) + markTerminalPinnedViewport(firstTerminal) + + const successor = createTerminal({ viewportY: 100, baseY: 100 }) + const successorDisposable = attachTerminalScrollIntentTracking( + successor, + new TestElement() as unknown as HTMLElement, + 'leaf-dispose-successor' + ) + markTerminalFollowOutput(successor) + firstTerminal.buffer.active.baseY = 150 + firstDisposable.dispose() + + expect(getTerminalScrollIntentKind(successor)).toBe('followOutput') + successorDisposable.dispose() + }) + it('tracks pointer-driven scrollbar scrolls without using output scroll as intent', () => { vi.stubGlobal('Element', TestElement) const terminal = createTerminal({ viewportY: 100, baseY: 100 }) @@ -358,6 +543,33 @@ describe('terminal scroll intent', () => { expect(terminal.scrollToLine).toHaveBeenLastCalledWith(75) }) + it('does not let a stale key-settle callback overwrite a remounted terminal', async () => { + const frameCallbacks: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frameCallbacks.push(callback) + return frameCallbacks.length + }) + vi.useFakeTimers({ toFake: ['setTimeout'] }) + let firstTerminalIsCurrent = true + const first = createTerminal({ viewportY: 100, baseY: 100 }) + bindTerminalScrollIntentKey(first, 'key-settle-remount') + markTerminalPinnedViewport(first) + first.buffer.active.viewportY = 50 + syncTerminalScrollIntentSoon(first, { shouldSync: () => firstTerminalIsCurrent }) + + const replacement = createTerminal({ viewportY: 100, baseY: 100 }) + bindTerminalScrollIntentKey(replacement, 'key-settle-remount') + markTerminalFollowOutput(replacement) + firstTerminalIsCurrent = false + await Promise.resolve() + while (frameCallbacks.length > 0) { + frameCallbacks.shift()?.(16) + } + vi.advanceTimersByTime(80) + + expect(getTerminalScrollIntentKind(replacement)).toBe('followOutput') + }) + it('enforces current intent once for visibility resume', () => { const terminal = createTerminal({ viewportY: 40, baseY: 100 }) markTerminalPinnedViewport(terminal) @@ -434,11 +646,11 @@ describe('terminal scroll intent', () => { markTerminalPinnedViewport(terminal) for (let batch = 1; batch <= 2; batch += 1) { - const snapshot = captureTerminalWriteScrollIntent(terminal) + const snapshot = captureTerminalStructuralScrollIntent(terminal) // xterm follows output during the write because the viewport was at bottom. terminal.buffer.active.baseY += 25 terminal.buffer.active.viewportY = terminal.buffer.active.baseY - enforceTerminalWriteScrollIntent(terminal, snapshot) + restoreTerminalStructuralScrollIntent(terminal, snapshot) expect(terminal.buffer.active.viewportY).toBe(terminal.buffer.active.baseY) } expect(getTerminalScrollIntentKind(terminal)).toBe('followOutput') @@ -470,16 +682,205 @@ describe('terminal scroll intent', () => { expect(terminal.buffer.active.viewportY).toBe(150) }) - it('supports bottom-offset restore for buffer-rebuild write paths', () => { - const terminal = createTerminal({ viewportY: 550, baseY: 600 }) + it('does not re-latch a pinned intent from a transiently shorter rebuilt buffer', () => { + const terminal = createTerminal({ viewportY: 248, baseY: 254 }) markTerminalPinnedViewport(terminal) - const snapshot = captureTerminalWriteScrollIntent(terminal) + const snapshot = captureTerminalStructuralScrollIntent(terminal) - terminal.buffer.active.baseY = 80 - terminal.buffer.active.viewportY = 80 - enforceTerminalWriteScrollIntent(terminal, snapshot, { restoreBy: 'bottomOffset' }) + // Snapshot replay cleared the buffer; enforcement races the async parse. + terminal.buffer.active.baseY = 0 + terminal.buffer.active.viewportY = 0 + restoreTerminalStructuralScrollIntent(terminal, snapshot) - expect(terminal.scrollToLine).toHaveBeenLastCalledWith(30) - expect(terminal.buffer.active.viewportY).toBe(30) + // The replay finishes parsing and the scrollback regrows past the pin. + terminal.buffer.active.baseY = 284 + terminal.buffer.active.viewportY = 284 + enforceTerminalCurrentScrollIntent(terminal) + + expect(terminal.scrollToLine).toHaveBeenLastCalledWith(248) + expect(terminal.buffer.active.viewportY).toBe(248) }) + + it('keeps a pinned intent when capture races a cleared unparsed buffer', () => { + const terminal = createTerminal({ viewportY: 248, baseY: 254 }) + markTerminalPinnedViewport(terminal) + + // A structural capture sees the rebuilt buffer while it is still empty; + // the at-bottom(0/0) reading is transient and must not convert the pin. + terminal.buffer.active.baseY = 0 + terminal.buffer.active.viewportY = 0 + const snapshot = captureTerminalStructuralScrollIntent(terminal) + expect(snapshot?.kind).toBe('pinnedViewport') + }) + + it('suspends intent capture and enforcement while a buffer rebuild is in flight', () => { + const terminal = createTerminal({ viewportY: 248, baseY: 254 }) + markTerminalPinnedViewport(terminal) + const preReplay = captureTerminalStructuralScrollIntent(terminal) + beginTerminalScrollIntentBufferRebuild(terminal) + + terminal.buffer.active.baseY = 0 + terminal.buffer.active.viewportY = 0 + expect(captureTerminalStructuralScrollIntent(terminal)).toBeNull() + enforceTerminalCurrentScrollIntent(terminal) + + // A live streaming batch lands while the replay is partially parsed. + terminal.buffer.active.baseY = 284 + terminal.buffer.active.viewportY = 0 + restoreTerminalStructuralScrollIntent(terminal, preReplay) + expect(terminal.scrollToLine).not.toHaveBeenCalled() + expect(terminal.scrollToBottom).not.toHaveBeenCalled() + + terminal.buffer.active.viewportY = 284 + endTerminalScrollIntentBufferRebuild(terminal) + restoreTerminalStructuralScrollIntent(terminal, preReplay, { restoreBy: 'bottomOffset' }) + expect(terminal.scrollToLine).toHaveBeenLastCalledWith(278) + expect(terminal.buffer.active.viewportY).toBe(278) + expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport') + }) + + it.each([ + { deltaY: -10, finalViewportY: 150, expectedKind: 'pinnedViewport', expectedLine: 150 }, + { deltaY: 10, finalViewportY: 200, expectedKind: 'followOutput', expectedLine: null } + ])( + 'resyncs a $expectedKind wheel intent after a delayed rebuild', + async ({ deltaY, finalViewportY, expectedKind, expectedLine }) => { + vi.stubGlobal('requestAnimationFrame', () => 0) + vi.stubGlobal('Element', TestElement) + const terminal = createTerminal({ viewportY: 42, baseY: 100 }) + const host = new TestElement() as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + markTerminalPinnedViewport(terminal) + const staleIntent = captureTerminalStructuralScrollIntent(terminal) + beginTerminalScrollIntentBufferRebuild(terminal) + + terminal.buffer.active.viewportY = 0 + terminal.buffer.active.baseY = 0 + const wheel = new Event('wheel') as WheelEvent + Object.defineProperty(wheel, 'deltaY', { value: deltaY }) + host.dispatchEvent(wheel) + terminal.buffer.active.viewportY = finalViewportY + terminal.buffer.active.baseY = 200 + endTerminalScrollIntentBufferRebuild(terminal) + restoreTerminalStructuralScrollIntent(terminal, staleIntent, { + restoreBy: 'bottomOffset' + }) + + expect(getTerminalScrollIntentKind(terminal)).toBe(expectedKind) + terminal.buffer.active.viewportY = 0 + terminal.scrollToLine.mockClear() + enforceTerminalCurrentScrollIntent(terminal) + if (expectedLine === null) { + expect(terminal.scrollToBottom).toHaveBeenCalled() + } else { + expect(terminal.scrollToLine).toHaveBeenLastCalledWith(expectedLine) + } + disposable.dispose() + } + ) + + it('preserves a durable pin when wheel-up lands on the cleared replay buffer', async () => { + vi.stubGlobal('requestAnimationFrame', () => 0) + vi.stubGlobal('Element', TestElement) + const terminal = createTerminal({ viewportY: 80, baseY: 100 }) + const host = new TestElement() as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + markTerminalPinnedViewport(terminal) + const staleIntent = captureTerminalStructuralScrollIntent(terminal) + beginTerminalScrollIntentBufferRebuild(terminal) + + terminal.buffer.active.viewportY = 0 + terminal.buffer.active.baseY = 0 + const wheel = new Event('wheel') as WheelEvent + Object.defineProperty(wheel, 'deltaY', { value: -10 }) + host.dispatchEvent(wheel) + terminal.buffer.active.viewportY = 200 + terminal.buffer.active.baseY = 200 + endTerminalScrollIntentBufferRebuild(terminal) + restoreTerminalStructuralScrollIntent(terminal, staleIntent, { restoreBy: 'bottomOffset' }) + await Promise.resolve() + + expect(terminal.scrollToLine).toHaveBeenLastCalledWith(180) + expect(terminal.buffer.active.viewportY).toBe(180) + expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport') + disposable.dispose() + }) + + it('keeps rebuild wheel intent when xterm classifies the same event as mouse input', async () => { + vi.stubGlobal('requestAnimationFrame', () => 0) + vi.stubGlobal('Element', TestElement) + const { terminal, capturedInput, capturedUserInput } = createTerminalWithInputCapture({ + viewportY: 80, + baseY: 100 + }) + const host = new TestElement() as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + markTerminalPinnedViewport(terminal) + const staleIntent = captureTerminalStructuralScrollIntent(terminal) + beginTerminalScrollIntentBufferRebuild(terminal) + + terminal.buffer.active.viewportY = 0 + terminal.buffer.active.baseY = 0 + const wheel = new Event('wheel') as WheelEvent + Object.defineProperty(wheel, 'deltaY', { value: -10 }) + host.dispatchEvent(wheel) + capturedUserInput.listener?.() + capturedInput.listener?.('\x1b[<64;10;5M') + await Promise.resolve() + terminal.buffer.active.viewportY = 200 + terminal.buffer.active.baseY = 200 + endTerminalScrollIntentBufferRebuild(terminal) + restoreTerminalStructuralScrollIntent(terminal, staleIntent, { restoreBy: 'bottomOffset' }) + await Promise.resolve() + + expect(terminal.scrollToLine).toHaveBeenLastCalledWith(180) + expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport') + disposable.dispose() + }) + + it('does not resync deferred wheel intent from a canceled partial rebuild', () => { + vi.stubGlobal('requestAnimationFrame', () => 0) + vi.stubGlobal('Element', TestElement) + const terminal = createTerminal({ viewportY: 42, baseY: 100 }) + const host = new TestElement() as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + markTerminalPinnedViewport(terminal) + beginTerminalScrollIntentBufferRebuild(terminal) + + terminal.buffer.active.viewportY = 0 + terminal.buffer.active.baseY = 0 + const wheel = new Event('wheel') as WheelEvent + Object.defineProperty(wheel, 'deltaY', { value: -10 }) + host.dispatchEvent(wheel) + cancelTerminalScrollIntentBufferRebuildCompletions(terminal) + endTerminalScrollIntentBufferRebuild(terminal) + + terminal.buffer.active.viewportY = 0 + terminal.buffer.active.baseY = 100 + enforceTerminalCurrentScrollIntent(terminal) + expect(terminal.scrollToLine).toHaveBeenLastCalledWith(42) + disposable.dispose() + }) + + function createTerminalWithInputCapture(args: { viewportY: number; baseY: number }) { + const capturedInput: { listener: ((data: string) => void) | null } = { listener: null } + const capturedUserInput: { listener: (() => void) | null } = { listener: null } + const terminal = createTerminal(args) as ReturnType & { + onData?: (listener: (data: string) => void) => { dispose: () => void } + _core?: { coreService: { onUserInput: (listener: () => void) => { dispose: () => void } } } + } + terminal.onData = (listener: (data: string) => void) => { + capturedInput.listener = listener + return { dispose: vi.fn() } + } + terminal._core = { + coreService: { + onUserInput: (listener: () => void) => { + capturedUserInput.listener = listener + return { dispose: vi.fn() } + } + } + } + return { terminal, capturedInput, capturedUserInput } + } }) diff --git a/src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts b/src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts index 88e70096a59..8fa96d62c69 100644 --- a/src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts +++ b/src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts @@ -1,35 +1,36 @@ -import type { IDisposable } from '@xterm/xterm' +import { isTerminalScrollIntentRebuildInFlight } from './terminal-scroll-intent-rebuild' +import { + clampTerminalViewportY, + isTerminalViewportAtBottom, + readTerminalScrollBufferSnapshot, + safeTerminalScrollCall, + type TerminalScrollBufferType +} from './terminal-scroll-buffer-snapshot' type TerminalScrollIntentKind = 'followOutput' | 'pinnedViewport' -type BufferType = 'normal' | 'alternate' - -type TerminalScrollIntentTarget = { - buffer?: { - active?: { - type?: string - viewportY?: number - baseY?: number - } - } +export type TerminalScrollIntentTarget = { + buffer?: Parameters[0]['buffer'] scrollToBottom?: () => void scrollToLine?: (line: number) => void } -type TerminalScrollIntentKey = string +export type TerminalScrollIntentKey = string type TerminalScrollIntent = { kind: TerminalScrollIntentKind - bufferType: BufferType + bufferType: TerminalScrollBufferType viewportY: number baseY: number + revision: number } -type TerminalScrollIntentWriteSnapshot = { +export type TerminalStructuralScrollIntentSnapshot = { kind: TerminalScrollIntentKind - bufferType: BufferType + bufferType: TerminalScrollBufferType viewportY: number baseY: number + revision: number } type TerminalScrollIntentEnforceOptions = { @@ -47,47 +48,31 @@ const terminalScrollIntentKeyByTerminal = new WeakMap< TerminalScrollIntentTarget, TerminalScrollIntentKey >() +const terminalScrollIntentKeyBindingByTerminal = new WeakMap() const terminalScrollIntentByKey = new Map() +const terminalScrollIntentBindingByKey = new Map() -const BOTTOM_TOLERANCE_ROWS = 1 -const XTERM_SCROLL_INTENT_POINTER_TARGET_CLASSES = [ - 'xterm-viewport', - 'xterm-scrollbar', - 'xterm-slider' -] as const -const XTERM_SCROLL_INTENT_POINTER_TARGET_SELECTOR = XTERM_SCROLL_INTENT_POINTER_TARGET_CLASSES.map( - (className) => `.${className}` -).join(',') - -function readBufferSnapshot( - terminal: TerminalScrollIntentTarget -): { bufferType: BufferType; viewportY: number; baseY: number } | null { - const buffer = terminal.buffer?.active - const viewportY = buffer?.viewportY - const baseY = buffer?.baseY - if (typeof viewportY !== 'number' || typeof baseY !== 'number') { - return null - } - return { - bufferType: buffer?.type === 'alternate' ? 'alternate' : 'normal', - viewportY, - baseY - } -} - -function isAtBottom(viewportY: number, baseY: number): boolean { - return viewportY >= baseY - BOTTOM_TOLERANCE_ROWS -} +let nextTerminalScrollIntentRevision = 1 +let nextTerminalScrollIntentKeyBinding = 1 function writeIntent( terminal: TerminalScrollIntentTarget, kind: TerminalScrollIntentKind ): TerminalScrollIntent | null { - const snapshot = readBufferSnapshot(terminal) + const snapshot = readTerminalScrollBufferSnapshot(terminal) if (!snapshot) { return null } - const intent = { kind, ...snapshot } + return writeIntentSnapshot(terminal, kind, snapshot) +} + +function writeIntentSnapshot( + terminal: TerminalScrollIntentTarget, + kind: TerminalScrollIntentKind, + snapshot: { bufferType: TerminalScrollBufferType; viewportY: number; baseY: number } +): TerminalScrollIntent { + const intent = { kind, ...snapshot, revision: nextTerminalScrollIntentRevision } + nextTerminalScrollIntentRevision += 1 terminalScrollIntentByTerminal.set(terminal, intent) const key = terminalScrollIntentKeyByTerminal.get(terminal) if (key) { @@ -105,7 +90,7 @@ function readStoredIntent(terminal: TerminalScrollIntentTarget): TerminalScrollI return key ? terminalScrollIntentByKey.get(key) : undefined } -function bindTerminalScrollIntentKey( +export function bindTerminalScrollIntentKey( terminal: TerminalScrollIntentTarget, key: TerminalScrollIntentKey | undefined ): TerminalScrollIntent | undefined { @@ -113,6 +98,10 @@ function bindTerminalScrollIntentKey( return terminalScrollIntentByTerminal.get(terminal) } terminalScrollIntentKeyByTerminal.set(terminal, key) + const binding = nextTerminalScrollIntentKeyBinding + nextTerminalScrollIntentKeyBinding += 1 + terminalScrollIntentKeyBindingByTerminal.set(terminal, binding) + terminalScrollIntentBindingByKey.set(key, binding) const existing = terminalScrollIntentByKey.get(key) if (existing) { terminalScrollIntentByTerminal.set(terminal, existing) @@ -120,28 +109,17 @@ function bindTerminalScrollIntentKey( return existing } -function clampViewportY(viewportY: number, baseY: number): number { - return Math.max(0, Math.min(viewportY, baseY)) -} - -function safeScrollCall(fn: () => void): boolean { - try { - fn() +export function isTerminalScrollIntentKeyBindingCurrent( + terminal: TerminalScrollIntentTarget +): boolean { + const key = terminalScrollIntentKeyByTerminal.get(terminal) + if (!key) { return true - } catch (err) { - if (err instanceof TypeError && /dimensions/.test(err.message)) { - return false - } - throw err } -} - -function isTerminalScrollIntentPointerTarget(target: EventTarget | null): target is Element { - if (typeof Element === 'undefined' || !(target instanceof Element)) { - return false - } - // xterm's custom scrollbar uses separate thumb/track nodes from the viewport. - return target.closest(XTERM_SCROLL_INTENT_POINTER_TARGET_SELECTOR) !== null + return ( + terminalScrollIntentKeyBindingByTerminal.get(terminal) === + terminalScrollIntentBindingByKey.get(key) + ) } export function markTerminalFollowOutput(terminal: TerminalScrollIntentTarget): void { @@ -154,45 +132,53 @@ export function markTerminalPinnedViewport(terminal: TerminalScrollIntentTarget) export function syncTerminalScrollIntentFromViewport( terminal: TerminalScrollIntentTarget, - options: { preservePinnedAtBottom?: boolean } = {} + options: { allowBufferShrink?: boolean; preservePinnedAtBottom?: boolean } = {} ): void { - const snapshot = readBufferSnapshot(terminal) + if (isTerminalScrollIntentRebuildInFlight(terminal)) { + return + } + const snapshot = readTerminalScrollBufferSnapshot(terminal) if (!snapshot) { return } const existing = readStoredIntent(terminal) // Why: a remounted/replayed terminal can briefly report an empty or shorter // scrollback. That transient state must not erase a durable pinned viewport. - if (existing?.kind === 'pinnedViewport' && snapshot.baseY < existing.baseY) { + if ( + !options.allowBufferShrink && + existing?.kind === 'pinnedViewport' && + snapshot.baseY < existing.baseY + ) { terminalScrollIntentByTerminal.set(terminal, existing) return } if ( options.preservePinnedAtBottom && existing?.kind === 'pinnedViewport' && - isAtBottom(snapshot.viewportY, snapshot.baseY) + isTerminalViewportAtBottom(snapshot.viewportY, snapshot.baseY) ) { return } - writeIntent( - terminal, - isAtBottom(snapshot.viewportY, snapshot.baseY) ? 'followOutput' : 'pinnedViewport' - ) -} - -export function syncTerminalScrollIntentSoon( - terminal: TerminalScrollIntentTarget, - options: { preservePinnedAtBottom?: boolean } = {} -): void { - const sync = (): void => syncTerminalScrollIntentFromViewport(terminal, options) - queueMicrotask(sync) - requestAnimationFrame(sync) - requestAnimationFrame(() => requestAnimationFrame(sync)) - // Why: preservePinnedAtBottom only bridges xterm's async scroll application. - // The settle tick must reclassify from the real viewport, otherwise a wheel - // the viewport never followed (sub-row delta, TUI-consumed mouse report, - // plain PageUp/Home sent to the app) latches a phantom pin at the bottom. - setTimeout(() => syncTerminalScrollIntentFromViewport(terminal), 80) + const kind = isTerminalViewportAtBottom(snapshot.viewportY, snapshot.baseY) + ? 'followOutput' + : 'pinnedViewport' + // Why: parser auto-replies and repeated wheel settle samples often observe + // no intent change. Avoid manufacturing revisions that can cancel a valid + // structural restore or amplify terminal-output bursts. + if ( + existing?.kind === kind && + existing.bufferType === snapshot.bufferType && + (kind === 'followOutput' || existing.viewportY === snapshot.viewportY) + ) { + if (kind === 'pinnedViewport' && existing.baseY !== snapshot.baseY) { + // Why: native pinned output can grow baseY without moving viewportY. + // Refresh geometry without creating a user-intent revision so a later + // keyed remount restores the same content, not the stale bottom offset. + Object.assign(existing, snapshot) + } + return + } + writeIntent(terminal, kind) } export function getTerminalScrollIntentKind( @@ -202,52 +188,82 @@ export function getTerminalScrollIntentKind( if (existing) { return existing.kind } - const snapshot = readBufferSnapshot(terminal) + const snapshot = readTerminalScrollBufferSnapshot(terminal) if (!snapshot) { return 'followOutput' } - return isAtBottom(snapshot.viewportY, snapshot.baseY) ? 'followOutput' : 'pinnedViewport' + return isTerminalViewportAtBottom(snapshot.viewportY, snapshot.baseY) + ? 'followOutput' + : 'pinnedViewport' } -export function captureTerminalWriteScrollIntent( +export function captureTerminalStructuralScrollIntent( terminal: TerminalScrollIntentTarget -): TerminalScrollIntentWriteSnapshot | null { - const snapshot = readBufferSnapshot(terminal) +): TerminalStructuralScrollIntentSnapshot | null { + if (isTerminalScrollIntentRebuildInFlight(terminal)) { + return null + } + const snapshot = readTerminalScrollBufferSnapshot(terminal) if (!snapshot) { return null } const existing = readStoredIntent(terminal) let kind = existing?.kind ?? - (isAtBottom(snapshot.viewportY, snapshot.baseY) ? 'followOutput' : 'pinnedViewport') + (isTerminalViewportAtBottom(snapshot.viewportY, snapshot.baseY) + ? 'followOutput' + : 'pinnedViewport') // Why: a pinned intent whose live viewport still sits at the bottom is a - // phantom pin (the user's scroll never detached the viewport). Enforcing it - // would freeze the terminal at the current line on every write batch. - if (kind === 'pinnedViewport' && isAtBottom(snapshot.viewportY, snapshot.baseY)) { + // phantom pin (the user's scroll never detached the viewport). Restoring it + // after a structural operation would freeze the terminal at a stale line. + // Only trust the at-bottom reading when the scrollback is at least as long + // as the pin's — a shorter one is a cleared buffer awaiting replay. + if ( + kind === 'pinnedViewport' && + isTerminalViewportAtBottom(snapshot.viewportY, snapshot.baseY) && + (!existing || snapshot.baseY >= existing.baseY) + ) { kind = 'followOutput' } + // Why: a keyed remount starts at 0/0 before replay. Preserve the durable + // pre-remount coordinates or a bottom-offset restore silently loses the pin. + const capturedCoordinates = + existing?.kind === 'pinnedViewport' && snapshot.baseY < existing.baseY ? existing : snapshot return { + ...capturedCoordinates, kind, - bufferType: snapshot.bufferType, - viewportY: snapshot.viewportY, - baseY: snapshot.baseY + revision: existing?.revision ?? 0 } } -export function enforceTerminalWriteScrollIntent( +export function isTerminalStructuralScrollIntentCurrent( terminal: TerminalScrollIntentTarget, - snapshot: TerminalScrollIntentWriteSnapshot | null, + snapshot: TerminalStructuralScrollIntentSnapshot | null +): boolean { + if (!snapshot) { + return false + } + return (readStoredIntent(terminal)?.revision ?? 0) === snapshot.revision +} + +export function restoreTerminalStructuralScrollIntent( + terminal: TerminalScrollIntentTarget, + snapshot: TerminalStructuralScrollIntentSnapshot | null, options: TerminalScrollIntentEnforceOptions = {} ): void { - if (!snapshot) { + if ( + !snapshot || + !isTerminalStructuralScrollIntentCurrent(terminal, snapshot) || + isTerminalScrollIntentRebuildInFlight(terminal) + ) { return } - const current = readBufferSnapshot(terminal) + const current = readTerminalScrollBufferSnapshot(terminal) if (!current || current.bufferType !== snapshot.bufferType) { return } if (snapshot.kind === 'followOutput') { - if (safeScrollCall(() => terminal.scrollToBottom?.())) { + if (safeTerminalScrollCall(() => terminal.scrollToBottom?.())) { writeIntent(terminal, 'followOutput') } return @@ -256,89 +272,60 @@ export function enforceTerminalWriteScrollIntent( options.restoreBy === 'bottomOffset' ? current.baseY - Math.max(0, snapshot.baseY - snapshot.viewportY) : snapshot.viewportY - const targetY = clampViewportY(requestedY, current.baseY) + const targetY = clampTerminalViewportY(requestedY, current.baseY) if (current.viewportY !== targetY) { - safeScrollCall(() => terminal.scrollToLine?.(targetY)) + if (!safeTerminalScrollCall(() => terminal.scrollToLine?.(targetY))) { + // Why: renderer teardown can reject the scroll before xterm changes its + // native viewport; retain the intended pin for the next fit/retry rather + // than latching the transient current bottom. + writeIntentSnapshot(terminal, 'pinnedViewport', { + bufferType: current.bufferType, + viewportY: targetY, + baseY: current.baseY + }) + return + } + } + const existing = readStoredIntent(terminal) + // Why: a scrollback shorter than the stored pin means the buffer is being + // rebuilt; re-latching from it would overwrite the durable line with the + // cleared buffer's line 0. + if (existing?.kind === 'pinnedViewport' && current.baseY < existing.baseY) { + return } writeIntent(terminal, 'pinnedViewport') } export function enforceTerminalCurrentScrollIntent(terminal: TerminalScrollIntentTarget): void { + if (isTerminalScrollIntentRebuildInFlight(terminal)) { + return + } const existing = readStoredIntent(terminal) if (!existing) { - enforceTerminalWriteScrollIntent(terminal, captureTerminalWriteScrollIntent(terminal)) + restoreTerminalStructuralScrollIntent(terminal, captureTerminalStructuralScrollIntent(terminal)) return } const snapshot = { kind: existing.kind, bufferType: existing.bufferType, viewportY: existing.viewportY, - baseY: existing.baseY + baseY: existing.baseY, + revision: existing.revision } - if (snapshot.kind === 'pinnedViewport' && isAtBottom(snapshot.viewportY, snapshot.baseY)) { + if ( + snapshot.kind === 'pinnedViewport' && + isTerminalViewportAtBottom(snapshot.viewportY, snapshot.baseY) + ) { // Why: a pin recorded at the bottom means the viewport never detached; // resuming must follow live output, not freeze at that stale line. snapshot.kind = 'followOutput' } - const current = readBufferSnapshot(terminal) + const current = readTerminalScrollBufferSnapshot(terminal) // Why: a shorter live buffer than the stored intent means the buffer was // rebuilt (snapshot replay/remount); absolute lines are renumbered there. const restoreBy = snapshot.kind === 'pinnedViewport' && current && current.baseY < snapshot.baseY ? 'bottomOffset' : 'viewportLine' - enforceTerminalWriteScrollIntent(terminal, snapshot, { restoreBy }) -} - -export function attachTerminalScrollIntentTracking( - terminal: TerminalScrollIntentTarget, - host: HTMLElement, - intentKey?: TerminalScrollIntentKey -): IDisposable { - if (!bindTerminalScrollIntentKey(terminal, intentKey)) { - syncTerminalScrollIntentFromViewport(terminal) - } - let pointerScrollActive = false - - const onWheel = (event: WheelEvent): void => { - if (event.deltaY < 0) { - markTerminalPinnedViewport(terminal) - syncTerminalScrollIntentSoon(terminal, { preservePinnedAtBottom: true }) - return - } - syncTerminalScrollIntentSoon(terminal) - } - - const onPointerDown = (event: PointerEvent): void => { - pointerScrollActive = isTerminalScrollIntentPointerTarget(event.target) - } - - const onPointerDone = (): void => { - if (!pointerScrollActive) { - return - } - pointerScrollActive = false - syncTerminalScrollIntentFromViewport(terminal) - } - - const onScroll = (): void => { - if (pointerScrollActive) { - syncTerminalScrollIntentFromViewport(terminal) - } - } - - host.addEventListener('wheel', onWheel, { capture: true, passive: true }) - host.addEventListener('pointerdown', onPointerDown, true) - host.addEventListener('scroll', onScroll, true) - globalThis.addEventListener?.('pointerup', onPointerDone, true) - globalThis.addEventListener?.('pointercancel', onPointerDone, true) - return { - dispose: () => { - host.removeEventListener('wheel', onWheel, true) - host.removeEventListener('pointerdown', onPointerDown, true) - host.removeEventListener('scroll', onScroll, true) - globalThis.removeEventListener?.('pointerup', onPointerDone, true) - globalThis.removeEventListener?.('pointercancel', onPointerDone, true) - } - } + restoreTerminalStructuralScrollIntent(terminal, snapshot, { restoreBy }) } diff --git a/src/renderer/src/lib/pane-manager/terminal-scrollback-clear.ts b/src/renderer/src/lib/pane-manager/terminal-scrollback-clear.ts new file mode 100644 index 00000000000..84929536a83 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-scrollback-clear.ts @@ -0,0 +1,16 @@ +import { markTerminalFollowOutput, type TerminalScrollIntentTarget } from './terminal-scroll-intent' + +type TerminalScrollbackClearTarget = TerminalScrollIntentTarget & { + clear: () => void + scrollToBottom: () => void +} + +export function clearTerminalScrollbackAndFollowOutput( + terminal: TerminalScrollbackClearTarget +): void { + terminal.clear() + // Why: xterm clear() leaves BufferService.isUserScrolling latched when the + // viewport was pinned, so a public zero-distance bottom scroll must reset it. + terminal.scrollToBottom() + markTerminalFollowOutput(terminal) +} diff --git a/src/renderer/src/lib/pane-manager/terminal-structural-replay-coordinator.test.ts b/src/renderer/src/lib/pane-manager/terminal-structural-replay-coordinator.test.ts new file mode 100644 index 00000000000..a4e2d86d429 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-structural-replay-coordinator.test.ts @@ -0,0 +1,248 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createTerminalStructuralReplayCoordinator } from './terminal-structural-replay-coordinator' +import { + markTerminalPinnedViewport, + syncTerminalScrollIntentFromViewport +} from './terminal-scroll-intent' +import { + isTerminalScrollIntentRebuildInFlight, + onTerminalScrollIntentBufferRebuildComplete +} from './terminal-scroll-intent-rebuild' +import { restoreScrollStateAfterFit } from './pane-scroll' +import type { ScrollState } from './pane-manager-types' + +function createTerminal(viewportY: number, baseY: number) { + const active = { type: 'normal', viewportY, baseY } + return { + buffer: { active }, + scrollToBottom: vi.fn(() => { + active.viewportY = active.baseY + }), + scrollToLine: vi.fn((line: number) => { + active.viewportY = line + }) + } +} + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve = (): void => {} + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise + }) + return { promise, resolve } +} + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +describe('terminal structural replay coordinator', () => { + it('serializes rebuilds and restores a pinned bottom offset after each parse', async () => { + const terminal = createTerminal(80, 100) + markTerminalPinnedViewport(terminal) + const coordinator = createTerminalStructuralReplayCoordinator(terminal) + const firstParsed = deferred() + const secondParsed = deferred() + const starts: string[] = [] + + const first = coordinator.run(async () => { + starts.push('first') + terminal.buffer.active.viewportY = 0 + terminal.buffer.active.baseY = 0 + await firstParsed.promise + terminal.buffer.active.viewportY = 200 + terminal.buffer.active.baseY = 200 + }) + const second = coordinator.run(async () => { + starts.push('second') + terminal.buffer.active.viewportY = 0 + terminal.buffer.active.baseY = 0 + await secondParsed.promise + terminal.buffer.active.viewportY = 300 + terminal.buffer.active.baseY = 300 + }) + + await Promise.resolve() + await Promise.resolve() + expect(starts).toEqual(['first']) + firstParsed.resolve() + await first + expect(terminal.buffer.active.viewportY).toBe(180) + await Promise.resolve() + expect(starts).toEqual(['first', 'second']) + secondParsed.resolve() + await second + expect(terminal.buffer.active.viewportY).toBe(280) + }) + + it('lets user viewport intent observed during replay supersede the old pin', async () => { + const terminal = createTerminal(80, 100) + markTerminalPinnedViewport(terminal) + const coordinator = createTerminalStructuralReplayCoordinator(terminal) + const parsed = deferred() + + const completion = coordinator.run(async () => { + terminal.buffer.active.viewportY = 0 + terminal.buffer.active.baseY = 0 + onTerminalScrollIntentBufferRebuildComplete(terminal, (completed) => { + if (completed) { + syncTerminalScrollIntentFromViewport(terminal, { allowBufferShrink: true }) + } + }) + await parsed.promise + terminal.buffer.active.viewportY = 190 + terminal.buffer.active.baseY = 200 + }) + + parsed.resolve() + await completion + expect(terminal.buffer.active.viewportY).toBe(190) + expect(terminal.scrollToLine).not.toHaveBeenCalled() + }) + + it('releases a rebuild on disposal without restoring a half-parsed buffer', async () => { + const terminal = createTerminal(80, 100) + markTerminalPinnedViewport(terminal) + const coordinator = createTerminalStructuralReplayCoordinator(terminal) + const neverParsed = new Promise(() => {}) + let postRebuildCompleted: boolean | null = null + + const completion = coordinator.run(async () => { + terminal.buffer.active.viewportY = 0 + terminal.buffer.active.baseY = 0 + onTerminalScrollIntentBufferRebuildComplete(terminal, (completed) => { + postRebuildCompleted = completed + }) + await neverParsed + }) + await Promise.resolve() + await Promise.resolve() + coordinator.dispose() + await completion + + expect(postRebuildCompleted).toBe(false) + expect(terminal.buffer.active.viewportY).toBe(0) + expect(terminal.scrollToLine).not.toHaveBeenCalled() + }) + + it('cancels a pre-existing fit restore before replay can make it stale', async () => { + const rafCallbacks: FrameRequestCallback[] = [] + const cancelAnimationFrame = vi.fn() + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + rafCallbacks.push(callback) + return rafCallbacks.length + }) + vi.stubGlobal('cancelAnimationFrame', cancelAnimationFrame) + const terminal = createTerminal(80, 100) as ReturnType & { + element: object | null + } + terminal.element = null + const staleState: ScrollState = { + bufferType: 'normal', + wasAtBottom: false, + viewportY: 20, + baseY: 100 + } + restoreScrollStateAfterFit(terminal as never, staleState, { + onRestored: vi.fn(), + shouldRestore: () => true + }) + expect(rafCallbacks).toHaveLength(1) + + markTerminalPinnedViewport(terminal) + const coordinator = createTerminalStructuralReplayCoordinator(terminal) + const parsed = deferred() + const completion = coordinator.run(async () => { + terminal.buffer.active.viewportY = 0 + terminal.buffer.active.baseY = 0 + await parsed.promise + terminal.buffer.active.viewportY = 200 + terminal.buffer.active.baseY = 200 + }) + await Promise.resolve() + await Promise.resolve() + expect(cancelAnimationFrame).toHaveBeenCalledWith(1) + + terminal.element = {} + parsed.resolve() + await completion + expect(terminal.buffer.active.viewportY).toBe(180) + rafCallbacks[0]?.(0) + expect(terminal.buffer.active.viewportY).toBe(180) + }) + + it('restores and releases replay when an optional completion listener throws', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + const terminal = createTerminal(80, 100) + markTerminalPinnedViewport(terminal) + const coordinator = createTerminalStructuralReplayCoordinator(terminal) + const laterCompletion = vi.fn() + + await coordinator.run(() => { + terminal.buffer.active.viewportY = 200 + terminal.buffer.active.baseY = 200 + onTerminalScrollIntentBufferRebuildComplete(terminal, () => { + throw new Error('optional listener failed') + }) + onTerminalScrollIntentBufferRebuildComplete(terminal, laterCompletion) + }) + + expect(laterCompletion).toHaveBeenCalledWith(true) + expect(terminal.buffer.active.viewportY).toBe(180) + expect(isTerminalScrollIntentRebuildInFlight(terminal)).toBe(false) + expect(consoleError).toHaveBeenCalledWith( + '[terminal] scroll-intent rebuild completion failed', + expect.any(Error) + ) + }) + + it('keeps later replay work serialized behind an asynchronous post-restore fit', async () => { + const terminal = createTerminal(80, 100) + const coordinator = createTerminalStructuralReplayCoordinator(terminal) + const fitCompleted = deferred() + const fitStarted = deferred() + const events: string[] = [] + + const first = coordinator.run( + () => { + events.push('first-replay') + }, + { + afterRestore: async () => { + events.push('first-fit') + fitStarted.resolve() + await fitCompleted.promise + } + } + ) + const second = coordinator.run(() => { + events.push('second-replay') + }) + + await fitStarted.promise + expect(events).toEqual(['first-replay', 'first-fit']) + fitCompleted.resolve() + await first + await second + expect(events).toEqual(['first-replay', 'first-fit', 'second-replay']) + }) + + it('releases an asynchronous post-restore wait when the coordinator is disposed', async () => { + const terminal = createTerminal(80, 100) + const coordinator = createTerminalStructuralReplayCoordinator(terminal) + const fitNeverCompletes = new Promise(() => {}) + const fitStarted = deferred() + const completion = coordinator.run(() => undefined, { + afterRestore: async () => { + fitStarted.resolve() + await fitNeverCompletes + } + }) + + await fitStarted.promise + coordinator.dispose() + + await expect(completion).resolves.toBeUndefined() + }) +}) diff --git a/src/renderer/src/lib/pane-manager/terminal-structural-replay-coordinator.ts b/src/renderer/src/lib/pane-manager/terminal-structural-replay-coordinator.ts new file mode 100644 index 00000000000..35aee56a5cd --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-structural-replay-coordinator.ts @@ -0,0 +1,91 @@ +import { + captureTerminalStructuralScrollIntent, + restoreTerminalStructuralScrollIntent, + type TerminalScrollIntentTarget +} from './terminal-scroll-intent' +import { + beginTerminalScrollIntentBufferRebuild, + cancelTerminalScrollIntentBufferRebuildCompletions, + endTerminalScrollIntentBufferRebuild +} from './terminal-scroll-intent-rebuild' +import { cancelDeferredScrollRestore } from './pane-scroll' + +type StructuralReplayTask = () => void | Promise + +type StructuralReplayOptions = { + shouldRestore?: () => boolean + afterRestore?: () => void | Promise +} + +export type TerminalStructuralReplayCoordinator = { + run: (task: StructuralReplayTask, options?: StructuralReplayOptions) => Promise + dispose: () => void +} + +// Why: clear-and-replay bytes parse later and can overlap. One pane-scoped +// queue prevents dimension changes and stale viewport restores from interleaving. +export function createTerminalStructuralReplayCoordinator( + terminal: TerminalScrollIntentTarget +): TerminalStructuralReplayCoordinator { + let disposed = false + let activeCancellation: (() => void) | null = null + let tail = Promise.resolve() + + const run = ( + task: StructuralReplayTask, + options: StructuralReplayOptions = {} + ): Promise => { + const completion = tail + .catch(() => undefined) + .then(async () => { + if (disposed) { + return + } + const intent = captureTerminalStructuralScrollIntent(terminal) + // Why: a pre-replay fit retry can otherwise run after this transaction + // and restore a stale marker over the authoritative replay viewport. + cancelDeferredScrollRestore(terminal) + beginTerminalScrollIntentBufferRebuild(terminal) + let cancelTask = (): void => {} + const cancellation = new Promise((resolve) => { + cancelTask = resolve + }) + activeCancellation = () => { + cancelTask() + } + try { + const taskCompletion = Promise.resolve(task()) + await Promise.race([taskCompletion, cancellation]) + } finally { + endTerminalScrollIntentBufferRebuild(terminal) + try { + const shouldRestore = !disposed && options.shouldRestore?.() !== false + if (shouldRestore) { + restoreTerminalStructuralScrollIntent(terminal, intent, { restoreBy: 'bottomOffset' }) + // Why: live bytes must remain serialized behind replay until any + // post-restore fit has produced the authoritative destination grid. + await Promise.race([Promise.resolve(options.afterRestore?.()), cancellation]) + } + } finally { + activeCancellation = null + } + } + }) + tail = completion + return completion + } + + return { + run, + dispose: () => { + if (disposed) { + return + } + disposed = true + // Why: a torn-down terminal may silently drop write callbacks. Release + // the rebuild without sampling its half-parsed buffer into the keyed pin. + cancelTerminalScrollIntentBufferRebuildCompletions(terminal) + activeCancellation?.() + } + } +} diff --git a/src/renderer/src/lib/pane-manager/terminal-viewport-scrollbar-sync.ts b/src/renderer/src/lib/pane-manager/terminal-viewport-scrollbar-sync.ts new file mode 100644 index 00000000000..b5d970e088d --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-viewport-scrollbar-sync.ts @@ -0,0 +1,29 @@ +import type { Terminal } from '@xterm/xterm' + +// Why: xterm 6 can leave its scrollbar thumb stale when ydisp is unchanged. +// A synchronous one-line jiggle updates the scrollbar without a visible paint. +export function forceTerminalViewportScrollbarSync(terminal: Terminal): void { + const buf = terminal.buffer.active + if (buf.viewportY >= buf.baseY) { + // Why: jiggle-scrolling at bottom makes xterm stop following active output + // after split-pane resizes; scrollToBottom already places the thumb there. + return + } + if (buf.viewportY > 0) { + safeScrollCall(() => terminal.scrollLines(-1)) + safeScrollCall(() => terminal.scrollLines(1)) + } else if (buf.viewportY < buf.baseY) { + safeScrollCall(() => terminal.scrollLines(1)) + safeScrollCall(() => terminal.scrollLines(-1)) + } +} + +function safeScrollCall(fn: () => void): void { + try { + fn() + } catch (error) { + if (!(error instanceof TypeError) || !/dimensions/.test(error.message)) { + throw error + } + } +} diff --git a/src/renderer/src/lib/pane-manager/xterm-user-scrolling-contract.test.ts b/src/renderer/src/lib/pane-manager/xterm-user-scrolling-contract.test.ts new file mode 100644 index 00000000000..2141e2bf187 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/xterm-user-scrolling-contract.test.ts @@ -0,0 +1,182 @@ +/** + * Contract test for xterm's native user-scrolling ownership (vendored + * 6.1.0-beta.287; @xterm/headless shares BufferService with @xterm/xterm). + * + * Orca's live PTY write path performs NO scroll-intent enforcement — it + * relies on xterm core keeping a scrolled-up viewport stable and following + * output at the bottom (BufferService.isUserScrolling, consumed atomically + * inside scroll()). App-side enforcement is scoped to structural operations + * (snapshot replay, remount, fit reflow) in terminal-scroll-intent.ts. + * + * If an xterm upgrade breaks any assertion here, the live write path loses + * its follow/pin semantics silently — fix the write path before bumping. + */ +import { describe, expect, it } from 'vitest' +import { Terminal } from '@xterm/headless' +import packageJson from '../../../../../package.json' +import { clearTerminalScrollbackAndFollowOutput } from './terminal-scrollback-clear' + +type TerminalWithBufferService = Terminal & { + _core?: { + _bufferService?: { isUserScrolling?: boolean } + coreService?: { onUserInput?: (listener: () => void) => { dispose: () => void } } + } +} + +function write(term: Terminal, data: string): Promise { + return new Promise((resolve) => term.write(data, resolve)) +} + +async function writeLines(term: Terminal, count: number, label: string): Promise { + for (let i = 0; i < count; i += 1) { + await write(term, `${label}${i}\r\n`) + } +} + +describe('xterm native user-scrolling contract (vendored 6.1.0-beta.287)', () => { + it('pins headless and renderer xterm to the same version', () => { + expect(packageJson.dependencies['@xterm/headless']).toBe( + packageJson.devDependencies['@xterm/xterm'] + ) + }) + + it('keeps a scrolled-up viewport stable while output is written', async () => { + const term = new Terminal({ rows: 10, cols: 40, scrollback: 1000, allowProposedApi: true }) + await writeLines(term, 30, 'line') + const buffer = term.buffer.active + expect(buffer.viewportY).toBe(buffer.baseY) + + term.scrollLines(-5) + const pinnedY = buffer.viewportY + expect(pinnedY).toBe(buffer.baseY - 5) + + await writeLines(term, 10, 'more') + expect(buffer.viewportY).toBe(pinnedY) + expect(buffer.baseY).toBe(pinnedY + 15) + }) + + it('treats a viewport one row above bottom as user-scrolling through output', async () => { + const term = new Terminal({ + rows: 10, + cols: 40, + scrollback: 1000, + allowProposedApi: true + }) as TerminalWithBufferService + await writeLines(term, 30, 'line') + const buffer = term.buffer.active + + term.scrollLines(-1) + const pinnedY = buffer.viewportY + expect(pinnedY).toBe(buffer.baseY - 1) + expect(term._core?._bufferService?.isUserScrolling).toBe(true) + + await writeLines(term, 5, 'more') + expect(buffer.viewportY).toBe(pinnedY) + }) + + it('follows output at the bottom and re-follows after scrolling back down', async () => { + const term = new Terminal({ rows: 10, cols: 40, scrollback: 1000, allowProposedApi: true }) + await writeLines(term, 30, 'line') + const buffer = term.buffer.active + + await writeLines(term, 5, 'tail') + expect(buffer.viewportY).toBe(buffer.baseY) + + term.scrollLines(-5) + term.scrollToBottom() + await writeLines(term, 5, 'after') + expect(buffer.viewportY).toBe(buffer.baseY) + }) + + it('applies scrollOnUserInput before notifying onData listeners', async () => { + const term = new Terminal({ rows: 10, cols: 40, scrollback: 1000, allowProposedApi: true }) + await writeLines(term, 30, 'line') + const buffer = term.buffer.active + term.scrollLines(-5) + let viewportSeenByOnData = -1 + const subscription = term.onData(() => { + viewportSeenByOnData = buffer.viewportY + }) + + term.input('a', true) + + // Why: Orca resyncs typing intent synchronously from onData, so this + // xterm ordering is part of the pinned-version contract. + expect(viewportSeenByOnData).toBe(buffer.baseY) + subscription.dispose() + }) + + it('distinguishes real user input from parser auto-replies', async () => { + const term = new Terminal({ + rows: 10, + cols: 40, + allowProposedApi: true + }) as TerminalWithBufferService + expect(term._core?.coreService?.onUserInput).toBeTypeOf('function') + let userInputCount = 0 + const subscription = term._core?.coreService?.onUserInput?.(() => { + userInputCount += 1 + }) + + term.input('a', true) + await write(term, '\x1b[6n') + + expect(userInputCount).toBe(1) + subscription?.dispose() + }) + + it('walks a pinned viewport down content-stably when scrollback trims', async () => { + const term = new Terminal({ rows: 5, cols: 20, scrollback: 20, allowProposedApi: true }) + await writeLines(term, 30, 'x') + const buffer = term.buffer.active + term.scrollLines(-10) + const pinnedY = buffer.viewportY + const fullBaseY = buffer.baseY + + await writeLines(term, 10, 'trim') + // Buffer is at capacity: baseY stays put while each trimmed line shifts + // the pinned viewport up by one so the visible content does not move. + expect(buffer.baseY).toBe(fullBaseY) + expect(buffer.viewportY).toBe(Math.max(0, pinnedY - 10)) + }) + + it('exposes the isUserScrolling flag the structural restore paths depend on', async () => { + const term = new Terminal({ + rows: 10, + cols: 40, + scrollback: 1000, + allowProposedApi: true + }) as TerminalWithBufferService + await writeLines(term, 30, 'line') + const bufferService = term._core?._bufferService + expect(typeof bufferService?.isUserScrolling).toBe('boolean') + + // scrollLines/scrollToBottom self-manage the flag, so Orca's programmatic + // scroll restores inherit xterm's native live-output ownership. + expect(bufferService?.isUserScrolling).toBe(false) + term.scrollLines(-5) + expect(bufferService?.isUserScrolling).toBe(true) + term.scrollToBottom() + expect(bufferService?.isUserScrolling).toBe(false) + }) + + it('resets native user-scrolling when a pinned scrollback is cleared', async () => { + const term = new Terminal({ + rows: 10, + cols: 40, + scrollback: 1000, + allowProposedApi: true + }) as TerminalWithBufferService + await writeLines(term, 30, 'line') + term.scrollLines(-5) + expect(term._core?._bufferService?.isUserScrolling).toBe(true) + + clearTerminalScrollbackAndFollowOutput(term) + expect(term.buffer.active.viewportY).toBe(0) + expect(term.buffer.active.baseY).toBe(0) + expect(term._core?._bufferService?.isUserScrolling).toBe(false) + + await writeLines(term, 15, 'after-clear') + expect(term.buffer.active.viewportY).toBe(term.buffer.active.baseY) + }) +}) diff --git a/src/renderer/src/lib/session-write-subscriber.test.ts b/src/renderer/src/lib/session-write-subscriber.test.ts index 703c5405834..3c31296095a 100644 --- a/src/renderer/src/lib/session-write-subscriber.test.ts +++ b/src/renderer/src/lib/session-write-subscriber.test.ts @@ -601,6 +601,32 @@ describe('createSessionWriteSubscriber', () => { cleanup() }) + it('rebuilds a pending tab patch after close so it cannot resurrect the tab', () => { + const persist = vi.fn<(payload: WorkspaceSessionWrite) => void>() + const cleanup = createSessionWriteSubscriber({ store: useAppStore, persist }) + + useAppStore.setState({ + workspaceSessionReady: true, + hydrationSucceeded: true, + ...makeTerminalSessionState('shell') + }) + vi.advanceTimersByTime(50) + useAppStore.setState({ + tabsByWorktree: { 'wt-1': [] }, + unifiedTabsByWorktree: { 'wt-1': [] }, + groupsByWorktree: { 'wt-1': [] }, + layoutByWorktree: {}, + activeGroupIdByWorktree: {}, + activeTabId: null + }) + vi.advanceTimersByTime(200) + + expect(persist).toHaveBeenCalledTimes(1) + expect(persist.mock.calls[0][0].patch.tabsByWorktree?.['wt-1']).toEqual([]) + expect(persist.mock.calls[0][0].patch.unifiedTabs?.['wt-1']).toBeUndefined() + cleanup() + }) + it('cleanup unsubscribes and cancels a pending timer', () => { const persist = vi.fn<(payload: WorkspaceSessionWrite) => void>() const cleanup = createSessionWriteSubscriber({ store: useAppStore, persist }) diff --git a/src/renderer/src/lib/settings-navigation-types.ts b/src/renderer/src/lib/settings-navigation-types.ts index 765940ffe9e..3541c0e3f2a 100644 --- a/src/renderer/src/lib/settings-navigation-types.ts +++ b/src/renderer/src/lib/settings-navigation-types.ts @@ -3,7 +3,7 @@ import type { LucideProps } from 'lucide-react' import type { SettingsSearchEntry } from '@/components/settings/settings-search' export type SettingsNavIcon = ComponentType -export type SettingsNavInstallStatus = 'install' | 'installed' | 'checking' +export type SettingsNavInstallStatus = 'install' | 'installed' | 'update-available' | 'checking' export type SettingsNavTarget = | 'general' diff --git a/src/renderer/src/lib/terminal-tab-for-pty-id.test.ts b/src/renderer/src/lib/terminal-tab-for-pty-id.test.ts new file mode 100644 index 00000000000..4fdc698e79e --- /dev/null +++ b/src/renderer/src/lib/terminal-tab-for-pty-id.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { resolveTerminalTabIdForPtyId } from './terminal-tab-for-pty-id' +import type { AppState } from '@/store/types' + +type ResolverState = Pick + +function state(partial: { + tabs?: Record + layouts?: Record }> +}): ResolverState { + return { + tabsByWorktree: (partial.tabs ?? {}) as unknown as AppState['tabsByWorktree'], + terminalLayoutsByTabId: (partial.layouts ?? {}) as unknown as AppState['terminalLayoutsByTabId'] + } +} + +describe('resolveTerminalTabIdForPtyId', () => { + it('matches a tab by its own ptyId', () => { + const s = state({ + tabs: { + wt: [ + { id: 'tab-a', ptyId: 'wt@@1' }, + { id: 'tab-b', ptyId: 'wt@@2' } + ] + } + }) + expect(resolveTerminalTabIdForPtyId(s, 'wt', 'wt@@2')).toBe('tab-b') + }) + + it('matches a tab by a split leaf ptyId in its saved layout', () => { + const s = state({ + tabs: { wt: [{ id: 'tab-a', ptyId: null }] }, + layouts: { 'tab-a': { ptyIdsByLeafId: { leaf1: 'wt@@1', leaf2: 'wt@@9' } } } + }) + expect(resolveTerminalTabIdForPtyId(s, 'wt', 'wt@@9')).toBe('tab-a') + }) + + it('returns null when no tab owns the ptyId', () => { + const s = state({ tabs: { wt: [{ id: 'tab-a', ptyId: 'wt@@1' }] } }) + expect(resolveTerminalTabIdForPtyId(s, 'wt', 'wt@@nope')).toBeNull() + }) + + it('returns null when stale persistence binds the ptyId to multiple tabs', () => { + const s = state({ + tabs: { + wt: [ + { id: 'tab-a', ptyId: 'wt@@1' }, + { id: 'tab-b', ptyId: null } + ] + }, + layouts: { 'tab-b': { ptyIdsByLeafId: { leaf2: 'wt@@1' } } } + }) + expect(resolveTerminalTabIdForPtyId(s, 'wt', 'wt@@1')).toBeNull() + }) + + it('returns null for an unknown worktree', () => { + const s = state({ tabs: { wt: [{ id: 'tab-a', ptyId: 'wt@@1' }] } }) + expect(resolveTerminalTabIdForPtyId(s, 'other', 'wt@@1')).toBeNull() + }) +}) diff --git a/src/renderer/src/lib/terminal-tab-for-pty-id.ts b/src/renderer/src/lib/terminal-tab-for-pty-id.ts new file mode 100644 index 00000000000..77ee3c29706 --- /dev/null +++ b/src/renderer/src/lib/terminal-tab-for-pty-id.ts @@ -0,0 +1,27 @@ +import type { AppState } from '@/store/types' + +/** Resolve a synthetic mobile handle's ptyId through persisted tab and split bindings. */ +export function resolveTerminalTabIdForPtyId( + state: Pick, + worktreeId: string, + ptyId: string +): string | null { + const tabs = state.tabsByWorktree[worktreeId] ?? [] + let resolvedTabId: string | null = null + for (const tab of tabs) { + const ptyIdsByLeafId = state.terminalLayoutsByTabId[tab.id]?.ptyIdsByLeafId + const ownsPty = + tab.ptyId === ptyId || + (ptyIdsByLeafId !== undefined && Object.values(ptyIdsByLeafId).includes(ptyId)) + if (!ownsPty) { + continue + } + if (resolvedTabId && resolvedTabId !== tab.id) { + // Why: stale duplicate ownership must not attach whichever hidden tab + // happens to appear first in persisted order. + return null + } + resolvedTabId = tab.id + } + return resolvedTabId +} diff --git a/src/renderer/src/lib/workspace-session-host-persistence.test.ts b/src/renderer/src/lib/workspace-session-host-persistence.test.ts index d2bf7336a9e..6c6db493341 100644 --- a/src/renderer/src/lib/workspace-session-host-persistence.test.ts +++ b/src/renderer/src/lib/workspace-session-host-persistence.test.ts @@ -6,7 +6,8 @@ import { buildHostIdByWorktreeId, fetchWorkspaceSessionFromHosts, fetchWorkspaceSessionWithRuntimeHostOwners, - patchWorkspaceSessionByHost + patchWorkspaceSessionByHost, + persistWorkspaceSessionByHost } from './workspace-session-host-persistence' describe('fetchWorkspaceSessionFromHosts', () => { @@ -384,3 +385,69 @@ describe('fetchWorkspaceSessionFromHosts', () => { expect(owner(worktreeWorkspaceKey(worktreeId))).toBe('runtime:env-1') }) }) + +describe('persistWorkspaceSessionByHost', () => { + it('awaits every host write before crossing the durable flush boundary', async () => { + const localWorktreeId = 'local-repo::/src/local' + const remoteWorktreeId = 'remote-repo::/srv/remote' + const payload: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [localWorktreeId]: [ + { + id: 'local-tab', + ptyId: null, + worktreeId: localWorktreeId, + title: 'Local', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ], + [remoteWorktreeId]: [ + { + id: 'remote-tab', + ptyId: null, + worktreeId: remoteWorktreeId, + title: 'Remote', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + } + const set = vi.fn().mockResolvedValue(undefined) + const flush = vi.fn().mockResolvedValue(undefined) + + await persistWorkspaceSessionByHost( + { get: vi.fn(), set, patch: vi.fn(), flush, setSync: vi.fn() }, + payload, + { + repos: [ + { id: 'local-repo', connectionId: null, executionHostId: 'local' }, + { id: 'remote-repo', connectionId: null, executionHostId: 'runtime:env-1' } + ], + worktreesByRepo: { + 'local-repo': [{ id: localWorktreeId, repoId: 'local-repo' }], + 'remote-repo': [{ id: remoteWorktreeId, repoId: 'remote-repo', hostId: 'runtime:env-1' }] + } + } + ) + + expect(set).toHaveBeenCalledTimes(2) + expect(set).toHaveBeenCalledWith( + expect.objectContaining({ tabsByWorktree: { [localWorktreeId]: expect.any(Array) } }) + ) + expect(set).toHaveBeenCalledWith( + expect.objectContaining({ + tabsByWorktree: { [remoteWorktreeId]: expect.any(Array) } + }), + 'runtime:env-1' + ) + expect(flush).toHaveBeenCalledTimes(1) + expect(set.mock.invocationCallOrder.at(-1)).toBeLessThan(flush.mock.invocationCallOrder[0]!) + }) +}) diff --git a/src/renderer/src/lib/workspace-session-host-persistence.ts b/src/renderer/src/lib/workspace-session-host-persistence.ts index f59a827b729..ab7b04c2fed 100644 --- a/src/renderer/src/lib/workspace-session-host-persistence.ts +++ b/src/renderer/src/lib/workspace-session-host-persistence.ts @@ -33,6 +33,11 @@ type SessionApi = { setSync: (args: WorkspaceSessionState, hostId?: ExecutionHostId) => void } +type DurableSessionApi = SessionApi & { + set: (args: WorkspaceSessionState, hostId?: ExecutionHostId) => Promise + flush: () => Promise +} + export type WorkspaceSessionHostRead = { session: WorkspaceSessionState runtimeHostIdByWorkspaceSessionKey: Record @@ -223,6 +228,23 @@ export function patchWorkspaceSessionByHost( return localWrite } +/** Persist a fresh full snapshot to every owning host partition, then force the + * main store to disk. Used by request/reply lifecycle operations whose success + * receipt is a durability boundary rather than a debounced UI update. */ +export async function persistWorkspaceSessionByHost( + api: DurableSessionApi, + payload: WorkspaceSessionState, + state: HostPersistenceState +): Promise { + const slices = splitWorkspaceSessionByHost(payload, buildHostIdByWorktreeId(state)) + const writes: Promise[] = [api.set(slices[LOCAL_EXECUTION_HOST_ID] ?? payload)] + for (const [hostId, slice] of nonLocalEntries(slices)) { + writes.push(api.set(slice, hostId)) + } + await Promise.all(writes) + await api.flush() +} + /** Synchronous full-session split for the beforeunload / quit paths. */ export function persistWorkspaceSessionByHostSync( api: SessionApi, diff --git a/src/renderer/src/lib/workspace-session-liveness.test.ts b/src/renderer/src/lib/workspace-session-liveness.test.ts index db06aaf0e6c..e46fa1b66d7 100644 --- a/src/renderer/src/lib/workspace-session-liveness.test.ts +++ b/src/renderer/src/lib/workspace-session-liveness.test.ts @@ -91,4 +91,137 @@ describe('workspace session live PTY persistence', () => { expect(payload.activeWorktreeIdsOnShutdown).toEqual([]) expect(payload.remoteSessionIdsByTabId).toBeUndefined() }) + + it('reconnects a persisted SSH PTY when shutdown observes a transient relay drop', () => { + const payload = buildWorkspaceSessionPayload( + createSnapshot({ + tabsByWorktree: { + 'wt-ssh': [ + { + id: 'tab-ssh', + title: 'remote', + ptyId: null, + worktreeId: 'wt-ssh' + } as never + ] + }, + ptyIdsByTabId: { 'tab-ssh': [] }, + lastKnownRelayPtyIdByTabId: { 'tab-ssh': 'ssh:conn-1@@pty-42' }, + // Why 'reconnecting': an involuntary transport drop always lands in + // 'reconnecting' / 'reconnection-failed' / 'error' — never + // 'disconnected', which only an explicit user disconnect produces. + sshConnectionStates: new Map([['conn-1', { status: 'reconnecting' } as never]]), + repos: [ + { + id: 'repo-ssh', + path: '/repo-ssh', + displayName: 'SSH', + badgeColor: '#fff', + addedAt: 1, + connectionId: 'conn-1' + } + ], + worktreesByRepo: { + 'repo-ssh': [{ id: 'wt-ssh', repoId: 'repo-ssh' } as never] + } + }) + ) + + expect(payload.remoteSessionIdsByTabId).toEqual({ + 'tab-ssh': 'ssh:conn-1@@pty-42' + }) + expect(payload.activeConnectionIdsAtShutdown).toEqual(['conn-1']) + }) + + it('keeps explicitly disconnected hosts out of the startup reconnect list', () => { + const payload = buildWorkspaceSessionPayload( + createSnapshot({ + tabsByWorktree: { + 'wt-ssh': [ + { + id: 'tab-ssh', + title: 'remote', + ptyId: null, + worktreeId: 'wt-ssh' + } as never + ] + }, + ptyIdsByTabId: { 'tab-ssh': [] }, + lastKnownRelayPtyIdByTabId: { 'tab-ssh': 'ssh:conn-1@@pty-42' }, + // 'disconnected' = the user chose to take this host offline. The + // session id must still persist (restore-on-focus), but startup must + // not auto-dial the host against the user's intent. + sshConnectionStates: new Map([['conn-1', { status: 'disconnected' } as never]]), + repos: [ + { + id: 'repo-ssh', + path: '/repo-ssh', + displayName: 'SSH', + badgeColor: '#fff', + addedAt: 1, + connectionId: 'conn-1' + } + ], + worktreesByRepo: { + 'repo-ssh': [{ id: 'wt-ssh', repoId: 'repo-ssh' } as never] + } + }) + ) + + expect(payload.remoteSessionIdsByTabId).toEqual({ + 'tab-ssh': 'ssh:conn-1@@pty-42' + }) + expect(payload.activeConnectionIdsAtShutdown).toBeUndefined() + }) + + it('never derives runtime-owned SSH targets into the startup reconnect list', () => { + const payload = buildWorkspaceSessionPayload( + createSnapshot({ + tabsByWorktree: { + 'wt-vm': [ + { + id: 'tab-vm', + title: 'vm', + ptyId: null, + worktreeId: 'wt-vm' + } as never + ] + }, + ptyIdsByTabId: { 'tab-vm': [] }, + lastKnownRelayPtyIdByTabId: { 'tab-vm': 'ssh:runtime-ssh-vm1@@pty-7' }, + // Why a status entry: without one the status gate already excludes the + // target and the runtime-owned check would be untested dead weight. + // A pane-driven optimistic write CAN stamp runtime-owned states + // (TerminalSshReconnectOverlay), so pin the exclusion independently — + // on both the session-id union path ('reconnecting') and the live + // connected-states path ('connected'). + sshConnectionStates: new Map([ + ['runtime-ssh-vm1', { status: 'reconnecting' } as never], + ['runtime-ssh-vm2', { status: 'connected' } as never] + ]), + repos: [ + { + id: 'repo-vm', + path: '/repo-vm', + displayName: 'VM', + badgeColor: '#fff', + addedAt: 1, + connectionId: 'runtime-ssh-vm1' + } + ], + worktreesByRepo: { + 'repo-vm': [{ id: 'wt-vm', repoId: 'repo-vm' } as never] + } + }) + ) + + // Why: the renderer must never drive startup ssh.connect for runtime-owned + // (ephemeral-VM) targets — their lifecycle belongs to the runtime layer, + // and ssh.listTargets() hides them so the connect would target a host the + // user cannot see or manage. + expect(payload.remoteSessionIdsByTabId).toEqual({ + 'tab-vm': 'ssh:runtime-ssh-vm1@@pty-7' + }) + expect(payload.activeConnectionIdsAtShutdown).toBeUndefined() + }) }) diff --git a/src/renderer/src/lib/workspace-session-patch.test.ts b/src/renderer/src/lib/workspace-session-patch.test.ts index ec4ea11e643..da578c1ff42 100644 --- a/src/renderer/src/lib/workspace-session-patch.test.ts +++ b/src/renderer/src/lib/workspace-session-patch.test.ts @@ -205,6 +205,10 @@ describe('buildWorkspaceSessionPatch', () => { expect(Object.keys(patch).sort()).toEqual( [ + // Why: the reconnect list derives from remote session ids, so any + // patch that rewrites remoteSessionIdsByTabId must carry it too — + // otherwise a crash between patches strands a stale target on disk. + 'activeConnectionIdsAtShutdown', 'activeWorktreeIdsOnShutdown', 'remoteSessionIdsByTabId', 'tabsByWorktree', @@ -225,6 +229,25 @@ describe('buildWorkspaceSessionPatch', () => { expect(patch.activeConnectionIdsAtShutdown).toBeUndefined() }) + it('derives reconnect targets from surviving relay session ids in terminal-field patches', () => { + const patch = buildWorkspaceSessionPatch( + createSnapshot({ + tabsByWorktree: { + 'wt-ssh': [{ id: 'tab-ssh', title: 'remote', ptyId: null, worktreeId: 'wt-ssh' } as never] + }, + ptyIdsByTabId: { 'tab-ssh': [] }, + lastKnownRelayPtyIdByTabId: { 'tab-ssh': 'ssh:conn-1@@pty-42' }, + sshConnectionStates: new Map([['conn-1', { status: 'reconnecting' } as never]]), + repos: [createRepo('repo-ssh', 'conn-1')], + worktreesByRepo: { 'repo-ssh': [{ id: 'wt-ssh', repoId: 'repo-ssh' } as never] } + }), + ['lastKnownRelayPtyIdByTabId'] + ) + + expect(patch.remoteSessionIdsByTabId).toEqual({ 'tab-ssh': 'ssh:conn-1@@pty-42' }) + expect(patch.activeConnectionIdsAtShutdown).toEqual(['conn-1']) + }) + it('patches tab chrome as a sanitized bundle when split groups change', () => { const patch = buildWorkspaceSessionPatch( createSnapshot({ diff --git a/src/renderer/src/lib/workspace-session-patch.ts b/src/renderer/src/lib/workspace-session-patch.ts index eff46edfa23..e88f476a8f6 100644 --- a/src/renderer/src/lib/workspace-session-patch.ts +++ b/src/renderer/src/lib/workspace-session-patch.ts @@ -72,10 +72,21 @@ export function buildWorkspaceSessionPatch( 'worktreesByRepo' ] as const) ) { - Object.assign(patch, buildTerminalSessionData(snapshot)) - } - if (changed.has('sshConnectionStates')) { - patch.activeConnectionIdsAtShutdown = buildActiveConnectionIdsAtShutdown(snapshot) + const terminalSessionData = buildTerminalSessionData(snapshot) + Object.assign(patch, terminalSessionData) + // Why: the reconnect list is derived from persisted remote session ids as + // well as live SSH state. Recompute it alongside remoteSessionIdsByTabId + // so a crash between patches cannot leave a target on disk whose sessions + // were all closed (or vice versa). + patch.activeConnectionIdsAtShutdown = buildActiveConnectionIdsAtShutdown( + snapshot, + terminalSessionData.remoteSessionIdsByTabId ?? null + ) + } else if (changed.has('sshConnectionStates')) { + patch.activeConnectionIdsAtShutdown = buildActiveConnectionIdsAtShutdown( + snapshot, + buildTerminalSessionData(snapshot).remoteSessionIdsByTabId ?? null + ) } if ( hasAnyChangedField(changed, [ diff --git a/src/renderer/src/lib/workspace-session-reconnect-targets.ts b/src/renderer/src/lib/workspace-session-reconnect-targets.ts new file mode 100644 index 00000000000..713e4388e53 --- /dev/null +++ b/src/renderer/src/lib/workspace-session-reconnect-targets.ts @@ -0,0 +1,47 @@ +import type { WorkspaceSessionState } from '../../../shared/types' +import { parseAppSshPtyId } from '../../../shared/ssh-pty-id' +import { isRuntimeOwnedSshTargetId } from '../../../shared/execution-host' +import type { WorkspaceSessionSnapshot } from './workspace-session' + +export function buildActiveConnectionIdsAtShutdown( + snapshot: WorkspaceSessionSnapshot, + // Why: required (null = none) rather than defaulted — every caller already + // has the terminal session data in hand, and a default initializer would + // re-run the full repo/worktree scan on the shutdown-critical path. + remoteSessionIdsByTabId: WorkspaceSessionState['remoteSessionIdsByTabId'] | null +): WorkspaceSessionState['activeConnectionIdsAtShutdown'] { + // Why: sshConnectionStates is a Map, not a plain + // object. Object.entries() on a Map returns [] — must use Array.from(). + // Runtime-owned states are normally never broadcast to the renderer, but a + // pane-level optimistic write could stamp one; exclude them here too. + const targetIds = new Set( + Array.from(snapshot.sshConnectionStates.entries()) + .filter( + ([targetId, state]) => state.status === 'connected' && !isRuntimeOwnedSshTargetId(targetId) + ) + .map(([targetId]) => targetId) + ) + + // Why: shutdown can observe SSH in a transient state (relay drop mid-quit, + // exhausted reconnect) after the socket closed but before the snapshot + // flushed. The durable PTY id still names the target Orca must reconnect to + // restore that surviving remote session. Two exclusions: + // 'disconnected'/'auth-failed'/never-observed usually mean an explicit user + // disconnect or a failed/cancelled connect — startup must not auto-dial a + // host the user left offline or stack credential dialogs (sessions still + // restore on tab focus via the deferred flow, so only eagerness is lost). + // Runtime-owned (ephemeral-VM) targets belong to the runtime layer; a + // renderer-driven ssh.connect would dispose the runtime's live relay session. + for (const sessionId of Object.values(remoteSessionIdsByTabId ?? {})) { + const connectionId = parseAppSshPtyId(sessionId)?.connectionId + if (!connectionId || isRuntimeOwnedSshTargetId(connectionId)) { + continue + } + const status = snapshot.sshConnectionStates.get(connectionId)?.status + if (status && status !== 'disconnected' && status !== 'auth-failed') { + targetIds.add(connectionId) + } + } + + return targetIds.size > 0 ? Array.from(targetIds) : undefined +} diff --git a/src/renderer/src/lib/workspace-session.ts b/src/renderer/src/lib/workspace-session.ts index f0acc22e620..23cc4da80e0 100644 --- a/src/renderer/src/lib/workspace-session.ts +++ b/src/renderer/src/lib/workspace-session.ts @@ -12,6 +12,9 @@ import type { OpenFile } from '../store/slices/editor' import { buildPersistedUnifiedTabSessionData } from './workspace-session-unified-tabs' import { buildLastVisitedAtByWorktreeId } from './workspace-session-focus-recency' import { buildSleepingAgentSessionData } from './workspace-session-sleeping-agents' +import { buildActiveConnectionIdsAtShutdown } from './workspace-session-reconnect-targets' + +export { buildActiveConnectionIdsAtShutdown } /** Why (issue #1158): the debounced + shutdown session writers share this * gate so a hydration failure cannot overwrite orca-data.json with the @@ -331,18 +334,6 @@ export function buildTerminalSessionData( } } -export function buildActiveConnectionIdsAtShutdown( - snapshot: WorkspaceSessionSnapshot -): WorkspaceSessionState['activeConnectionIdsAtShutdown'] { - // Why: sshConnectionStates is a Map, not a plain - // object. Object.entries() on a Map returns [] — must use Array.from(). - const connectedTargetIds = Array.from(snapshot.sshConnectionStates.entries()) - .filter(([, state]) => state.status === 'connected') - .map(([targetId]) => targetId) - - return connectedTargetIds.length > 0 ? connectedTargetIds : undefined -} - export function buildWorkspaceSessionPayload( snapshot: WorkspaceSessionSnapshot ): WorkspaceSessionState { @@ -380,7 +371,10 @@ export function buildWorkspaceSessionPayload( // Persist only layouts backed by real tabs so a reload cannot restore a // blank split pane from that transient midpoint. ...buildPersistedUnifiedTabSessionData(snapshot), - activeConnectionIdsAtShutdown: buildActiveConnectionIdsAtShutdown(snapshot), + activeConnectionIdsAtShutdown: buildActiveConnectionIdsAtShutdown( + snapshot, + terminalSessionData.remoteSessionIdsByTabId ?? null + ), remoteSessionIdsByTabId: terminalSessionData.remoteSessionIdsByTabId, // Why: per-worktree focus-recency for Cmd+J's empty-query ordering. // Omit when empty so sessions written by builds that never stamped diff --git a/src/renderer/src/runtime/abortable-runtime-environment-call.ts b/src/renderer/src/runtime/abortable-runtime-environment-call.ts new file mode 100644 index 00000000000..88b9dc9d6fb --- /dev/null +++ b/src/renderer/src/runtime/abortable-runtime-environment-call.ts @@ -0,0 +1,65 @@ +import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' + +export function createRuntimeRpcAbortError(): Error { + const error = new Error('Runtime request aborted') + error.name = 'AbortError' + return error +} + +export async function callAbortableRuntimeEnvironment( + environmentId: string, + method: string, + params: unknown, + timeoutMs: number | undefined, + signal: AbortSignal +): Promise> { + if (signal.aborted) { + throw createRuntimeRpcAbortError() + } + // Why: the one-shot runtime call bridge cannot cancel host work; the + // subscription bridge closes its request context when we unsubscribe. + return new Promise((resolve, reject) => { + let handle: { unsubscribe: () => void } | null = null + let settled = false + // Why: the subscription transport's own timeout only bounds subscription + // start, not the response. Keep the one-shot call path's response deadline + // so a connected-but-unresponsive runtime cannot stall the caller forever. + const deadline = + timeoutMs === undefined + ? null + : setTimeout(() => { + finish(() => reject(new Error(`Runtime request timed out before ${method} completed`))) + }, timeoutMs) + const finish = (complete: () => void): void => { + if (settled) { + return + } + settled = true + if (deadline !== null) { + clearTimeout(deadline) + } + signal.removeEventListener('abort', onAbort) + handle?.unsubscribe() + complete() + } + const onAbort = (): void => finish(() => reject(createRuntimeRpcAbortError())) + signal.addEventListener('abort', onAbort, { once: true }) + void window.api.runtimeEnvironments + .subscribe( + { selector: environmentId, method, params, timeoutMs }, + { + onResponse: (response) => finish(() => resolve(response)), + onError: (error) => finish(() => reject(new Error(error.message))), + onClose: () => + finish(() => reject(new Error(`Runtime request closed before ${method} completed`))) + } + ) + .then((subscription) => { + handle = subscription + if (settled) { + subscription.unsubscribe() + } + }) + .catch((error) => finish(() => reject(error))) + }) +} diff --git a/src/renderer/src/runtime/runtime-git-client.test.ts b/src/renderer/src/runtime/runtime-git-client.test.ts index 64c12946a4b..78ed0853a48 100644 --- a/src/renderer/src/runtime/runtime-git-client.test.ts +++ b/src/renderer/src/runtime/runtime-git-client.test.ts @@ -23,6 +23,7 @@ import { import { clearRuntimeCompatibilityCacheForTests } from './runtime-rpc-client' const gitStatus = vi.fn() +const gitCancelStatus = vi.fn() const gitCheckIgnored = vi.fn() const gitSubmoduleStatus = vi.fn() const gitDiff = vi.fn() @@ -44,6 +45,8 @@ const runtimeCall = vi.fn() beforeEach(() => { clearRuntimeCompatibilityCacheForTests() gitStatus.mockReset() + gitCancelStatus.mockReset() + gitCancelStatus.mockResolvedValue(undefined) gitCheckIgnored.mockReset() gitSubmoduleStatus.mockReset() gitDiff.mockReset() @@ -68,6 +71,7 @@ beforeEach(() => { api: { git: { status: gitStatus, + cancelStatus: gitCancelStatus, checkIgnored: gitCheckIgnored, submoduleStatus: gitSubmoduleStatus, diff: gitDiff, @@ -204,6 +208,41 @@ describe('runtime git client', () => { }) }) + it('forwards line-stat reuse to local status and cancels tokenized work on abort', async () => { + const controller = new AbortController() + let resolveStatus!: (value: { entries: never[]; conflictOperation: string }) => void + gitStatus.mockImplementation( + () => + new Promise((resolve) => { + resolveStatus = resolve + }) + ) + + const request = getRuntimeGitStatus( + { + settings: { activeRuntimeEnvironmentId: null }, + worktreeId: 'wt-1', + worktreePath: '/repo' + }, + { reuseLineStats: true, signal: controller.signal } + ) + await vi.waitFor(() => expect(gitStatus).toHaveBeenCalled()) + const statusArgs = gitStatus.mock.calls[0]?.[0] as { requestToken?: string } + controller.abort() + resolveStatus({ entries: [], conflictOperation: 'unknown' }) + // Why: cancel is best-effort on the main process; even if status still + // settles, the aborted local call must reject rather than look fresh. + await expect(request).rejects.toMatchObject({ name: 'AbortError' }) + + expect(gitStatus).toHaveBeenCalledWith({ + worktreePath: '/repo', + connectionId: undefined, + reuseLineStats: true, + requestToken: expect.any(String) + }) + expect(gitCancelStatus).toHaveBeenCalledWith({ requestToken: statusArgs.requestToken }) + }) + it('checks ignored paths through local git IPC', async () => { gitCheckIgnored.mockResolvedValue(['dist/bundle.js']) @@ -408,6 +447,57 @@ describe('runtime git client', () => { }) }) + it('keeps safety (reuse) refreshes on the pooled call transport even with a signal', async () => { + const controller = new AbortController() + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { entries: [], conflictOperation: 'unknown' }, + _meta: { runtimeId: 'remote-runtime' } + }) + + await getRuntimeGitStatus( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/repo' + }, + { reuseLineStats: true, signal: controller.signal } + ) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'git.status', + params: { worktree: 'id:wt-1', reuseLineStats: true }, + timeoutMs: 15_000 + }) + }) + + it('forwards line-stat reuse through the active runtime environment', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { entries: [], conflictOperation: 'unknown' }, + _meta: { runtimeId: 'remote-runtime' } + }) + + await getRuntimeGitStatus( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/repo' + }, + { reuseLineStats: true } + ) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'git.status', + params: { worktree: 'id:wt-1', reuseLineStats: true }, + timeoutMs: 15_000 + }) + }) + it('checks ignored paths through the active runtime environment', async () => { runtimeEnvironmentCall.mockResolvedValue({ id: 'rpc-1', diff --git a/src/renderer/src/runtime/runtime-git-client.ts b/src/renderer/src/runtime/runtime-git-client.ts index 972e2b7fdcb..019eaef5039 100644 --- a/src/renderer/src/runtime/runtime-git-client.ts +++ b/src/renderer/src/runtime/runtime-git-client.ts @@ -133,20 +133,30 @@ export function getRuntimeGitScope( export async function getRuntimeGitStatus( context: RuntimeGitContext, - options?: { includeIgnored?: boolean; bypassEffectiveUpstreamNegativeCache?: boolean } + options?: { + includeIgnored?: boolean + bypassEffectiveUpstreamNegativeCache?: boolean + reuseLineStats?: boolean + signal?: AbortSignal + } ): Promise { const target = getActiveRuntimeTarget(context.settings) const includeIgnoredArgs = options?.includeIgnored ? { includeIgnored: true } : {} const upstreamCacheBypassArgs = options?.bypassEffectiveUpstreamNegativeCache ? { bypassEffectiveUpstreamNegativeCache: true } : {} + const lineStatsReuseArgs = options?.reuseLineStats ? { reuseLineStats: true } : {} if (target.kind === 'local' || !context.worktreeId) { - return window.api.git.status({ - worktreePath: resolveLocalWorktreePath(context), - connectionId: context.connectionId, - ...includeIgnoredArgs, - ...upstreamCacheBypassArgs - }) + return callLocalGitStatus( + { + worktreePath: resolveLocalWorktreePath(context), + connectionId: context.connectionId, + ...includeIgnoredArgs, + ...upstreamCacheBypassArgs, + ...lineStatsReuseArgs + }, + options?.signal + ) } return callRuntimeRpc( target, @@ -154,12 +164,58 @@ export async function getRuntimeGitStatus( { worktree: toRuntimeWorktreeSelector(context.worktreeId), ...includeIgnoredArgs, - ...upstreamCacheBypassArgs + ...upstreamCacheBypassArgs, + ...lineStatsReuseArgs }, - { timeoutMs: 15_000 } + { + timeoutMs: 15_000, + // Why: an abort signal forces the dedicated per-request subscription + // socket (one WebSocket + E2EE handshake per request). The safety + // refresh is the idle steady state, so it stays on the pooled call + // transport: its stale result is rejected by the caller's liveness + // guard and the 15s timeout bounds it. Activity refreshes keep the + // abortable transport so pause/host switches cancel real remote work. + ...(options?.reuseLineStats ? {} : { signal: options?.signal }) + } ) } +let nextGitStatusRequestToken = 0 + +function createGitStatusAbortError(): Error { + const error = new Error('Git status request aborted') + error.name = 'AbortError' + return error +} + +async function callLocalGitStatus( + args: Parameters[0], + signal?: AbortSignal +): Promise { + if (!signal) { + return window.api.git.status(args) + } + if (signal.aborted) { + throw createGitStatusAbortError() + } + const requestToken = `git-status-${Date.now()}-${++nextGitStatusRequestToken}` + const cancel = (): void => { + void window.api.git.cancelStatus({ requestToken }).catch(() => {}) + } + signal.addEventListener('abort', cancel, { once: true }) + try { + const status = await window.api.git.status({ ...args, requestToken }) + // Why: cancel is best-effort; a scan that finished after abort must still + // reject so callers never treat a cancelled request as a fresh result. + if (signal.aborted) { + throw createGitStatusAbortError() + } + return status + } finally { + signal.removeEventListener('abort', cancel) + } +} + export async function getRuntimeGitSubmoduleStatus( context: RuntimeGitContext, submodulePath: string, diff --git a/src/renderer/src/runtime/runtime-jira-client.ts b/src/renderer/src/runtime/runtime-jira-client.ts index 812e28b8233..a6c058ac9f7 100644 --- a/src/renderer/src/runtime/runtime-jira-client.ts +++ b/src/renderer/src/runtime/runtime-jira-client.ts @@ -1,5 +1,6 @@ import type { GlobalSettings, + JiraAuthType, JiraComment, JiraConnectionStatus, JiraCreateField, @@ -57,7 +58,7 @@ export async function jiraStatus(settings: RuntimeJiraSettings): Promise { const target = getJiraRuntimeTarget(settings) return target.kind === 'environment' diff --git a/src/renderer/src/runtime/runtime-rpc-client.test.ts b/src/renderer/src/runtime/runtime-rpc-client.test.ts index 0bf2ac4f8f0..c9481d2746c 100644 --- a/src/renderer/src/runtime/runtime-rpc-client.test.ts +++ b/src/renderer/src/runtime/runtime-rpc-client.test.ts @@ -20,15 +20,20 @@ import { const runtimeCall = vi.fn() const runtimeEnvironmentCall = vi.fn() +const runtimeEnvironmentSubscribe = vi.fn() beforeEach(() => { clearRuntimeCompatibilityCacheForTests() runtimeCall.mockReset() runtimeEnvironmentCall.mockReset() + runtimeEnvironmentSubscribe.mockReset() vi.stubGlobal('window', { api: { runtime: { call: runtimeCall }, - runtimeEnvironments: { call: runtimeEnvironmentCall } + runtimeEnvironments: { + call: runtimeEnvironmentCall, + subscribe: runtimeEnvironmentSubscribe + } } }) }) @@ -109,6 +114,49 @@ describe('runtime RPC client routing', () => { expect(runtimeCall).not.toHaveBeenCalled() }) + it('uses an abortable subscription for paired-runtime requests', async () => { + const controller = new AbortController() + const unsubscribe = vi.fn() + runtimeEnvironmentSubscribe.mockResolvedValue({ unsubscribe, sendBinary: vi.fn() }) + + const request = callRuntimeRpc( + { kind: 'environment', environmentId: 'env-1' }, + 'status.get', + undefined, + { signal: controller.signal } + ) + await vi.waitFor(() => expect(runtimeEnvironmentSubscribe).toHaveBeenCalled()) + controller.abort() + + await expect(request).rejects.toMatchObject({ name: 'AbortError' }) + await vi.waitFor(() => expect(unsubscribe).toHaveBeenCalledTimes(1)) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('rejects an abortable paired-runtime request at the response deadline', async () => { + vi.useFakeTimers() + try { + const controller = new AbortController() + const unsubscribe = vi.fn() + runtimeEnvironmentSubscribe.mockResolvedValue({ unsubscribe, sendBinary: vi.fn() }) + + const request = callRuntimeRpc( + { kind: 'environment', environmentId: 'env-1' }, + 'status.get', + undefined, + { signal: controller.signal, timeoutMs: 15_000 } + ) + await vi.waitFor(() => expect(runtimeEnvironmentSubscribe).toHaveBeenCalled()) + const rejection = expect(request).rejects.toThrow('timed out before status.get completed') + await vi.advanceTimersByTimeAsync(15_000) + + await rejection + await vi.waitFor(() => expect(unsubscribe).toHaveBeenCalledTimes(1)) + } finally { + vi.useRealTimers() + } + }) + it('preflights remote runtime compatibility before non-status calls', async () => { runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) => { const result = diff --git a/src/renderer/src/runtime/runtime-rpc-client.ts b/src/renderer/src/runtime/runtime-rpc-client.ts index e9b58f9d694..1f024c1a67f 100644 --- a/src/renderer/src/runtime/runtime-rpc-client.ts +++ b/src/renderer/src/runtime/runtime-rpc-client.ts @@ -4,20 +4,22 @@ import type { RuntimeStatus } from '../../../shared/runtime-types' import type { RuntimeCapability } from '../../../shared/protocol-version' import { withBrowserPaneUiRuntimeRpcSource } from '../../../shared/runtime-rpc-feature-interaction-source' import { assertRuntimeStatusCompatible } from './runtime-protocol-compat' +import { + callAbortableRuntimeEnvironment, + createRuntimeRpcAbortError +} from './abortable-runtime-environment-call' export type RuntimeClientTarget = { kind: 'local' } | { kind: 'environment'; environmentId: string } const RUNTIME_COMPATIBILITY_CACHE_MAX = 32 const RECENT_RUNTIME_COMPATIBILITY_FAILURE_TTL_MS = 60_000 -// Why: a saved environment can restart into a different Orca version without -// changing ids; capability verdicts must eventually follow that version change. +// Why: capability verdicts must eventually follow a saved environment's version changes. const RUNTIME_CAPABILITY_STATUS_TTL_MS = 60_000 type RuntimeCompatibilityCacheEntry = { check: Promise failedAt: number | null - // True only once status.get settled and proved compatible. Stays false while - // the probe is in flight, so a recovery clear can drop a doomed pending probe. + // False while probing so recovery can drop a doomed pending compatibility check. provenCompatible: boolean status: RuntimeStatus | null statusCheckedAt: number | null @@ -73,34 +75,38 @@ export async function callRuntimeRpc( timeoutMs?: number suppressFeatureInteraction?: boolean reuseRecentCompatibilityFailure?: boolean + signal?: AbortSignal } = {} ): Promise { if (target.kind === 'environment' && method !== 'status.get') { await ensureRuntimeEnvironmentCompatible(target.environmentId, options) } - const nextParams = addFeatureInteractionSource(params, options) + if (options.signal?.aborted) { + throw createRuntimeRpcAbortError() + } + const nextParams = options.suppressFeatureInteraction + ? withBrowserPaneUiRuntimeRpcSource(params) + : params const response = target.kind === 'local' ? await window.api.runtime.call({ method, params: nextParams }) - : await window.api.runtimeEnvironments.call({ - selector: target.environmentId, - method, - params: nextParams, - timeoutMs: options.timeoutMs - }) + : options.signal + ? await callAbortableRuntimeEnvironment( + target.environmentId, + method, + nextParams, + options.timeoutMs, + options.signal + ) + : await window.api.runtimeEnvironments.call({ + selector: target.environmentId, + method, + params: nextParams, + timeoutMs: options.timeoutMs + }) return unwrapRuntimeRpcResult(response as RuntimeRpcResponse) } -function addFeatureInteractionSource( - params: unknown, - options: { suppressFeatureInteraction?: boolean } -): unknown { - if (!options.suppressFeatureInteraction) { - return params - } - return withBrowserPaneUiRuntimeRpcSource(params) -} - async function ensureRuntimeEnvironmentCompatible( environmentId: string, options: { timeoutMs?: number; reuseRecentCompatibilityFailure?: boolean } = {} @@ -187,11 +193,8 @@ function rememberRuntimeEnvironmentCompatibility( } } -// Why: a live status.get answer proves any cached compatibility verdict that is -// not a settled success is stale. Drop settled failures AND still-pending probes -// (a probe queued on the dropped connection is doomed, and a reachability- -// triggered refresh must not coalesce onto it) so the refresh re-probes. Only -// proven-compatible successes stay cached. +// Why: a live status answer invalidates failures and pending probes from the +// dropped connection; only proven-compatible successes remain reusable. export function clearRecentRuntimeCompatibilityFailure(environmentId: string): void { const trimmed = environmentId.trim() if (!trimmed) { diff --git a/src/renderer/src/store/slices/github.test.ts b/src/renderer/src/store/slices/github.test.ts index 730a9105e54..6ddba03caec 100644 --- a/src/renderer/src/store/slices/github.test.ts +++ b/src/renderer/src/store/slices/github.test.ts @@ -6331,7 +6331,7 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { 24, 100, '', - '2026-05-21T00:00:00Z' + 1 ) expect(result.failedCount).toBe(0) @@ -6377,7 +6377,7 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { 24, 100, 'is:open', - '2026-05-22T00:00:00Z' + 1 ) expect(mockApi.gh.listWorkItems).not.toHaveBeenCalled() @@ -6388,7 +6388,7 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { repo: 'runtime-repo-id', limit: 24, query: 'is:open', - before: '2026-05-22T00:00:00Z' + page: 1 }, timeoutMs: 30_000 }) @@ -6413,9 +6413,13 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { const result = await store .getState() - .countWorkItemsAcrossRepos([{ repoId: 'caller-repo-id', path: '/server/repo' }], 'is:open') + .countWorkItemsAcrossRepos( + [{ repoId: 'caller-repo-id', path: '/server/repo' }], + 'is:open', + 10 + ) - expect(result).toBe(12) + expect(result).toEqual({ totalCount: 12, totalPages: 2 }) expect(mockApi.gh.countWorkItems).not.toHaveBeenCalled() expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ selector: 'env-1', @@ -6434,9 +6438,9 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { const result = await store .getState() - .countWorkItemsAcrossRepos([{ repoId: 'repo-id', path: '/local/repo' }], '') + .countWorkItemsAcrossRepos([{ repoId: 'repo-id', path: '/local/repo' }], '', 10) - expect(result).toBe(7) + expect(result).toEqual({ totalCount: 7, totalPages: 1 }) expect(runtimeEnvironmentCall).not.toHaveBeenCalled() expect(mockApi.gh.countWorkItems).toHaveBeenCalledWith({ repoPath: '/local/repo', @@ -6445,6 +6449,22 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { }) }) + it('derives page count from the repo with the most results', async () => { + const store = createTestStore() + mockApi.gh.countWorkItems.mockResolvedValueOnce(100).mockResolvedValueOnce(1) + + const result = await store.getState().countWorkItemsAcrossRepos( + [ + { repoId: 'large-repo', path: '/local/large' }, + { repoId: 'small-repo', path: '/local/small' } + ], + 'is:issue', + 36 + ) + + expect(result).toEqual({ totalCount: 101, totalPages: 3 }) + }) + it('rejects oversized work-item queries before cache keys or provider calls', async () => { const store = createTestStore() const secret = 'github-work-items-secret' @@ -6471,14 +6491,14 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => { 24, 24, oversizedQuery, - 'cursor' + 1 ) ).resolves.toEqual({ items: [], failedCount: 0 }) await expect( store .getState() - .countWorkItemsAcrossRepos([{ repoId: 'repo-id', path: '/local/repo' }], oversizedQuery) - ).resolves.toBe(0) + .countWorkItemsAcrossRepos([{ repoId: 'repo-id', path: '/local/repo' }], oversizedQuery, 24) + ).resolves.toEqual({ totalCount: 0, totalPages: 0 }) store.getState().prefetchWorkItems('repo-id', '/local/repo', 24, oversizedQuery) expect(store.getState().getCachedWorkItems('repo-id', 24, oversizedQuery, '/local/repo')).toBe( diff --git a/src/renderer/src/store/slices/github.ts b/src/renderer/src/store/slices/github.ts index e3344ebee97..1505fb7b121 100644 --- a/src/renderer/src/store/slices/github.ts +++ b/src/renderer/src/store/slices/github.ts @@ -34,7 +34,7 @@ import type { } from '../../../../shared/github-project-types' import { isGitHubWorkItemsSshRemoteRequiredError, - sortWorkItemsByUpdatedAt, + sortWorkItemsByNumber, PER_REPO_FETCH_LIMIT } from '../../../../shared/work-items' import { deriveCheckStatusFromChecks, syncPRChecksStatus } from './github-checks' @@ -207,7 +207,7 @@ type GitHubWorkItemRequestTarget = type GitHubWorkItemsListArgs = { limit: number query?: string - before?: string + page?: number noCache?: true } @@ -2109,10 +2109,7 @@ export type GitHubSlice = { query: string, options?: FetchOptions ) => Promise<{ items: GitHubWorkItem[]; failedCount: number }> - /** - * Fetch the next page of work items using a date cursor. Does not cache — - * pagination pages are ephemeral and managed by TaskPage state. - */ + /** Fetch one numbered provider page. Pagination pages remain renderer-local. */ fetchWorkItemsNextPage: ( repos: { repoId: string @@ -2123,12 +2120,9 @@ export type GitHubSlice = { perRepoLimit: number, displayLimit: number, query: string, - before: string + page: number ) => Promise<{ items: GitHubWorkItem[]; failedCount: number }> - /** - * Count total work items across repos using GitHub's search API. - * Returns the sum of per-repo counts for the given query. - */ + /** Count items and derive pages from the largest per-repo result set. */ countWorkItemsAcrossRepos: ( repos: { repoId: string @@ -2136,8 +2130,9 @@ export type GitHubSlice = { executionHostId?: string | null sourceContext?: TaskSourceContext | null }[], - query: string - ) => Promise + query: string, + perRepoLimit: number + ) => Promise<{ totalCount: number; totalPages: number }> /** * Fire-and-forget prefetch used by UI entry points (hover/focus of the * "new workspace" buttons) to warm the cache before the page mounts. @@ -2962,11 +2957,11 @@ export const createGitHubSlice: StateCreator = (s } }) ) - const merged = sortWorkItemsByUpdatedAt(perProjectResults.flat()).slice(0, displayLimit) + const merged = sortWorkItemsByNumber(perProjectResults.flat()).slice(0, displayLimit) return { items: merged, failedCount } }, - fetchWorkItemsNextPage: async (repos, perRepoLimit, displayLimit, query, before) => { + fetchWorkItemsNextPage: async (repos, perRepoLimit, displayLimit, query, page) => { if (isGitHubWorkItemsQueryTooLarge(query)) { return { items: [], failedCount: 0 } } @@ -2992,7 +2987,7 @@ export const createGitHubSlice: StateCreator = (s const envelope = await listGitHubWorkItemsForRepo(requestContext, { limit: perRepoLimit, query: query || undefined, - before + page }) // Why: page-N partial failures don't participate in the cache's per-repo // error banner (which is keyed on the initial-fetch cache entry). Log the @@ -3020,14 +3015,15 @@ export const createGitHubSlice: StateCreator = (s } }) ) - const merged = sortWorkItemsByUpdatedAt(perProjectResults.flat()).slice(0, displayLimit) + const merged = sortWorkItemsByNumber(perProjectResults.flat()).slice(0, displayLimit) return { items: merged, failedCount } }, - countWorkItemsAcrossRepos: async (repos, query) => { + countWorkItemsAcrossRepos: async (repos, query, perRepoLimit) => { if (isGitHubWorkItemsQueryTooLarge(query)) { - return 0 + return { totalCount: 0, totalPages: 0 } } + const normalizedLimit = Math.max(1, Math.floor(perRepoLimit)) const counts = await Promise.all( repos.map(async (r) => { // Why: same stampede cap as the item-fetch paths — without a slot, @@ -3057,7 +3053,15 @@ export const createGitHubSlice: StateCreator = (s } }) ) - return counts.reduce((sum, c) => sum + c, 0) + return { + totalCount: counts.reduce((sum, count) => sum + count, 0), + // Why: each repo advances independently by the same numbered page. A sum + // divided by page width undercounts when one repo owns most results. + totalPages: counts.reduce( + (maxPages, count) => Math.max(maxPages, Math.ceil(count / normalizedLimit)), + 0 + ) + } }, prefetchWorkItems: (repoId, repoPath, limit = PER_REPO_FETCH_LIMIT, query = '', options) => { diff --git a/src/renderer/src/store/slices/jira.ts b/src/renderer/src/store/slices/jira.ts index fd084a3d985..4f6957366f2 100644 --- a/src/renderer/src/store/slices/jira.ts +++ b/src/renderer/src/store/slices/jira.ts @@ -4,6 +4,7 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../types' import type { + JiraAuthType, JiraConnectionStatus, JiraIssue, JiraIssueFilter, @@ -163,6 +164,7 @@ export type JiraSlice = { siteUrl: string email: string apiToken: string + authType?: JiraAuthType }) => Promise<{ ok: true; viewer: JiraViewer } | { ok: false; error: string }> testJiraConnection: ( siteId?: string | null diff --git a/src/renderer/src/store/slices/repos-host-identity-routing.test.ts b/src/renderer/src/store/slices/repos-host-identity-routing.test.ts index 449290d81f4..23325aa73e9 100644 --- a/src/renderer/src/store/slices/repos-host-identity-routing.test.ts +++ b/src/renderer/src/store/slices/repos-host-identity-routing.test.ts @@ -496,6 +496,42 @@ describe('repo slice host identity routing', () => { }) }) + it('persists a moved paired-host project block without reversing its host occurrences', async () => { + reposReorderForHost.mockResolvedValue({ status: 'applied' }) + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-paired-project-reorder', + ok: true, + result: { status: 'applied' }, + _meta: { runtimeId: 'runtime-remote' } + }) + const bravo = { ...localDuplicate, id: 'bravo' } + const charlie = { ...remoteDuplicate, id: 'charlie' } + const store = createTestStore() + store.setState({ repos: [bravo, localDuplicate, charlie, remoteDuplicate] }) + + await store.getState().reorderRepos(['same-repo', 'same-repo', 'bravo', 'charlie']) + + expect(store.getState().repos).toEqual([localDuplicate, remoteDuplicate, bravo, charlie]) + expect(uiSet).toHaveBeenCalledWith({ + manualRepoOrder: [ + { hostId: 'local', repoId: 'same-repo' }, + { hostId: 'runtime:env-1', repoId: 'same-repo' }, + { hostId: 'local', repoId: 'bravo' }, + { hostId: 'runtime:env-1', repoId: 'charlie' } + ] + }) + expect(reposReorderForHost).toHaveBeenCalledWith({ + hostId: 'local', + orderedIds: ['same-repo', 'bravo'] + }) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'repo.reorder', + params: { orderedIds: ['same-repo', 'charlie'] }, + timeoutMs: 15_000 + }) + }) + it('persists a complete cross-host overlay alongside host-local permutations', async () => { reposReorderForHost.mockResolvedValue({ status: 'applied' }) runtimeEnvironmentCall.mockResolvedValue({ diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 0693b2f8972..4be3cc90b3c 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -721,6 +721,7 @@ export type UISlice = { title: string url: string linearIdentifier?: string + linearBranchName?: string } | null /** Why: starting from a task must preserve where provider data came from * separately from the host selected to run the workspace. */ diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index e43a5700cca..3b148562442 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -6686,12 +6686,13 @@ describe('setWorktreesPinnedAndReveal', () => { resetRemoteRuntimeMocks() }) - it('pins a worktree and reveals it so the viewport follows it into the Pinned section', () => { + it('pins the focused worktree and reveals it so the viewport follows it into the Pinned section', () => { const store = createTestStore() const wt = makeWorktree({ id: 'repo1::/a', repoId: 'repo1', path: '/a', isPinned: false }) const reveal = vi.fn() store.setState({ worktreesByRepo: { repo1: [wt] }, + activeWorktreeId: wt.id, revealWorktreeInSidebar: reveal } as Partial) @@ -6701,12 +6702,13 @@ describe('setWorktreesPinnedAndReveal', () => { expect(reveal).toHaveBeenCalledWith(wt.id, { behavior: 'smooth', highlight: true }) }) - it('reveals on unpin so the viewport follows the row back to its status group', () => { + it('reveals on unpin of the focused worktree so the viewport follows it back to its status group', () => { const store = createTestStore() const wt = makeWorktree({ id: 'repo1::/a', repoId: 'repo1', path: '/a', isPinned: true }) const reveal = vi.fn() store.setState({ worktreesByRepo: { repo1: [wt] }, + activeWorktreeId: wt.id, revealWorktreeInSidebar: reveal } as Partial) @@ -6716,6 +6718,41 @@ describe('setWorktreesPinnedAndReveal', () => { expect(reveal).toHaveBeenCalledWith(wt.id, { behavior: 'smooth', highlight: true }) }) + it('does not scroll when unpinning an unfocused worktree, but still unpins it', () => { + const store = createTestStore() + const focused = makeWorktree({ id: 'repo1::/a', repoId: 'repo1', path: '/a', isPinned: false }) + const pinned = makeWorktree({ id: 'repo1::/b', repoId: 'repo1', path: '/b', isPinned: true }) + const reveal = vi.fn() + store.setState({ + worktreesByRepo: { repo1: [focused, pinned] }, + activeWorktreeId: focused.id, + revealWorktreeInSidebar: reveal + } as Partial) + + store.getState().setWorktreesPinnedAndReveal([pinned.id], false) + + // The row unpins, but the viewport stays put because it isn't focused. + expect(store.getState().worktreesByRepo.repo1[1].isPinned).toBe(false) + expect(reveal).not.toHaveBeenCalled() + }) + + it('does not scroll when pinning an unfocused worktree, but still pins it', () => { + const store = createTestStore() + const focused = makeWorktree({ id: 'repo1::/a', repoId: 'repo1', path: '/a', isPinned: false }) + const other = makeWorktree({ id: 'repo1::/b', repoId: 'repo1', path: '/b', isPinned: false }) + const reveal = vi.fn() + store.setState({ + worktreesByRepo: { repo1: [focused, other] }, + activeWorktreeId: focused.id, + revealWorktreeInSidebar: reveal + } as Partial) + + store.getState().setWorktreesPinnedAndReveal([other.id], true) + + expect(store.getState().worktreesByRepo.repo1[1].isPinned).toBe(true) + expect(reveal).not.toHaveBeenCalled() + }) + it('skips a no-op toggle without requesting a reveal', () => { const store = createTestStore() const wt = makeWorktree({ id: 'repo1::/a', repoId: 'repo1', path: '/a', isPinned: true }) @@ -6759,7 +6796,7 @@ describe('setWorktreesPinnedAndReveal', () => { expect(reveal).not.toHaveBeenCalled() }) - it('reveals only the first newly-pinned worktree when pinning several at once', () => { + it('pins several at once and reveals the focused row even when it is not first', () => { const store = createTestStore() const alreadyPinned = makeWorktree({ id: 'repo1::/a', @@ -6768,23 +6805,49 @@ describe('setWorktreesPinnedAndReveal', () => { isPinned: true }) const first = makeWorktree({ id: 'repo1::/b', repoId: 'repo1', path: '/b', isPinned: false }) - const second = makeWorktree({ id: 'repo1::/c', repoId: 'repo1', path: '/c', isPinned: false }) + const focused = makeWorktree({ id: 'repo1::/c', repoId: 'repo1', path: '/c', isPinned: false }) const reveal = vi.fn() store.setState({ - worktreesByRepo: { repo1: [alreadyPinned, first, second] }, + worktreesByRepo: { repo1: [alreadyPinned, first, focused] }, + activeWorktreeId: focused.id, revealWorktreeInSidebar: reveal } as Partial) - store.getState().setWorktreesPinnedAndReveal([alreadyPinned.id, first.id, second.id], true) + store.getState().setWorktreesPinnedAndReveal([alreadyPinned.id, first.id, focused.id], true) + // Only the focused row is revealed, not the first-changed one. expect(reveal).toHaveBeenCalledTimes(1) - expect(reveal).toHaveBeenCalledWith(first.id, { behavior: 'smooth', highlight: true }) + expect(reveal).toHaveBeenCalledWith(focused.id, { behavior: 'smooth', highlight: true }) // Every targeted row is pinned, not just the revealed one, and the // already-pinned row is left untouched. expect(store.getState().worktreesByRepo.repo1[0].isPinned).toBe(true) expect(store.getState().worktreesByRepo.repo1[1].isPinned).toBe(true) expect(store.getState().worktreesByRepo.repo1[2].isPinned).toBe(true) }) + + it('pins several at once without scrolling when none of them are focused', () => { + const store = createTestStore() + const first = makeWorktree({ id: 'repo1::/b', repoId: 'repo1', path: '/b', isPinned: false }) + const second = makeWorktree({ id: 'repo1::/c', repoId: 'repo1', path: '/c', isPinned: false }) + const elsewhere = makeWorktree({ + id: 'repo1::/z', + repoId: 'repo1', + path: '/z', + isPinned: false + }) + const reveal = vi.fn() + store.setState({ + worktreesByRepo: { repo1: [first, second, elsewhere] }, + activeWorktreeId: elsewhere.id, + revealWorktreeInSidebar: reveal + } as Partial) + + store.getState().setWorktreesPinnedAndReveal([first.id, second.id], true) + + expect(reveal).not.toHaveBeenCalled() + expect(store.getState().worktreesByRepo.repo1[0].isPinned).toBe(true) + expect(store.getState().worktreesByRepo.repo1[1].isPinned).toBe(true) + }) }) describe('migrateWorktreeIdentity', () => { diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index aca7efcd33f..5541ad0ef92 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -73,6 +73,7 @@ import { import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { folderWorkspaceKey, + getActiveSidebarWorkspaceId, isWorkspaceKey, parseWorkspaceKey, worktreeWorkspaceKey @@ -4128,33 +4129,44 @@ export const createWorktreeSlice: StateCreator }, setWorktreesPinnedAndReveal: (worktreeIds, isPinned) => { + // Only follow a toggled row with the viewport when it's the focused + // worktree; pinning/unpinning an unfocused card shouldn't yank the user's + // scroll to a row they aren't looking at. + const activeSidebarWorktreeId = getActiveSidebarWorkspaceId( + get().activeWorkspaceKey, + get().activeWorktreeId + ) // Skip worktrees already in the target state so a no-op toggle doesn't // scroll the viewport away from where the user is. const updates = new Map>() + let didChange = false let revealWorktreeId: string | null = null for (const worktreeId of worktreeIds) { const current = get().getKnownWorktreeById(worktreeId) if (!current || current.isPinned === isPinned) { continue } + didChange = true const workspaceScope = parseWorkspaceKey(worktreeId) if (workspaceScope?.type === 'folder') { void get().updateWorktreeMeta(worktreeId, { isPinned }) } else { updates.set(worktreeId, { isPinned }) } - if (revealWorktreeId === null) { + if (revealWorktreeId === null && worktreeId === activeSidebarWorktreeId) { revealWorktreeId = worktreeId } } - if (revealWorktreeId === null) { + if (!didChange) { return } // updateWorktreesMeta applies its store update synchronously (only the // persistence is async), so the reveal below resolves against a render // where the shortcut row already exists. void get().updateWorktreesMeta(updates) - get().revealWorktreeInSidebar(revealWorktreeId, { behavior: 'smooth', highlight: true }) + if (revealWorktreeId !== null) { + get().revealWorktreeInSidebar(revealWorktreeId, { behavior: 'smooth', highlight: true }) + } }, markWorktreeUnread: (worktreeId) => { diff --git a/src/renderer/src/web/web-preload-api.test.ts b/src/renderer/src/web/web-preload-api.test.ts index 749eab542f0..bb77e236218 100644 --- a/src/renderer/src/web/web-preload-api.test.ts +++ b/src/renderer/src/web/web-preload-api.test.ts @@ -2521,13 +2521,13 @@ describe('web GitHub preload API', () => { }, { key: 'listWorkItems', - args: { repoPath, limit: 20, query: 'is:pr', before: 'cursor', noCache: true }, + args: { repoPath, limit: 20, query: 'is:pr', page: 2, noCache: true }, expectedMethod: 'github.listWorkItems', expectedParams: withRepo({ repoPath, limit: 20, query: 'is:pr', - before: 'cursor', + page: 2, noCache: true }) }, diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index ac19ddce7c8..d6f6d6e5830 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -35,6 +35,7 @@ import type { WorkspaceSessionState } from '../../../shared/types' import type { SkillDiscoveryResult } from '../../../shared/skills' +import type { SkillFreshnessInventory } from '../../../shared/skill-freshness' import type { SshConnectionState, SshTarget } from '../../../shared/ssh-types' import { getDefaultOnboardingState, @@ -66,6 +67,7 @@ import { type ExecutionHostId } from '../../../shared/execution-host' import { toRuntimeWorktreeSelector } from '../runtime/runtime-worktree-selector' +import { callAbortableRuntimeEnvironment } from '../runtime/abortable-runtime-environment-call' import { normalizeDisabledTuiAgents } from '../../../shared/tui-agent-selection' import { normalizeTuiAgentArgsRecord, @@ -649,6 +651,8 @@ function createWebPreloadApi(): Partial { }) ) }, + // localStorage writes synchronously, so there is no deferred web flush. + flush: async () => {}, readTerminalScrollback: () => null, setSync: (session, hostId) => { writeJson(sessionStorageKeyForHost(hostId), sanitizeWebRuntimeWorkspaceSession(session)) @@ -1087,41 +1091,114 @@ function createNativeChatApi(): NativeChatApi { limit, transcriptPath }), - subscribe: (args, onAppended) => { + subscribe: (args, onFrame) => { // No paired runtime yet: nothing to subscribe to, and // requireActiveEnvironment() would throw. Return a no-op teardown so the // chat view mounts cleanly until a runtime is paired (only the not-paired // case is swallowed — real subscribe errors still surface via .catch). const environment = requireActiveEnvironmentOrNull() if (!environment) { + onFrame({ + type: 'snapshot', + messages: [], + hasMore: false, + error: translate( + 'components.native-chat.state.pairHost', + 'Pair a host to view agent chat history.' + ) + }) return () => {} } let handle: { unsubscribe: () => void } | null = null let cancelled = false + let receivedInitial = false void getClientForEnvironment(environment) .subscribe( 'nativeChat.subscribe', - { agent: args.agent, sessionId: args.sessionId, transcriptPath: args.transcriptPath }, + { + agent: args.agent, + sessionId: args.sessionId, + subscriptionId: args.subscriptionId, + transcriptPath: args.transcriptPath, + limit: args.limit + }, { onResponse: (response) => { - if (cancelled || !response.ok) { + if (cancelled) { + return + } + if (!response.ok) { + if (!receivedInitial) { + receivedInitial = true + onFrame({ + type: 'snapshot', + messages: [], + hasMore: false, + error: response.error.message + }) + } return } const result = response.result as { type?: string messages?: NativeChatAppendedMessages + hasMore?: boolean + error?: string } - if (result?.type === 'appended' && Array.isArray(result.messages)) { - onAppended(result.messages) + if ( + (result?.type === 'appended' || + result?.type === 'snapshot' || + result?.type === 'replacement') && + Array.isArray(result.messages) + ) { + if (!receivedInitial) { + receivedInitial = true + onFrame({ + type: 'snapshot', + messages: result.messages, + hasMore: result.hasMore ?? result.messages.length >= (args.limit ?? 300), + ...(result.error ? { error: result.error } : {}) + }) + } else if (result.type === 'snapshot') { + onFrame({ + type: 'snapshot', + messages: result.messages, + hasMore: result.hasMore ?? false, + ...(result.error ? { error: result.error } : {}) + }) + } else { + onFrame( + result.type === 'replacement' + ? { + type: 'replacement', + messages: result.messages, + hasMore: result.hasMore ?? false + } + : { type: 'appended', messages: result.messages } + ) + } + } else if (!receivedInitial) { + // Why: an ok response whose payload shape we don't recognize would + // otherwise never flip receivedInitial, stranding the view on + // 'loading'. Settle it with an empty snapshot (carrying any error + // the runtime sent) so the UI resolves. + receivedInitial = true + onFrame({ + type: 'snapshot', + messages: [], + hasMore: false, + ...(result?.error ? { error: result.error } : {}) + }) } } }, { // Why: send nativeChat.unsubscribe on teardown so the server reaps // the transcript fs-watcher on view-toggle, not just on socket close - // (the watcher-leak fix). Uses the same agent:sessionId cleanup token - // mobile sends, via the shared key-builder so it can't drift. - buildUnsubscribe: () => buildNativeChatUnsubscribe(args.agent, args.sessionId) + // (the watcher-leak fix). Echo the pane-specific token so two panes + // watching one session cannot tear down each other's watcher. + buildUnsubscribe: () => + buildNativeChatUnsubscribe(args.agent, args.sessionId, args.subscriptionId) } ) .then((h) => { @@ -1131,7 +1208,17 @@ function createNativeChatApi(): NativeChatApi { handle = h } }) - .catch(() => {}) + .catch((err: unknown) => { + if (!cancelled && !receivedInitial) { + receivedInitial = true + onFrame({ + type: 'snapshot', + messages: [], + hasMore: false, + error: err instanceof Error ? err.message : String(err) + }) + } + }) return () => { cancelled = true handle?.unsubscribe() @@ -1642,14 +1729,64 @@ function createFileApi(): NonNullable['fs']> { } } +// Why: track the in-flight abortable status request per token so cancelStatus +// can abort the matching subscription and close its remote request context. +const webGitStatusAbortControllers = new Map() + +async function callAbortableRuntimeStatus( + requestToken: string, + params: unknown +): Promise { + const environment = requireActiveEnvironment() + webGitStatusAbortControllers.get(requestToken)?.abort() + const controller = new AbortController() + webGitStatusAbortControllers.set(requestToken, controller) + try { + const response = await callAbortableRuntimeEnvironment( + environment.id, + 'git.status', + params, + undefined, + controller.signal + ) + updateEnvironmentFromResponse(environment, response) + if (!response.ok) { + throw new Error(response.error.message) + } + return response.result as TResult + } finally { + if (webGitStatusAbortControllers.get(requestToken) === controller) { + webGitStatusAbortControllers.delete(requestToken) + } + } +} + function createGitApi(): NonNullable['git']> { return { - status: async ({ worktreePath, includeIgnored }) => { + status: async ({ + worktreePath, + includeIgnored, + bypassEffectiveUpstreamNegativeCache, + reuseLineStats, + requestToken + }) => { const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - return callRuntimeResult('git.status', { + const params = { worktree: toRuntimeWorktreeSelector(worktree.id), - includeIgnored - }) + includeIgnored, + bypassEffectiveUpstreamNegativeCache, + reuseLineStats + } + // Why: without a token there's nothing to cancel, so stay on the pooled + // call transport. With one, route through the subscription bridge so + // cancelStatus can close the request context and abort the remote scan. + if (!requestToken) { + return callRuntimeResult('git.status', params) + } + return callAbortableRuntimeStatus(requestToken, params) + }, + cancelStatus: async ({ requestToken }) => { + webGitStatusAbortControllers.get(requestToken)?.abort() }, submoduleStatus: async ({ worktreePath, submodulePath, area }) => { const worktree = await resolveRuntimeWorktreeByPath(worktreePath) @@ -2325,6 +2462,9 @@ function createWebUiApi(): NonNullable['ui']> { }, isMaximized: () => Promise.resolve(false), onOpenSettings: () => noopUnsubscribe, + // Why: the web client has no native tray/menu bar, so there is never a + // queued open-settings intent to consume. + consumePendingOpenSettings: () => Promise.resolve(false), onOpenSetupGuide: () => noopUnsubscribe, onOpenFeatureTour: () => noopUnsubscribe, onOpenCrashReport: () => noopUnsubscribe, @@ -2373,6 +2513,7 @@ function createWebUiApi(): NonNullable['ui']> { onActivateWorktree: () => noopUnsubscribe, onCreateTerminal: () => noopUnsubscribe, onRequestTerminalCreate: () => noopUnsubscribe, + onRequestTerminalTabMount: () => noopUnsubscribe, replyTerminalCreate: () => {}, onSplitTerminal: () => noopUnsubscribe, onRenameTerminal: () => noopUnsubscribe, @@ -2385,6 +2526,8 @@ function createWebUiApi(): NonNullable['ui']> { onMobileMarkdownRequest: () => noopUnsubscribe, respondMobileMarkdownRequest: () => {}, onCloseTerminal: () => noopUnsubscribe, + onTerminalTabCloseRequest: () => noopUnsubscribe, + respondTerminalTabClose: () => {}, onSleepWorktree: () => noopUnsubscribe, // Why: paired web is a full renderer that wakes on activation; mobile wake is // desktop-host-scoped, so the web client never receives this signal. @@ -2601,7 +2744,16 @@ function createSkillsApi(): NonNullable['skills']> { skills: [], sources: [], scannedAt: Date.now() - })) + })), + // Why: browser clients have no local skill homes, and remote-host + // freshness stays disabled until its update rail has equivalent coverage. + freshnessInventory: (): Promise => + Promise.resolve({ + schemaVersion: 1, + installations: [], + eligibleUpdateNames: [], + scannedAt: Date.now() + }) } } @@ -2814,6 +2966,7 @@ function createPtyApi(): NonNullable['pty']> { declarePendingPaneSerializer: () => Promise.resolve(0), settlePaneSerializer: () => Promise.resolve(), clearPendingPaneSerializer: () => Promise.resolve(), + reportRendererSerializerReady: () => Promise.resolve(), management: { listSessions: () => Promise.resolve({ sessions: [], degraded: false }), killAll: () => Promise.resolve({ killedCount: 0, remainingCount: 0, killedSessionIds: [] }), diff --git a/src/renderer/src/web/web-runtime-client-file-watch-replay.test.ts b/src/renderer/src/web/web-runtime-client-file-watch-replay.test.ts index 3b554614bd5..ee522962669 100644 --- a/src/renderer/src/web/web-runtime-client-file-watch-replay.test.ts +++ b/src/renderer/src/web/web-runtime-client-file-watch-replay.test.ts @@ -3,6 +3,7 @@ // setup, so consumers must receive a conservative overflow once it is ready. import { describe, expect, it, vi, afterEach, beforeEach } from 'vitest' import { WebRuntimeClient } from './web-runtime-client' +import { encrypt } from '../../../shared/e2ee-crypto' const fakeSockets: FakeWebSocket[] = [] @@ -108,4 +109,161 @@ describe('WebRuntimeClient file-watch replay resync', () => { }) client.close() }) + + it('replays an active file watch on reconnect and keeps its logical handle', async () => { + const client = new WebRuntimeClient({ + v: 2, + endpoint: 'ws://127.0.0.1:6768', + deviceToken: 'token', + publicKeyB64: Buffer.alloc(32).toString('base64') + }) + const initialSocket = fakeSockets[0]! + initialSocket.readyState = FakeWebSocket.OPEN + const onResponse = vi.fn() + const onClose = vi.fn() + const internals = client as unknown as { + ws: FakeWebSocket | null + sharedKey: Uint8Array | null + state: string + subscriptions: Map< + string, + { + callbacks: { onResponse: typeof onResponse } + needsReplay: boolean + } + > + setState(next: string): void + handleSocketClosed(socket: FakeWebSocket): void + } + internals.sharedKey = new Uint8Array(32) + internals.state = 'connected' + + const handle = await client.subscribe( + 'files.watch', + { worktree: 'wt-1' }, + { onResponse, onClose } + ) + const firstId = Array.from(internals.subscriptions.keys())[0] + expect(initialSocket.send).toHaveBeenCalledTimes(1) + + initialSocket.onclose?.() + + expect(onClose).not.toHaveBeenCalled() + expect(internals.subscriptions.get(firstId!)?.needsReplay).toBe(true) + + const replacementSocket = new FakeWebSocket('ws://127.0.0.1:6768') + replacementSocket.readyState = FakeWebSocket.OPEN + internals.ws = replacementSocket + internals.sharedKey = new Uint8Array(32) + internals.setState('connected') + + const [replacementId, replacement] = Array.from(internals.subscriptions.entries())[0]! + expect(replacementId).not.toBe(firstId) + expect(replacementSocket.send).toHaveBeenCalledTimes(1) + replacement.callbacks.onResponse({ + id: replacementId, + ok: true, + streaming: true, + result: { type: 'changed', worktree: 'wt-1', events: [] }, + _meta: { runtimeId: 'runtime-web-test' } + }) + expect(onResponse).toHaveBeenCalledTimes(1) + + handle.unsubscribe() + internals.handleSocketClosed(replacementSocket) + expect(internals.subscriptions.size).toBe(0) + const secondReplacementSocket = new FakeWebSocket('ws://127.0.0.1:6768') + secondReplacementSocket.readyState = FakeWebSocket.OPEN + internals.ws = secondReplacementSocket + internals.sharedKey = new Uint8Array(32) + internals.setState('connected') + expect(secondReplacementSocket.send).not.toHaveBeenCalled() + client.close() + expect(internals.subscriptions.size).toBe(0) + }) + + it('does not replay a file watch stopped after its socket disconnects', async () => { + const client = new WebRuntimeClient({ + v: 2, + endpoint: 'ws://127.0.0.1:6768', + deviceToken: 'token', + publicKeyB64: Buffer.alloc(32).toString('base64') + }) + const initialSocket = fakeSockets[0]! + initialSocket.readyState = FakeWebSocket.OPEN + const internals = client as unknown as { + ws: FakeWebSocket | null + sharedKey: Uint8Array | null + state: string + subscriptions: Map + setState(next: string): void + } + internals.sharedKey = new Uint8Array(32) + internals.state = 'connected' + const handle = await client.subscribe( + 'files.watch', + { worktree: 'wt-1' }, + { onResponse: vi.fn() } + ) + + initialSocket.onclose?.() + expect(Array.from(internals.subscriptions.values())[0]?.needsReplay).toBe(true) + handle.unsubscribe() + expect(internals.subscriptions.size).toBe(0) + + const replacementSocket = new FakeWebSocket('ws://127.0.0.1:6768') + replacementSocket.readyState = FakeWebSocket.OPEN + internals.ws = replacementSocket + internals.sharedKey = new Uint8Array(32) + internals.setState('connected') + expect(replacementSocket.send).not.toHaveBeenCalled() + client.close() + }) + + it('evicts a failed file-watch setup before reconnect can replay it', async () => { + const client = new WebRuntimeClient({ + v: 2, + endpoint: 'ws://127.0.0.1:6768', + deviceToken: 'token', + publicKeyB64: Buffer.alloc(32).toString('base64') + }) + const initialSocket = fakeSockets[0]! + initialSocket.readyState = FakeWebSocket.OPEN + const sharedKey = new Uint8Array(32) + const onResponse = vi.fn() + const internals = client as unknown as { + ws: FakeWebSocket | null + sharedKey: Uint8Array | null + state: string + subscriptions: Map + handleSocketMessage(rawData: unknown): Promise + setState(next: string): void + } + internals.sharedKey = sharedKey + internals.state = 'connected' + await client.subscribe('files.watch', { worktree: 'wt-1' }, { onResponse }) + const subscriptionId = Array.from(internals.subscriptions.keys())[0]! + + await internals.handleSocketMessage( + encrypt( + JSON.stringify({ + id: subscriptionId, + ok: false, + error: { code: 'watch_failed', message: 'root unavailable' } + }), + sharedKey + ) + ) + + expect(onResponse).toHaveBeenCalledTimes(1) + expect(internals.subscriptions.size).toBe(0) + initialSocket.onclose?.() + const replacementSocket = new FakeWebSocket('ws://127.0.0.1:6768') + replacementSocket.readyState = FakeWebSocket.OPEN + internals.ws = replacementSocket + internals.sharedKey = sharedKey + internals.setState('connected') + expect(replacementSocket.send).not.toHaveBeenCalled() + client.close() + }) }) diff --git a/src/renderer/src/web/web-runtime-client.test.ts b/src/renderer/src/web/web-runtime-client.test.ts index c96bcbe9b68..4a15fcce2ef 100644 --- a/src/renderer/src/web/web-runtime-client.test.ts +++ b/src/renderer/src/web/web-runtime-client.test.ts @@ -205,161 +205,47 @@ describe('WebRuntimeClient', () => { } }) - it('replays an active file watch on reconnect and keeps its logical handle', async () => { + it('delivers plain unary responses to their subscription instead of dropping them', async () => { const client = new WebRuntimeClient({ v: 2, endpoint: 'ws://127.0.0.1:6768', deviceToken: 'token', publicKeyB64: Buffer.alloc(32).toString('base64') }) - const initialSocket = fakeSockets[0]! - initialSocket.readyState = FakeWebSocket.OPEN - const onResponse = vi.fn() - const onClose = vi.fn() - const internals = client as unknown as { - ws: FakeWebSocket | null - sharedKey: Uint8Array | null - state: string - subscriptions: Map< - string, - { - callbacks: { onResponse: typeof onResponse } - needsReplay: boolean - } - > - setState(next: string): void - handleSocketClosed(socket: FakeWebSocket): void + try { + const keyPair = generateKeyPair() + const sharedKey = deriveSharedKey(keyPair.secretKey, keyPair.publicKey) + const internals = client as unknown as { + state: string + sharedKey: Uint8Array | null + subscriptions: Map< + string, + { callbacks: { onResponse: (response: unknown) => void; onClose?: () => void } } + > + handleSocketMessage: (rawData: unknown, sourceWs?: unknown) => Promise + } + internals.state = 'connected' + internals.sharedKey = sharedKey + const onResponse = vi.fn() + internals.subscriptions.set('req-1', { callbacks: { onResponse } }) + + // A unary RPC (e.g. abortable git.status) answered over the subscription + // bridge: not streaming and neither an end nor a scrollback frame. + const response = { + id: 'req-1', + ok: true, + result: { entries: [], conflictOperation: 'unknown' }, + _meta: { runtimeId: 'runtime-1' } + } + await internals.handleSocketMessage(encrypt(JSON.stringify(response), sharedKey)) + + expect(onResponse).toHaveBeenCalledWith(response) + // The caller owns the unsubscribe; a plain reply must not close it. + expect(internals.subscriptions.has('req-1')).toBe(true) + internals.subscriptions.delete('req-1') + } finally { + client.close() } - internals.sharedKey = new Uint8Array(32) - internals.state = 'connected' - - const handle = await client.subscribe( - 'files.watch', - { worktree: 'wt-1' }, - { onResponse, onClose } - ) - const firstId = Array.from(internals.subscriptions.keys())[0] - expect(initialSocket.send).toHaveBeenCalledTimes(1) - - initialSocket.onclose?.() - - expect(onClose).not.toHaveBeenCalled() - expect(internals.subscriptions.get(firstId!)?.needsReplay).toBe(true) - - const replacementSocket = new FakeWebSocket('ws://127.0.0.1:6768') - replacementSocket.readyState = FakeWebSocket.OPEN - internals.ws = replacementSocket - internals.sharedKey = new Uint8Array(32) - internals.setState('connected') - - const [replacementId, replacement] = Array.from(internals.subscriptions.entries())[0]! - expect(replacementId).not.toBe(firstId) - expect(replacementSocket.send).toHaveBeenCalledTimes(1) - replacement.callbacks.onResponse({ - id: replacementId, - ok: true, - streaming: true, - result: { type: 'changed', worktree: 'wt-1', events: [] }, - _meta: { runtimeId: 'runtime-web-test' } - }) - expect(onResponse).toHaveBeenCalledTimes(1) - - handle.unsubscribe() - internals.handleSocketClosed(replacementSocket) - expect(internals.subscriptions.size).toBe(0) - const secondReplacementSocket = new FakeWebSocket('ws://127.0.0.1:6768') - secondReplacementSocket.readyState = FakeWebSocket.OPEN - internals.ws = secondReplacementSocket - internals.sharedKey = new Uint8Array(32) - internals.setState('connected') - expect(secondReplacementSocket.send).not.toHaveBeenCalled() - client.close() - expect(internals.subscriptions.size).toBe(0) - }) - - it('does not replay a file watch stopped after its socket disconnects', async () => { - const client = new WebRuntimeClient({ - v: 2, - endpoint: 'ws://127.0.0.1:6768', - deviceToken: 'token', - publicKeyB64: Buffer.alloc(32).toString('base64') - }) - const initialSocket = fakeSockets[0]! - initialSocket.readyState = FakeWebSocket.OPEN - const internals = client as unknown as { - ws: FakeWebSocket | null - sharedKey: Uint8Array | null - state: string - subscriptions: Map - setState(next: string): void - } - internals.sharedKey = new Uint8Array(32) - internals.state = 'connected' - const handle = await client.subscribe( - 'files.watch', - { worktree: 'wt-1' }, - { onResponse: vi.fn() } - ) - - initialSocket.onclose?.() - expect(Array.from(internals.subscriptions.values())[0]?.needsReplay).toBe(true) - handle.unsubscribe() - expect(internals.subscriptions.size).toBe(0) - - const replacementSocket = new FakeWebSocket('ws://127.0.0.1:6768') - replacementSocket.readyState = FakeWebSocket.OPEN - internals.ws = replacementSocket - internals.sharedKey = new Uint8Array(32) - internals.setState('connected') - expect(replacementSocket.send).not.toHaveBeenCalled() - client.close() - }) - - it('evicts a failed file-watch setup before reconnect can replay it', async () => { - const client = new WebRuntimeClient({ - v: 2, - endpoint: 'ws://127.0.0.1:6768', - deviceToken: 'token', - publicKeyB64: Buffer.alloc(32).toString('base64') - }) - const initialSocket = fakeSockets[0]! - initialSocket.readyState = FakeWebSocket.OPEN - const sharedKey = new Uint8Array(32) - const onResponse = vi.fn() - const internals = client as unknown as { - ws: FakeWebSocket | null - sharedKey: Uint8Array | null - state: string - subscriptions: Map - handleSocketMessage(rawData: unknown): Promise - setState(next: string): void - } - internals.sharedKey = sharedKey - internals.state = 'connected' - await client.subscribe('files.watch', { worktree: 'wt-1' }, { onResponse }) - const subscriptionId = Array.from(internals.subscriptions.keys())[0]! - - await internals.handleSocketMessage( - encrypt( - JSON.stringify({ - id: subscriptionId, - ok: false, - error: { code: 'watch_failed', message: 'root unavailable' } - }), - sharedKey - ) - ) - - expect(onResponse).toHaveBeenCalledTimes(1) - expect(internals.subscriptions.size).toBe(0) - initialSocket.onclose?.() - const replacementSocket = new FakeWebSocket('ws://127.0.0.1:6768') - replacementSocket.readyState = FakeWebSocket.OPEN - internals.ws = replacementSocket - internals.sharedKey = sharedKey - internals.setState('connected') - expect(replacementSocket.send).not.toHaveBeenCalled() - client.close() }) it('keeps file watches on the owning WebSocket instead of opening child clients', async () => { diff --git a/src/renderer/src/web/web-runtime-client.ts b/src/renderer/src/web/web-runtime-client.ts index 59d7e696fc0..0162dc24ff9 100644 --- a/src/renderer/src/web/web-runtime-client.ts +++ b/src/renderer/src/web/web-runtime-client.ts @@ -564,14 +564,15 @@ export class WebRuntimeClient { } const subscription = this.subscriptions.get(response.id) - if (subscription && isSubscriptionResponse(response)) { - // Why: setup failures are terminal. Evict before callbacks so a - // synchronous retry creates a fresh watch and reconnect cannot replay it. - if (response.ok === false) { + if (subscription) { + const subscriptionResponse = response as RuntimeRpcResponse + // Why: setup failures must be evicted before callbacks so reconnect cannot replay them. + if (subscriptionResponse.ok === false) { this.subscriptions.delete(response.id) } - subscription.callbacks.onResponse(response) - if (response.ok && isEndResult(response.result)) { + // Why: subscription-backed unary RPCs can return ordinary success frames. + subscription.callbacks.onResponse(subscriptionResponse) + if (subscriptionResponse.ok && isEndResult(subscriptionResponse.result)) { this.subscriptions.delete(response.id) subscription.callbacks.onClose?.() } @@ -862,21 +863,6 @@ export class WebRuntimeClient { } } -function isSubscriptionResponse( - response: RuntimeRpcResponse | Record -): response is RuntimeRpcResponse { - if (!('ok' in response)) { - return false - } - if (response.ok === false) { - return true - } - const success = response as RuntimeRpcResponse & { ok: true; streaming?: true } - return ( - success.streaming === true || isEndResult(success.result) || isScrollbackResult(success.result) - ) -} - function isRuntimeFailureResponse( response: RuntimeRpcResponse | Record ): response is RuntimeRpcResponse & { ok: false } { @@ -940,10 +926,6 @@ function isEndResult(value: unknown): value is { type: 'end' } { return !!value && typeof value === 'object' && (value as { type?: unknown }).type === 'end' } -function isScrollbackResult(value: unknown): value is { type: 'scrollback' } { - return !!value && typeof value === 'object' && (value as { type?: unknown }).type === 'scrollback' -} - async function websocketPayloadToUint8( value: unknown ): Promise | null> { diff --git a/src/shared/agent-type-label.ts b/src/shared/agent-type-label.ts new file mode 100644 index 00000000000..b638334f633 --- /dev/null +++ b/src/shared/agent-type-label.ts @@ -0,0 +1,34 @@ +import type { AgentType } from './agent-status-types' + +// Shared so the desktop renderer and the mobile app show the same agent name +// (e.g. native chat's empty state on both surfaces) from one source of truth. +const WELL_KNOWN_LABELS: Record = { + claude: 'Claude', + openclaude: 'OpenClaude', + codex: 'Codex', + gemini: 'Gemini', + antigravity: 'Antigravity', + amp: 'Amp', + copilot: 'GitHub Copilot', + opencode: 'OpenCode', + 'mimo-code': 'MiMo Code', + cursor: 'Cursor', + aider: 'Aider', + pi: 'Pi', + omp: 'OMP', + droid: 'Droid', + 'command-code': 'Command Code', + grok: 'Grok', + hermes: 'Hermes', + devin: 'Devin', + ante: 'Ante', + kimi: 'Kimi' +} + +export function formatAgentTypeLabel(agentType: AgentType | null | undefined): string { + if (!agentType || agentType === 'unknown') { + return 'Agent' + } + // Capitalize well-known names nicely; pass through custom names as-is + return WELL_KNOWN_LABELS[agentType] ?? agentType +} diff --git a/src/shared/ai-vault-resume-command.test.ts b/src/shared/ai-vault-resume-command.test.ts index a654dd8217e..c7ac1423e10 100644 --- a/src/shared/ai-vault-resume-command.test.ts +++ b/src/shared/ai-vault-resume-command.test.ts @@ -3,6 +3,17 @@ import { describe, expect, it } from 'vitest' import { buildAiVaultResumeCommand } from './ai-vault-types' describe('buildAiVaultResumeCommand', () => { + it('uses Antigravity conversation ids instead of Gemini resume flags', () => { + expect( + buildAiVaultResumeCommand({ + agent: 'antigravity', + sessionId: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee', + cwd: '/repo/app', + platform: 'darwin' + }) + ).toBe("cd '/repo/app' && agy --conversation 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'") + }) + it('builds a self-contained cmd wrapper when no live shell is known', () => { expect( buildAiVaultResumeCommand({ diff --git a/src/shared/ai-vault-types.ts b/src/shared/ai-vault-types.ts index fe07aca6963..dd780ef9ac4 100644 --- a/src/shared/ai-vault-types.ts +++ b/src/shared/ai-vault-types.ts @@ -15,6 +15,7 @@ export const AI_VAULT_AGENTS = [ 'omp', 'cursor', 'gemini', + 'antigravity', 'rovo', 'copilot', 'opencode', @@ -43,6 +44,7 @@ export const AI_VAULT_AGENT_LABELS = { omp: 'OMP', cursor: 'Cursor', gemini: 'Gemini', + antigravity: 'Antigravity', rovo: 'Rovo Dev', copilot: 'GitHub Copilot', opencode: 'OpenCode', @@ -323,6 +325,8 @@ function buildAgentResumeInvocation( // but the `--resume ` invocation form is identical to the others here. case 'omp': return `${baseCommand} --resume ${sessionArg}` + case 'antigravity': + return `${baseCommand} --conversation ${sessionArg}` } } diff --git a/src/shared/constants.test.ts b/src/shared/constants.test.ts index 343e0ffe173..8a167b08d41 100644 --- a/src/shared/constants.test.ts +++ b/src/shared/constants.test.ts @@ -48,6 +48,10 @@ describe('getDefaultSettings', () => { expect(getDefaultSettings('/tmp').uiLanguage).toBe('system') }) + it('defaults the menu bar icon on so the value round-trips across platforms', () => { + expect(getDefaultSettings('/tmp').showMenuBarIcon).toBe(true) + }) + it('confirms before closing pinned tabs by default', () => { expect(getDefaultSettings('/tmp').confirmClosePinnedTab).toBe(true) }) diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 22064cbe6ca..0573c89ec93 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -266,6 +266,9 @@ export function getDefaultSettings(homedir: string): GlobalSettings { terminalFocusFollowsMouse: false, windowBackgroundBlur: false, minimizeToTrayOnClose: false, + // Why: default-on everywhere so the value round-trips across platforms; + // only the darwin consumers act on it. + showMenuBarIcon: true, terminalClipboardOnSelect: false, // Why: OSC 52 is a classic data-exfiltration vector (any process piping // untrusted output into the terminal can rewrite the clipboard). Keep the diff --git a/src/shared/git-status-line-stats-cache.test.ts b/src/shared/git-status-line-stats-cache.test.ts new file mode 100644 index 00000000000..7c4034daad4 --- /dev/null +++ b/src/shared/git-status-line-stats-cache.test.ts @@ -0,0 +1,202 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { + applyCachedGitStatusLineStats, + beginGitStatusLineStatsCacheWrite, + clearGitStatusLineStatsCache, + clearGitStatusLineStatsCacheKey, + GIT_STATUS_LINE_STATS_CACHE_MAX_AGE_MS, + reuseOrRecomputeGitStatusLineStats, + storeGitStatusLineStats +} from './git-status-line-stats-cache' + +const cachedEntries = [ + { + path: 'src/a.ts', + status: 'modified', + area: 'unstaged', + added: 3, + removed: 2 + } +] + +describe('git status line stats cache', () => { + beforeEach(() => { + clearGitStatusLineStatsCache() + }) + + it('reuses counts only for an exact head and status-entry identity', () => { + storeGitStatusLineStats({ cacheKey: 'native\0/repo', head: 'head-1', entries: cachedEntries }) + const matchingEntries = [{ path: 'src/a.ts', status: 'modified', area: 'unstaged' }] + + expect( + applyCachedGitStatusLineStats({ + cacheKey: 'native\0/repo', + head: 'head-1', + entries: matchingEntries + }) + ).toBe(true) + expect(matchingEntries).toEqual(cachedEntries) + + expect( + applyCachedGitStatusLineStats({ + cacheKey: 'native\0/repo', + head: 'head-2', + entries: [{ path: 'src/a.ts', status: 'modified', area: 'unstaged' }] + }) + ).toBe(false) + }) + + it('invalidates a matching status identity at the bounded age', () => { + storeGitStatusLineStats({ + cacheKey: 'native\0/repo', + head: 'head-1', + entries: cachedEntries, + now: 1_000 + }) + + expect( + applyCachedGitStatusLineStats({ + cacheKey: 'native\0/repo', + head: 'head-1', + entries: [{ path: 'src/a.ts', status: 'modified', area: 'unstaged' }], + now: 1_000 + GIT_STATUS_LINE_STATS_CACHE_MAX_AGE_MS + }) + ).toBe(false) + }) + + it('isolates execution-host keys and supports mutation invalidation', () => { + storeGitStatusLineStats({ + cacheKey: 'wsl:ubuntu\0/repo', + head: 'head-1', + entries: cachedEntries + }) + + expect( + applyCachedGitStatusLineStats({ + cacheKey: 'wsl:debian\0/repo', + head: 'head-1', + entries: [{ path: 'src/a.ts', status: 'modified', area: 'unstaged' }] + }) + ).toBe(false) + + clearGitStatusLineStatsCacheKey('wsl:ubuntu\0/repo') + expect( + applyCachedGitStatusLineStats({ + cacheKey: 'wsl:ubuntu\0/repo', + head: 'head-1', + entries: [{ path: 'src/a.ts', status: 'modified', area: 'unstaged' }] + }) + ).toBe(false) + }) + + it('lets an earlier recompute store after a later reuse-only read began', () => { + const recomputeWrite = beginGitStatusLineStatsCacheWrite('native\0/repo') + // A hinted safety read begins later, reuses, and never stores; it must not + // starve the older recompute's store. + beginGitStatusLineStatsCacheWrite('native\0/repo') + storeGitStatusLineStats({ + cacheKey: 'native\0/repo', + head: 'head-1', + entries: cachedEntries, + writeToken: recomputeWrite + }) + + const entries: { path: string; status: string; area: string; added?: number }[] = [ + { path: 'src/a.ts', status: 'modified', area: 'unstaged' } + ] + expect( + applyCachedGitStatusLineStats({ cacheKey: 'native\0/repo', head: 'head-1', entries }) + ).toBe(true) + expect(entries[0]?.added).toBe(3) + }) + + it('keeps a prior good snapshot reusable when an overlapping recompute is aborted', async () => { + // A completed scan stored healthy counts. + storeGitStatusLineStats({ cacheKey: 'native\0/repo', head: 'head-1', entries: cachedEntries }) + + // A later scan begins and recomputes but is aborted before it can store. + const abortedWrite = beginGitStatusLineStatsCacheWrite('native\0/repo') + let aborted = false + await expect( + reuseOrRecomputeGitStatusLineStats({ + cacheKey: 'native\0/repo', + head: 'head-1', + entries: [{ path: 'src/a.ts', status: 'modified', area: 'unstaged' }], + writeToken: abortedWrite, + reuse: false, + isAborted: () => aborted, + recompute: async () => { + aborted = true + return true + } + }) + ).rejects.toMatchObject({ name: 'AbortError' }) + + // The aborted pass persisted nothing, so it must not evict the healthy + // snapshot — a following safety read still reuses the stored counts. + const entries: { path: string; status: string; area: string; added?: number }[] = [ + { path: 'src/a.ts', status: 'modified', area: 'unstaged' } + ] + expect( + applyCachedGitStatusLineStats({ cacheKey: 'native\0/repo', head: 'head-1', entries }) + ).toBe(true) + expect(entries[0]?.added).toBe(3) + }) + + it('rejects a reuse-only read that is already aborted', async () => { + storeGitStatusLineStats({ cacheKey: 'native\0/repo', head: 'head-1', entries: cachedEntries }) + const writeToken = beginGitStatusLineStatsCacheWrite('native\0/repo') + await expect( + reuseOrRecomputeGitStatusLineStats({ + cacheKey: 'native\0/repo', + head: 'head-1', + entries: [{ path: 'src/a.ts', status: 'modified', area: 'unstaged' }], + writeToken, + reuse: true, + isAborted: () => true, + recompute: async () => true + }) + ).rejects.toMatchObject({ name: 'AbortError' }) + }) + + it('rejects stale cache writes and writes started before invalidation', () => { + const olderWrite = beginGitStatusLineStatsCacheWrite('native\0/repo') + const latestWrite = beginGitStatusLineStatsCacheWrite('native\0/repo') + storeGitStatusLineStats({ + cacheKey: 'native\0/repo', + head: 'head-1', + entries: [{ ...cachedEntries[0], added: 8 }], + writeToken: latestWrite + }) + storeGitStatusLineStats({ + cacheKey: 'native\0/repo', + head: 'head-1', + entries: cachedEntries, + writeToken: olderWrite + }) + const entries: { path: string; status: string; area: string; added?: number }[] = [ + { path: 'src/a.ts', status: 'modified', area: 'unstaged' } + ] + + expect( + applyCachedGitStatusLineStats({ cacheKey: 'native\0/repo', head: 'head-1', entries }) + ).toBe(true) + expect(entries[0]?.added).toBe(8) + + const invalidatedWrite = beginGitStatusLineStatsCacheWrite('native\0/other') + clearGitStatusLineStatsCache() + storeGitStatusLineStats({ + cacheKey: 'native\0/other', + head: 'head-1', + entries: cachedEntries, + writeToken: invalidatedWrite + }) + expect( + applyCachedGitStatusLineStats({ + cacheKey: 'native\0/other', + head: 'head-1', + entries: [{ path: 'src/a.ts', status: 'modified', area: 'unstaged' }] + }) + ).toBe(false) + }) +}) diff --git a/src/shared/git-status-line-stats-cache.ts b/src/shared/git-status-line-stats-cache.ts new file mode 100644 index 00000000000..9a3ccf59356 --- /dev/null +++ b/src/shared/git-status-line-stats-cache.ts @@ -0,0 +1,256 @@ +type GitStatusLineStatsEntry = { + path?: unknown + status?: unknown + area?: unknown + oldPath?: unknown + conflictKind?: unknown + conflictStatus?: unknown + conflictStatusSource?: unknown + submodule?: { + commitChanged?: boolean + trackedChanges?: boolean + untrackedChanges?: boolean + } + added?: number + removed?: number +} + +type CachedLineStats = { + identity: string + storedAt: number + stats: { added?: number; removed?: number }[] +} + +export type GitStatusLineStatsWriteToken = { + cacheKey: string + globalGeneration: number + keyGeneration: number + beginSeq: number +} + +// Why: the TTL is the sole staleness backstop when file contents change while +// the porcelain identity stays "modified" (added/removed are excluded from the +// reuse identity), so a missed watcher signal pins counts for at most this long. +export const GIT_STATUS_LINE_STATS_CACHE_MAX_AGE_MS = 2 * 60_000 +const GIT_STATUS_LINE_STATS_CACHE_MAX_ENTRIES = 128 +const GIT_STATUS_LINE_STATS_WRITE_KEYS_MAX_ENTRIES = 1024 +const lineStatsByWorktree = new Map() +// Why: mutation invalidation must retire scans that began before it. A scan +// captures these generations at begin; a mismatch at store/clear time means an +// invalidation happened mid-scan and the derived stats may be pre-mutation. +let globalInvalidationGeneration = 0 +const keyInvalidationGenerationByWorktree = new Map() +// Why: overlapping recomputes must resolve latest-begun-wins without letting a +// reuse-only read (which never stores) starve an older recompute's store. +const lastStoredBeginSeqByWorktree = new Map() +let nextBeginSeq = 0 + +// Why: wall-clock steps (NTP, VM resume) must not extend or shrink the TTL. +const monotonicNowMs = (): number => performance.now() + +function bumpBoundedKeyMap(map: Map, cacheKey: string, value: number): void { + map.delete(cacheKey) + map.set(cacheKey, value) + while (map.size > GIT_STATUS_LINE_STATS_WRITE_KEYS_MAX_ENTRIES) { + const oldestKey = map.keys().next().value + if (oldestKey === undefined) { + return + } + map.delete(oldestKey) + } +} + +export function beginGitStatusLineStatsCacheWrite(cacheKey: string): GitStatusLineStatsWriteToken { + return { + cacheKey, + globalGeneration: globalInvalidationGeneration, + keyGeneration: keyInvalidationGenerationByWorktree.get(cacheKey) ?? 0, + beginSeq: ++nextBeginSeq + } +} + +function isWriteTokenCurrent(token: GitStatusLineStatsWriteToken): boolean { + return ( + token.globalGeneration === globalInvalidationGeneration && + token.keyGeneration === (keyInvalidationGenerationByWorktree.get(token.cacheKey) ?? 0) && + token.beginSeq >= (lastStoredBeginSeqByWorktree.get(token.cacheKey) ?? 0) + ) +} + +function createInputIdentity(head: string | undefined, entries: GitStatusLineStatsEntry[]): string { + return JSON.stringify([ + head ?? null, + entries.map((entry) => [ + entry.path, + entry.status, + entry.area, + entry.oldPath ?? null, + entry.conflictKind ?? null, + entry.conflictStatus ?? null, + entry.conflictStatusSource ?? null, + entry.submodule?.commitChanged ?? null, + entry.submodule?.trackedChanges ?? null, + entry.submodule?.untrackedChanges ?? null + ]) + ]) +} + +function trimLineStatsCache(now: number): void { + for (const [key, cached] of lineStatsByWorktree) { + if (now - cached.storedAt >= GIT_STATUS_LINE_STATS_CACHE_MAX_AGE_MS) { + lineStatsByWorktree.delete(key) + } + } + while (lineStatsByWorktree.size > GIT_STATUS_LINE_STATS_CACHE_MAX_ENTRIES) { + const oldestKey = lineStatsByWorktree.keys().next().value + if (oldestKey === undefined) { + return + } + lineStatsByWorktree.delete(oldestKey) + } +} + +export function applyCachedGitStatusLineStats(input: { + cacheKey: string + head?: string + entries: GitStatusLineStatsEntry[] + now?: number +}): boolean { + const now = input.now ?? monotonicNowMs() + const cached = lineStatsByWorktree.get(input.cacheKey) + if (!cached) { + return false + } + if ( + now - cached.storedAt >= GIT_STATUS_LINE_STATS_CACHE_MAX_AGE_MS || + cached.identity !== createInputIdentity(input.head, input.entries) + ) { + lineStatsByWorktree.delete(input.cacheKey) + return false + } + + lineStatsByWorktree.delete(input.cacheKey) + lineStatsByWorktree.set(input.cacheKey, cached) + input.entries.forEach((entry, index) => { + const stats = cached.stats[index] + if (stats?.added !== undefined) { + entry.added = stats.added + } + if (stats?.removed !== undefined) { + entry.removed = stats.removed + } + }) + return true +} + +export function storeGitStatusLineStats(input: { + cacheKey: string + head?: string + entries: GitStatusLineStatsEntry[] + now?: number + writeToken?: GitStatusLineStatsWriteToken +}): void { + const writeToken = input.writeToken ?? beginGitStatusLineStatsCacheWrite(input.cacheKey) + if (!isWriteTokenCurrent(writeToken)) { + return + } + bumpBoundedKeyMap(lastStoredBeginSeqByWorktree, input.cacheKey, writeToken.beginSeq) + const now = input.now ?? monotonicNowMs() + lineStatsByWorktree.delete(input.cacheKey) + lineStatsByWorktree.set(input.cacheKey, { + identity: createInputIdentity(input.head, input.entries), + storedAt: now, + stats: input.entries.map((entry) => ({ + ...(entry.added === undefined ? {} : { added: entry.added }), + ...(entry.removed === undefined ? {} : { removed: entry.removed }) + })) + }) + trimLineStatsCache(now) +} + +/** + * Shared post-status line-stat step for every host that executes Git. Reuses + * the cached snapshot only for hinted safety reads; otherwise recomputes and + * stores. `recompute` returns false when the counts are incomplete (e.g. a + * transient numstat failure) so a failed pass never replaces or pins a + * snapshot, and the previous good counts stay reusable. + */ +function createGitStatusLineStatsAbortError(): Error { + const error = new Error('The operation was aborted.') + error.name = 'AbortError' + return error +} + +export async function reuseOrRecomputeGitStatusLineStats(input: { + cacheKey: string + head?: string + entries: GitStatusLineStatsEntry[] + writeToken: GitStatusLineStatsWriteToken + reuse: boolean + isAborted: () => boolean + recompute: () => Promise +}): Promise { + if (input.isAborted()) { + // Why: reject rather than resolve — a cancelled scan must not look like a + // completed status result (including a cache-hit reuse path). + throw createGitStatusLineStatsAbortError() + } + if ( + input.reuse && + applyCachedGitStatusLineStats({ + cacheKey: input.cacheKey, + head: input.head, + entries: input.entries + }) + ) { + if (input.isAborted()) { + throw createGitStatusLineStatsAbortError() + } + return + } + const complete = await input.recompute() + if (input.isAborted()) { + // Why: an aborted pass never reached storeGitStatusLineStats, so there is + // nothing partial to undo; clearing here would instead evict a concurrent + // scan's healthy snapshot and force a redundant numstat on the next read. + // Reject so the caller cannot treat this pass as a successful status. + throw createGitStatusLineStatsAbortError() + } + if (!complete) { + return + } + storeGitStatusLineStats({ + cacheKey: input.cacheKey, + head: input.head, + entries: input.entries, + writeToken: input.writeToken + }) +} + +export function clearGitStatusLineStatsCache(): void { + globalInvalidationGeneration += 1 + lineStatsByWorktree.clear() + keyInvalidationGenerationByWorktree.clear() + lastStoredBeginSeqByWorktree.clear() +} + +export function clearGitStatusLineStatsCacheKey( + cacheKey: string, + writeToken?: GitStatusLineStatsWriteToken +): void { + if (writeToken !== undefined && !isWriteTokenCurrent(writeToken)) { + return + } + if (writeToken === undefined) { + bumpBoundedKeyMap( + keyInvalidationGenerationByWorktree, + cacheKey, + (keyInvalidationGenerationByWorktree.get(cacheKey) ?? 0) + 1 + ) + } else { + // Why: a token-scoped purge must retire scans that began before it, so an + // older in-flight scan can't store pre-purge counts and repopulate this key. + bumpBoundedKeyMap(lastStoredBeginSeqByWorktree, cacheKey, writeToken.beginSeq) + } + lineStatsByWorktree.delete(cacheKey) +} diff --git a/src/shared/git-uncommitted-line-stats.test.ts b/src/shared/git-uncommitted-line-stats.test.ts index d9e876113aa..001e9ef9efa 100644 --- a/src/shared/git-uncommitted-line-stats.test.ts +++ b/src/shared/git-uncommitted-line-stats.test.ts @@ -136,6 +136,15 @@ describe('collectUntrackedAdditions', () => { expect(readFileMock).toHaveBeenCalledTimes(1) }) + it('rejects when the status request is aborted instead of returning partial counts', async () => { + const controller = new AbortController() + controller.abort() + await expect( + collectUntrackedAdditions('/repo', ['a.ts'], controller.signal) + ).rejects.toMatchObject({ name: 'AbortError' }) + expect(lstatMock).not.toHaveBeenCalled() + }) + it('keeps the cache effective across polls for a status-limit-sized change set', async () => { // Why: git status caps at DEFAULT_GIT_STATUS_LIMIT (10,000) entries. A // cache smaller than one scan FIFO-evicts every entry mid-scan, so the diff --git a/src/shared/git-uncommitted-line-stats.ts b/src/shared/git-uncommitted-line-stats.ts index 3fe4c5cc99d..ac46b928486 100644 --- a/src/shared/git-uncommitted-line-stats.ts +++ b/src/shared/git-uncommitted-line-stats.ts @@ -176,12 +176,25 @@ function rememberUntrackedStats( // Untracked files have no git-tracked baseline, so `git diff` ignores them. // We count their contents directly to show an additions magnitude. +function createGitLineStatsAbortError(): Error { + const error = new Error('The operation was aborted.') + error.name = 'AbortError' + return error +} + export async function collectUntrackedAdditions( worktreePath: string, - untrackedPaths: readonly string[] + untrackedPaths: readonly string[], + signal?: AbortSignal ): Promise> { const result = new Map() for (let i = 0; i < untrackedPaths.length; i += UNTRACKED_READ_CONCURRENCY) { + // Why: an aborted refresh must reject (not resolve partial counts) so a + // cancelled scan cannot look like a completed status result, and so we + // stop burning (possibly remote-host) file I/O after cancellation. + if (signal?.aborted) { + throw createGitLineStatsAbortError() + } const chunk = untrackedPaths.slice(i, i + UNTRACKED_READ_CONCURRENCY) await Promise.all( chunk.map(async (relativePath) => { @@ -189,6 +202,9 @@ export async function collectUntrackedAdditions( }) ) } + if (signal?.aborted) { + throw createGitLineStatsAbortError() + } return result } diff --git a/src/shared/jira-types.ts b/src/shared/jira-types.ts index 7831bd18416..ad8e84ecbad 100644 --- a/src/shared/jira-types.ts +++ b/src/shared/jira-types.ts @@ -1,9 +1,15 @@ +// 'cloud' = Atlassian Cloud (email + API token, Basic auth, REST v3). +// 'server' = self-hosted Jira Server/Data Center (personal access token, +// Bearer auth, REST v2). Older stored sites omit the field and mean 'cloud'. +export type JiraAuthType = 'cloud' | 'server' + export type JiraSite = { id: string siteUrl: string email: string displayName: string accountId: string + authType?: JiraAuthType } export type JiraViewer = { @@ -130,8 +136,11 @@ export type JiraIssueFilter = 'assigned' | 'reported' | 'all' | 'done' export type JiraConnectArgs = { siteUrl: string + // Ignored for 'server' auth: self-hosted PATs authenticate via Bearer + // header alone, so the email field may be empty. email: string apiToken: string + authType?: JiraAuthType } export type JiraCreateIssueArgs = { diff --git a/src/shared/native-chat-agent-support.test.ts b/src/shared/native-chat-agent-support.test.ts new file mode 100644 index 00000000000..8745d47b394 --- /dev/null +++ b/src/shared/native-chat-agent-support.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { + isNativeChatSupportedAgent, + resolveNativeChatTranscriptAgent, + shouldStepNativeChatAskAnswer +} from './native-chat-agent-support' + +describe('resolveNativeChatTranscriptAgent', () => { + it('maps OpenClaude onto the Claude transcript format', () => { + expect(resolveNativeChatTranscriptAgent('openclaude')).toBe('claude') + expect(resolveNativeChatTranscriptAgent('claude')).toBe('claude') + }) + + it('passes codex and grok through and rejects everything else', () => { + expect(resolveNativeChatTranscriptAgent('codex')).toBe('codex') + expect(resolveNativeChatTranscriptAgent('grok')).toBe('grok') + expect(resolveNativeChatTranscriptAgent('cursor')).toBeNull() + expect(resolveNativeChatTranscriptAgent(null)).toBeNull() + expect(resolveNativeChatTranscriptAgent(undefined)).toBeNull() + }) +}) + +describe('isNativeChatSupportedAgent', () => { + it('recognizes the parseable agents and rejects unknown / nullish input', () => { + expect(isNativeChatSupportedAgent('claude')).toBe(true) + expect(isNativeChatSupportedAgent('openclaude')).toBe(true) + expect(isNativeChatSupportedAgent('cursor')).toBe(false) + expect(isNativeChatSupportedAgent(null)).toBe(false) + expect(isNativeChatSupportedAgent(undefined)).toBe(false) + }) +}) + +describe('shouldStepNativeChatAskAnswer', () => { + it('steps only the Claude-format agents (Claude, OpenClaude)', () => { + expect(shouldStepNativeChatAskAnswer('claude')).toBe(true) + expect(shouldStepNativeChatAskAnswer('openclaude')).toBe(true) + }) + + it('does not step other or unknown agents', () => { + expect(shouldStepNativeChatAskAnswer('codex')).toBe(false) + expect(shouldStepNativeChatAskAnswer('grok')).toBe(false) + expect(shouldStepNativeChatAskAnswer('cursor')).toBe(false) + expect(shouldStepNativeChatAskAnswer(null)).toBe(false) + expect(shouldStepNativeChatAskAnswer(undefined)).toBe(false) + }) +}) diff --git a/src/shared/native-chat-agent-support.ts b/src/shared/native-chat-agent-support.ts new file mode 100644 index 00000000000..0c7e8e4a14d --- /dev/null +++ b/src/shared/native-chat-agent-support.ts @@ -0,0 +1,34 @@ +export type NativeChatTranscriptAgent = 'claude' | 'codex' | 'grok' + +/** Agents whose transcripts the native chat view can parse and render. */ +export const NATIVE_CHAT_SUPPORTED_AGENTS: ReadonlySet = new Set([ + 'claude', + 'openclaude', + 'codex', + 'grok' +]) + +export function isNativeChatSupportedAgent(agent: string | null | undefined): boolean { + return agent != null && NATIVE_CHAT_SUPPORTED_AGENTS.has(agent) +} + +/** True when the agent renders Claude's multi-step AskUserQuestion — one question + * per step, each Enter advancing — so a multi-line answer must be paced per line. + * Other agents submit the whole answer with a single Enter. */ +export function shouldStepNativeChatAskAnswer(agent: string | null | undefined): boolean { + return resolveNativeChatTranscriptAgent(agent) === 'claude' +} + +export function resolveNativeChatTranscriptAgent( + agent: string | null | undefined +): NativeChatTranscriptAgent | null { + // Why: OpenClaude writes the Claude transcript format and layout even though + // Orca preserves its distinct agent identity for launch and UI behavior. + if (agent === 'claude' || agent === 'openclaude') { + return 'claude' + } + if (agent === 'codex' || agent === 'grok') { + return agent + } + return null +} diff --git a/src/shared/native-chat-answer-stepping.ts b/src/shared/native-chat-answer-stepping.ts new file mode 100644 index 00000000000..0fd148e13e8 --- /dev/null +++ b/src/shared/native-chat-answer-stepping.ts @@ -0,0 +1,26 @@ +export const NATIVE_CHAT_SUBMIT_DELAY_MS = 500 +// 500ms (not a tighter cadence) so the next AskUserQuestion step still renders +// before its body is written on slower machines / under SSH round-trip latency. +export const NATIVE_CHAT_ADVANCE_BUFFER_MS = 500 +export const NATIVE_CHAT_QUESTION_STEP_MS = + NATIVE_CHAT_SUBMIT_DELAY_MS + NATIVE_CHAT_ADVANCE_BUFFER_MS + +export function nativeChatQuestionOffsets(index: number): { bodyAt: number; enterAt: number } { + const bodyAt = index * NATIVE_CHAT_QUESTION_STEP_MS + return { bodyAt, enterAt: bodyAt + NATIVE_CHAT_SUBMIT_DELAY_MS } +} + +/** Schedule one body and one Enter per question; callers own the actual transport. */ +export function scheduleNativeChatAnswer( + lines: readonly string[], + writeBody: (line: string) => void, + writeEnter: () => void +): ReturnType[] { + const timers: ReturnType[] = [] + lines.forEach((line, index) => { + const { bodyAt, enterAt } = nativeChatQuestionOffsets(index) + timers.push(setTimeout(() => writeBody(line), bodyAt)) + timers.push(setTimeout(writeEnter, enterAt)) + }) + return timers +} diff --git a/src/shared/native-chat-ask-types.ts b/src/shared/native-chat-ask-types.ts index 640f2ed74d7..9c5942ab7de 100644 --- a/src/shared/native-chat-ask-types.ts +++ b/src/shared/native-chat-ask-types.ts @@ -1,8 +1,5 @@ -// Canonical AskUserQuestion prompt types shared by both native-chat surfaces -// (desktop `native-chat-interactive-prompt.ts` and mobile `mobile-native-chat-ask.ts`). -// Types only — both surfaces `import type` from here (Metro can't resolve runtime -// values from src/shared), so the type declarations stop being duplicated while -// the parser bodies stay mirrored and parity-tested. +// Canonical AskUserQuestion prompt types consumed by the shared parser and both +// native-chat platform UIs. export type AskOption = { label: string; description?: string } export type AskQuestion = { diff --git a/src/shared/native-chat-ask.test.ts b/src/shared/native-chat-ask.test.ts new file mode 100644 index 00000000000..e02f2bb9bba --- /dev/null +++ b/src/shared/native-chat-ask.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatBlock, NativeChatMessage } from './native-chat-types' +import { extractPendingAsk, parseAskFromStatus } from './native-chat-ask' + +function message(id: string, blocks: NativeChatBlock[]): NativeChatMessage { + return { id, role: 'assistant', blocks, timestamp: 1, source: 'transcript' } +} + +function call(name: string, input: unknown): NativeChatBlock { + return { type: 'tool-call', name, input } +} + +function result(): NativeChatBlock { + return { type: 'tool-result', output: 'ok' } +} + +const QUESTIONS_INPUT = { + questions: [{ question: 'Deploy?', options: [{ label: 'Yes' }, { label: 'No' }] }] +} + +describe('extractPendingAsk', () => { + it('recognizes an unregistered tool whose input matches the canonical questions shape', () => { + // The live path (parseAskFromStatus) accepts this shape from any tool name; + // transcript replay must not silently drop the same pending question. + const pending = extractPendingAsk([message('m1', [call('CustomAskTool', QUESTIONS_INPUT)])]) + expect(pending?.questions[0]?.question).toBe('Deploy?') + }) + + it('resolves calls FIFO so a sibling result cannot clear a newer pending ask', () => { + const pending = extractPendingAsk([ + message('m1', [ + call('Bash', { command: 'ls' }), + call('AskUserQuestion', QUESTIONS_INPUT), + // FIFO: this result answers the Bash call, not the ask. + result() + ]) + ]) + expect(pending?.questions[0]?.question).toBe('Deploy?') + }) + + it("clears the ask when its own result arrives, keeping the newest ask's identity", () => { + const first = { questions: [{ question: 'First?', options: [] }] } + const pending = extractPendingAsk([ + message('m1', [ + call('AskUserQuestion', first), + call('AskUserQuestion', QUESTIONS_INPUT), + // Resolves the FIRST ask (FIFO); the newer one stays pending. + result() + ]) + ]) + expect(pending?.questions[0]?.question).toBe('Deploy?') + }) + + it('ignores malformed question payloads', () => { + expect( + extractPendingAsk([ + message('m1', [ + call('AskUserQuestion', { questions: [] }), + call('AskUserQuestion', { questions: [{}] }), + call('AskUserQuestion', 'not-an-object') + ]) + ]) + ).toBeNull() + }) +}) + +describe('parseAskFromStatus', () => { + it('accepts the canonical shape from any tool name and rejects broken JSON', () => { + expect( + parseAskFromStatus(JSON.stringify(QUESTIONS_INPUT), 'SomeNewTool')?.questions + ).toHaveLength(1) + expect(parseAskFromStatus('{not json', 'AskUserQuestion')).toBeNull() + expect(parseAskFromStatus(null)).toBeNull() + }) + + it('parses string options into labels', () => { + const prompt = parseAskFromStatus( + JSON.stringify({ questions: [{ question: 'Pick', options: ['a', 'b'] }] }) + ) + expect(prompt?.questions[0]?.options.map((o) => o.label)).toEqual(['a', 'b']) + }) +}) diff --git a/src/shared/native-chat-ask.ts b/src/shared/native-chat-ask.ts new file mode 100644 index 00000000000..567ed97e0d2 --- /dev/null +++ b/src/shared/native-chat-ask.ts @@ -0,0 +1,227 @@ +import type { + AskOption, + AskPrompt, + AskQuestion, + InteractiveQuestionParser +} from './native-chat-ask-types' +import type { NativeChatMessage } from './native-chat-types' + +export type { AskOption, AskPrompt, AskQuestion, InteractiveQuestionParser } + +const QUESTION_TOOL_PARSERS = new Map() + +export function registerQuestionTool(toolName: string, parser: InteractiveQuestionParser): void { + QUESTION_TOOL_PARSERS.set(toolName, parser) +} + +function parseQuestionsShape(input: unknown): AskPrompt | null { + if (!input || typeof input !== 'object') { + return null + } + const rawQuestions = (input as { questions?: unknown }).questions + if (!Array.isArray(rawQuestions) || rawQuestions.length === 0) { + return null + } + const questions: AskQuestion[] = [] + for (const raw of rawQuestions) { + if (!raw || typeof raw !== 'object') { + continue + } + const question = raw as Record + const text = typeof question.question === 'string' ? question.question : '' + const options = parseOptions(question.options) + if (text || options.length > 0) { + questions.push({ + question: text, + header: typeof question.header === 'string' ? question.header : undefined, + multiSelect: question.multiSelect === true, + options + }) + } + } + return questions.length > 0 ? { questions } : null +} + +function parseOptions(raw: unknown): AskOption[] { + if (!Array.isArray(raw)) { + return [] + } + return raw + .map((option): AskOption | null => { + if (typeof option === 'string') { + return { label: option } + } + if ( + option && + typeof option === 'object' && + typeof (option as { label?: unknown }).label === 'string' + ) { + const value = option as { label: string; description?: unknown } + return { + label: value.label, + description: typeof value.description === 'string' ? value.description : undefined + } + } + return null + }) + .filter((option): option is AskOption => option !== null) +} + +for (const name of ['AskUserQuestion', 'ask_user_question', 'askUserQuestion']) { + QUESTION_TOOL_PARSERS.set(name, parseQuestionsShape) +} + +function parseToolInput(toolName: string | undefined, input: unknown): AskPrompt | null { + const parser = toolName ? QUESTION_TOOL_PARSERS.get(toolName) : undefined + return (parser ? parser(input) : null) ?? parseQuestionsShape(input) +} + +export function parseAskFromStatus( + interactivePrompt: string | undefined | null, + toolName?: string +): AskPrompt | null { + if (!interactivePrompt) { + return null + } + try { + return parseToolInput(toolName, JSON.parse(interactivePrompt)) + } catch { + return null + } +} + +/** Resolve the newest question tool that has not received its FIFO tool result. + * Transcript replay parses each tool-call through the same registered-parser + + * canonical-shape fallback as live status, so a question tool that rendered + * live cannot vanish from pending state after a reconnect/replay. */ +export function extractPendingAsk(messages: readonly NativeChatMessage[]): AskPrompt | null { + let pending: AskPrompt | null = null + const outstanding: (AskPrompt | null)[] = [] + for (const message of messages) { + for (const block of message.blocks) { + if (block.type === 'tool-call') { + const parsed = parseToolInput(block.name, block.input) + if (parsed) { + pending = parsed + } + outstanding.push(parsed) + } else if (block.type === 'tool-result' && outstanding.length > 0) { + const resolved = outstanding.shift() + if (resolved && resolved === pending) { + pending = null + } + } + } + } + return pending +} + +/** One question's chosen answer, normalized for delivery: the selected option + * indices (in option order) plus any free-text "other" answer. Index-based (not + * label text) so the answer can be delivered by the selector's stable option + * number — see `buildAskAnswerKeys`. */ +export type AskAnswerSelection = { indices: number[]; other?: string } + +/** A single keystroke group to write to the agent PTY. `raw` bytes (option + * numbers, Enter, arrows) are written verbatim as keystrokes; `text` is a + * free-text answer the caller runs through its paste sanitizer before writing. */ +export type AskAnswerKeyGroup = { raw: string } | { text: string } + +/** True when this question is answered (a picked option or typed free text). */ +function isAnswered(sel: AskAnswerSelection | undefined): boolean { + return (sel?.indices.length ?? 0) > 0 || (sel?.other ?? '').trim().length > 0 +} + +/** The picked labels + trimmed free text for one question, in option order. */ +function answerLabels(question: AskQuestion, sel: AskAnswerSelection | undefined): string[] { + const labels = (sel?.indices ?? []) + .map((i) => question.options[i]?.label ?? '') + .filter((l) => l.length > 0) + const other = (sel?.other ?? '').trim() + return other ? [...labels, other] : labels +} + +/** Build the human-readable answer text: one line per question, in question + * order, each the selected label(s) + free text joined by ", ". Empty answers + * stay empty lines so N lines always == N questions. Used for agents whose + * question tool commits a pasted answer (not Claude's arrow-navigate selector). */ +export function formatAskAnswer(prompt: AskPrompt, selections: AskAnswerSelection[]): string { + return prompt.questions.map((q, i) => answerLabels(q, selections[i]).join(', ')).join('\n') +} + +// Claude's AskUserQuestion is an arrow-navigate selector: a bare Enter commits +// the HIGHLIGHTED default (the first option), and pasted label text does not move +// the highlight — so answering by label silently delivered every non-first pick +// as the first option (STA-1860). Instead we drive the selector by each option's +// stable 1-based number (which matches the card's badge), the marker it commits +// on. Right-arrow steps to the next question / the Submit tab. Verified live +// against Claude Code's TUI; groups are written spaced apart (see the senders) +// because a navigation keystroke batched with Enter commits before the selector +// has applied it. +const ASK_ENTER = '\r' +const ASK_NEXT_TAB = '\x1b[C' + +/** Build the ordered keystroke groups that answer a Claude Code AskUserQuestion. + * Each group is written a step apart so the selector applies it before the next. + * + * - single-select pick → the option number (selects AND commits; in a + * multi-question prompt it auto-advances to the next question) + * - free-text answer → the "Type something" row number, the text, then Enter + * - multi-select → each option number TOGGLES its checkbox, then a step + * to the Submit tab + * - a multi-question prompt (and a lone multi-select) finishes on a Submit + * confirmation, so it ends with one Enter + * + * (Option counts are ≤ the tool's cap of a few, so single-digit numbers always + * address every row.) */ +export function buildAskAnswerKeys( + prompt: AskPrompt, + selections: AskAnswerSelection[] +): AskAnswerKeyGroup[] { + const questions = prompt.questions + const multiQuestion = questions.length > 1 + const groups: AskAnswerKeyGroup[] = [] + + questions.forEach((q, qi) => { + const sel = selections[qi] + const other = (sel?.other ?? '').trim() + const typeSomething = String(q.options.length + 1) + + if (q.multiSelect) { + for (const i of sel?.indices ?? []) { + groups.push({ raw: String(i + 1) }) + } + if (other) { + groups.push({ raw: typeSomething }, { text: other }, { raw: ASK_ENTER }) + } + // A multi-select never auto-advances; step to the next tab (the Submit tab + // when this is the last question). + groups.push({ raw: ASK_NEXT_TAB }) + } else if (other) { + // Single-select can only carry one value, so route any answer that + // includes free text through the "Type something" row as one string. + groups.push( + { raw: typeSomething }, + { text: answerLabels(q, sel).join(', ') }, + { raw: ASK_ENTER } + ) + } else if ((sel?.indices.length ?? 0) > 0) { + groups.push({ raw: String(sel!.indices[0]! + 1) }) + } else if (multiQuestion) { + // Unanswered question in a multi-question prompt: step past it. + groups.push({ raw: ASK_NEXT_TAB }) + } + }) + + const endsOnSubmitTab = + multiQuestion || (questions.length === 1 && questions[0]!.multiSelect === true) + if (endsOnSubmitTab && groups.length > 0) { + groups.push({ raw: ASK_ENTER }) + } + return groups +} + +/** Whether any question in `selections` carries an answer worth submitting. */ +export function hasAskAnswer(prompt: AskPrompt, selections: AskAnswerSelection[]): boolean { + return prompt.questions.some((_, i) => isAnswered(selections[i])) +} diff --git a/src/shared/native-chat-diff.ts b/src/shared/native-chat-diff.ts new file mode 100644 index 00000000000..1e49d1e8969 --- /dev/null +++ b/src/shared/native-chat-diff.ts @@ -0,0 +1,85 @@ +export type NativeChatDiffLineKind = 'add' | 'del' | 'context' | 'meta' + +export type NativeChatDiffLine = { + kind: NativeChatDiffLineKind + text: string +} + +const EDIT_TOOL_NAMES = new Set(['Edit', 'MultiEdit', 'Write', 'str_replace', 'apply_patch']) +const MAX_DIFF_CHARS = 32_000 +const DEFAULT_MAX_DIFF_LINES = 120 +const DIFF_TRUNCATED_LINE: NativeChatDiffLine = { + kind: 'meta', + text: '… diff truncated …' +} + +function toLines(value: unknown, maxLines: number): { lines: string[]; truncated: boolean } { + if (typeof value !== 'string') { + return { lines: [], truncated: false } + } + const clipped = value.slice(0, MAX_DIFF_CHARS) + const lines = clipped.split('\n', maxLines + 1) + const truncated = value.length > MAX_DIFF_CHARS || lines.length > maxLines + const bounded = lines.slice(0, maxLines) + if (!truncated && bounded.at(-1) === '') { + bounded.pop() + } + return { lines: bounded, truncated } +} + +export function diffFromToolCall( + name: string, + input: unknown, + maxLines = DEFAULT_MAX_DIFF_LINES +): NativeChatDiffLine[] | null { + if (!EDIT_TOOL_NAMES.has(name) || typeof input !== 'object' || input === null) { + return null + } + const value = input as Record + const oldLines = toLines(value.old_string ?? value.oldString ?? value.old, maxLines) + const newLines = toLines( + value.new_string ?? value.newString ?? value.new ?? value.content ?? value.file_text, + maxLines + ) + const deleted = oldLines.lines.map((text): NativeChatDiffLine => ({ kind: 'del', text })) + const added = newLines.lines.map((text): NativeChatDiffLine => ({ kind: 'add', text })) + if (deleted.length === 0 && added.length === 0) { + return null + } + const path = value.file_path ?? value.path + const prefix: NativeChatDiffLine[] = + typeof path === 'string' ? [{ kind: 'meta', text: path }] : [] + const combined = [...prefix, ...deleted, ...added] + const truncated = oldLines.truncated || newLines.truncated || combined.length > maxLines + return truncated ? [...combined.slice(0, maxLines - 1), DIFF_TRUNCATED_LINE] : combined +} + +export function diffFromText( + text: string, + maxLines = DEFAULT_MAX_DIFF_LINES +): NativeChatDiffLine[] | null { + if (text.length === 0) { + return null + } + const bounded = toLines(text, maxLines) + let added = 0 + let removed = 0 + const lines = bounded.lines.map((line): NativeChatDiffLine => { + if (line.startsWith('@@') || line.startsWith('diff ') || line.startsWith('index ')) { + return { kind: 'meta', text: line } + } + if (line.startsWith('+') && !line.startsWith('+++')) { + added += 1 + return { kind: 'add', text: line.slice(1) } + } + if (line.startsWith('-') && !line.startsWith('---')) { + removed += 1 + return { kind: 'del', text: line.slice(1) } + } + return { kind: 'context', text: line } + }) + if (added + removed < 2) { + return null + } + return bounded.truncated ? [...lines.slice(0, maxLines - 1), DIFF_TRUNCATED_LINE] : lines +} diff --git a/src/shared/native-chat-empty-state.ts b/src/shared/native-chat-empty-state.ts new file mode 100644 index 00000000000..2797648afd0 --- /dev/null +++ b/src/shared/native-chat-empty-state.ts @@ -0,0 +1,39 @@ +// Canonical English copy for the native-chat empty/loading/error states, shared +// by the desktop renderer (as its i18n fallback strings) and the mobile app +// (used directly — mobile ships English only) so the two surfaces never drift. +// `{{value0}}` is the agent label; each caller substitutes it (i18n on desktop, +// `formatNativeChatEmptyStateCopy` on mobile). + +export type NativeChatEmptyStateCopy = { title: string; subtitle: string } + +export const NATIVE_CHAT_EMPTY_STATE_COPY = { + loading: { + title: 'Loading conversation…', + subtitle: 'Reading the agent transcript.' + }, + empty: { + title: 'Start a chat with {{value0}}', + subtitle: 'Ask {{value0}} to inspect code, explain output, or make a change.' + }, + error: { + title: 'Could not load conversation', + subtitle: 'The transcript could not be read. Toggle back to the terminal to keep working.' + }, + notAgent: { + title: 'No conversation here', + subtitle: 'This terminal is not running a recognized coding agent.' + } +} as const satisfies Record + +/** Resolve the empty-state copy with the agent label substituted for `{{value0}}`. + * For platforms without an i18n layer (mobile). */ +export function formatNativeChatEmptyStateCopy( + kind: keyof typeof NATIVE_CHAT_EMPTY_STATE_COPY, + agentLabel: string +): NativeChatEmptyStateCopy { + const copy = NATIVE_CHAT_EMPTY_STATE_COPY[kind] + return { + title: copy.title.replaceAll('{{value0}}', agentLabel), + subtitle: copy.subtitle.replaceAll('{{value0}}', agentLabel) + } +} diff --git a/src/shared/native-chat-merge.ts b/src/shared/native-chat-merge.ts index d662fec3891..a2e12ad8302 100644 --- a/src/shared/native-chat-merge.ts +++ b/src/shared/native-chat-merge.ts @@ -1,13 +1,10 @@ -// Pure id-dedup merge for native-chat message windows, shared by the desktop -// renderer live path. Mobile keeps a parity-locked twin of this algorithm -// (mobile/src/session/mobile-native-chat-merge.ts) because Metro can't resolve -// runtime values outside the mobile package — the cross-surface parity test -// (native-chat-merge-parity.test.ts) pins the two implementations together. -// -// The algorithm is parameterized on a source-priority map so the single body -// works for any caller that supplies NATIVE_CHAT_SOURCE_PRIORITY. +// Pure id-dedup and windowing for both desktop and mobile native-chat streams. -import type { NativeChatMessage, NativeChatSource } from './native-chat-types' +import { + NATIVE_CHAT_SOURCE_PRIORITY, + type NativeChatMessage, + type NativeChatSource +} from './native-chat-types' export type NativeChatSourcePriority = Record @@ -31,6 +28,13 @@ export function mergeNativeChatMessagesWith( return merged } +export function mergeNativeChatMessages( + existing: readonly NativeChatMessage[], + incoming: readonly NativeChatMessage[] +): NativeChatMessage[] { + return mergeNativeChatMessagesWith(existing, incoming, NATIVE_CHAT_SOURCE_PRIORITY) +} + /** Cap a message list to its most-recent `limit` entries. The base read is * already windowed; this keeps the live-append tail bounded to the same window * so a long run can't grow the list without limit. A non-positive limit means @@ -56,7 +60,9 @@ export type NativeChatMerger = { readonly priority: NativeChatSourcePriority } -export function createNativeChatMerger(priority: NativeChatSourcePriority): NativeChatMerger { +export function createNativeChatMerger( + priority: NativeChatSourcePriority = NATIVE_CHAT_SOURCE_PRIORITY +): NativeChatMerger { return { list: [], indexById: new Map(), priority } } @@ -73,13 +79,20 @@ export function replaceList(merger: NativeChatMerger, list: readonly NativeChatM * index incrementally — O(incoming), never re-scanning the existing list. */ export function applyAppend( merger: NativeChatMerger, - incoming: readonly NativeChatMessage[] + incoming: readonly NativeChatMessage[], + limit?: number ): NativeChatMessage[] { if (incoming.length === 0) { return merger.list } const next = [...merger.list] applyIncoming(next, merger.indexById, incoming, merger.priority) + const bounded = limit === undefined ? next : boundNativeChatWindow(next, limit) + if (bounded !== next) { + // Why: trimming shifts every cached index, so rebuild at the window boundary. + replaceList(merger, bounded) + return merger.list + } merger.list = next return next } diff --git a/src/shared/native-chat-noise.ts b/src/shared/native-chat-noise.ts new file mode 100644 index 00000000000..ef75c81f751 --- /dev/null +++ b/src/shared/native-chat-noise.ts @@ -0,0 +1,25 @@ +import { isKnownHarnessInjectedUserTurnText } from './harness-injected-user-turns' +import { isTextBlock, type NativeChatMessage } from './native-chat-types' + +function messageText(message: NativeChatMessage): string { + return message.blocks + .filter(isTextBlock) + .map((block) => block.text) + .join('') + .trim() +} + +/** Harness-injected user/system turns are transport machinery, not conversation. */ +export function isNoiseMessage(message: NativeChatMessage): boolean { + if (message.role !== 'user' && message.role !== 'system') { + return false + } + if (message.blocks.some((block) => block.type === 'tool-call' || block.type === 'tool-result')) { + return false + } + return isKnownHarnessInjectedUserTurnText(messageText(message)) +} + +export function stripNoiseMessages(messages: readonly NativeChatMessage[]): NativeChatMessage[] { + return messages.filter((message) => !isNoiseMessage(message)) +} diff --git a/src/shared/native-chat-stream-unsubscribe.test.ts b/src/shared/native-chat-stream-unsubscribe.test.ts index 01c4c3e0db8..60f3cc26099 100644 --- a/src/shared/native-chat-stream-unsubscribe.test.ts +++ b/src/shared/native-chat-stream-unsubscribe.test.ts @@ -15,4 +15,11 @@ describe('native-chat stream unsubscribe key builder', () => { params: { subscriptionId: 'codex:abc' } }) }) + + it('echoes a pane-specific token when the subscriber supplied one', () => { + expect(buildNativeChatUnsubscribe('claude', 'same-session', 'pane-2')).toEqual({ + method: 'nativeChat.unsubscribe', + params: { subscriptionId: 'pane-2' } + }) + }) }) diff --git a/src/shared/native-chat-stream-unsubscribe.ts b/src/shared/native-chat-stream-unsubscribe.ts index 76475ba32cb..ae7c52587fe 100644 --- a/src/shared/native-chat-stream-unsubscribe.ts +++ b/src/shared/native-chat-stream-unsubscribe.ts @@ -18,10 +18,11 @@ export function buildNativeChatSubscriptionId(agent: string, sessionId: string): /** The unsubscribe RPC frame a client sends on teardown to close the watcher. */ export function buildNativeChatUnsubscribe( agent: string, - sessionId: string + sessionId: string, + subscriptionId?: string ): NativeChatUnsubscribeRpc { return { method: 'nativeChat.unsubscribe', - params: { subscriptionId: buildNativeChatSubscriptionId(agent, sessionId) } + params: { subscriptionId: subscriptionId ?? buildNativeChatSubscriptionId(agent, sessionId) } } } diff --git a/src/shared/native-chat-tool-fold.ts b/src/shared/native-chat-tool-fold.ts new file mode 100644 index 00000000000..1b363a280fa --- /dev/null +++ b/src/shared/native-chat-tool-fold.ts @@ -0,0 +1,93 @@ +import { + isToolCallBlock, + isToolResultBlock, + type NativeChatBlock, + type NativeChatMessage, + type NativeChatToolCallBlock, + type NativeChatToolResultBlock +} from './native-chat-types' + +function isToolOnlyMessage(message: NativeChatMessage): boolean { + return ( + message.blocks.length > 0 && + message.blocks.every((block) => isToolCallBlock(block) || isToolResultBlock(block)) + ) +} + +/** Fold consecutive tool-only messages into their preceding assistant turn. */ +export function foldToolMessages(messages: readonly NativeChatMessage[]): NativeChatMessage[] { + const output: NativeChatMessage[] = [] + let mutableAssistantIndex = -1 + for (const message of messages) { + const previous = output.at(-1) + if (isToolOnlyMessage(message) && previous?.role === 'assistant') { + const index = output.length - 1 + if (mutableAssistantIndex !== index) { + output[index] = { ...previous, blocks: [...previous.blocks] } + mutableAssistantIndex = index + } + output[index]!.blocks.push(...message.blocks) + } else { + output.push(message) + mutableAssistantIndex = -1 + } + } + return output +} + +export type NativeChatToolPair = { + call?: NativeChatToolCallBlock + result?: NativeChatToolResultBlock +} + +/** Pair calls and results by FIFO ordinal because transcript blocks carry no tool ids. */ +export function pairToolBlocks( + blocks: readonly NativeChatBlock[], + limit = Infinity +): NativeChatToolPair[] { + const pairs: NativeChatToolPair[] = [] + const callSlots: (number | null)[] = [] + let resultOrdinal = 0 + for (const block of blocks) { + if (block.type === 'tool-call') { + if (pairs.length < limit) { + callSlots.push(pairs.length) + pairs.push({ call: block }) + } else { + callSlots.push(null) + } + continue + } + if (block.type !== 'tool-result') { + continue + } + const slot = callSlots[resultOrdinal] + if (slot === undefined) { + if (pairs.length < limit) { + pairs.push({ result: block }) + } + } else { + resultOrdinal += 1 + if (slot !== null) { + pairs[slot]!.result = block + } + } + } + return pairs +} + +export function splitNativeChatBlocks(blocks: readonly NativeChatBlock[]): { + prose: NativeChatBlock[] + tools: NativeChatBlock[] +} { + const prose: NativeChatBlock[] = [] + const tools: NativeChatBlock[] = [] + for (const block of blocks) { + if (isToolCallBlock(block) || isToolResultBlock(block)) { + tools.push(block) + } else { + prose.push(block) + } + } + return { prose, tools } +} diff --git a/src/shared/native-chat-tool-summary.test.ts b/src/shared/native-chat-tool-summary.test.ts new file mode 100644 index 00000000000..ef6b385806d --- /dev/null +++ b/src/shared/native-chat-tool-summary.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatBlock } from './native-chat-types' +import { briefToolArg, summarizeToolInput, summarizeToolRun } from './native-chat-tool-summary' + +describe('summarizeToolInput bounded preview', () => { + it('collapses depth beyond the bound instead of serializing the whole tree', () => { + const deep = { a: { b: { c: { d: 'buried' } } } } + const preview = summarizeToolInput(deep) + expect(preview).toContain('[…]') + expect(preview).not.toContain('buried') + }) + + it('truncates oversized collections with an ellipsis marker', () => { + const wide = Object.fromEntries(Array.from({ length: 12 }, (_, i) => [`k${i}`, i])) + const preview = summarizeToolInput(wide) + expect(preview).toContain('…') + expect(preview).not.toContain('k11') + }) + + it('survives circular references', () => { + const cyclic: Record = { name: 'loop' } + cyclic.self = cyclic + expect(summarizeToolInput(cyclic)).toContain('[circular]') + }) +}) + +describe('briefToolArg', () => { + it('extracts the basename from forward- and backslash paths', () => { + expect(briefToolArg({ file_path: 'src/app/main.tsx' })).toBe('main.tsx') + expect(briefToolArg({ file_path: 'C:\\Users\\me\\project\\app.tsx' })).toBe('app.tsx') + }) + + it('falls back to the command preview when no path is present', () => { + expect(briefToolArg({ command: 'git status --short' })).toBe('git status --short') + }) +}) + +describe('summarizeToolRun', () => { + it('caps the run summary and skips nameless calls', () => { + const blocks: NativeChatBlock[] = [ + { type: 'tool-call', name: ' ', input: {} }, + { type: 'tool-call', name: 'Bash', input: { command: 'ls' } }, + { type: 'tool-call', name: 'Read', input: { file_path: 'a.ts' } }, + { type: 'tool-call', name: 'Edit', input: { file_path: 'b.ts' } }, + { type: 'tool-call', name: 'Write', input: { file_path: 'c.ts' } } + ] + const summary = summarizeToolRun(blocks) + expect(summary).toBe('Bash ls · Read a.ts · Edit b.ts') + }) +}) diff --git a/src/shared/native-chat-tool-summary.ts b/src/shared/native-chat-tool-summary.ts new file mode 100644 index 00000000000..efe606f55a9 --- /dev/null +++ b/src/shared/native-chat-tool-summary.ts @@ -0,0 +1,140 @@ +import { isToolCallBlock, type NativeChatBlock } from './native-chat-types' + +const MAX_PREVIEW_LENGTH = 80 +const MAX_PREVIEW_STRING_INPUT = 160 +const MAX_PREVIEW_COLLECTION_ITEMS = 8 +const MAX_PREVIEW_DEPTH = 2 +const MAX_TOOL_RUN_SUMMARY_PARTS = 3 + +export function summarizeToolInput(input: unknown): string { + const collapsed = toRawPreview(input).replace(/\s+/g, ' ').trim() + return collapsed.length <= MAX_PREVIEW_LENGTH + ? collapsed + : `${collapsed.slice(0, MAX_PREVIEW_LENGTH - 1)}…` +} + +/** Full, pretty-printed tool-call input for the expanded detail view. Strings + * pass through as-is; objects/arrays print as indented JSON so a diff-less call + * (e.g. a question payload) reads cleanly instead of one long minified line. */ +export function formatToolInput(input: unknown): string { + if (input === null || input === undefined) { + return '' + } + if (typeof input === 'string') { + return input + } + if (typeof input === 'number' || typeof input === 'boolean') { + return String(input) + } + try { + return JSON.stringify(input, null, 2) ?? '' + } catch { + return '' + } +} + +export function toolFilePath(input: unknown): string | null { + if (!input || typeof input !== 'object') { + return null + } + const value = input as Record + const path = value.file_path ?? value.filePath ?? value.path ?? value.notebook_path + return typeof path === 'string' && path.length > 0 ? path : null +} + +export function briefToolArg(input: unknown): string { + if (input && typeof input === 'object') { + const value = input as Record + const path = value.file_path ?? value.filePath ?? value.path ?? value.notebook_path + if (typeof path === 'string' && path.length > 0) { + const parts = path.split(/[\\/]/).filter(Boolean) + return parts.at(-1) ?? path + } + const command = value.command ?? value.cmd ?? value.query ?? value.pattern + if (typeof command === 'string') { + return summarizeToolInput(command).slice(0, 28) + } + } + return summarizeToolInput(input).slice(0, 28) +} + +export function summarizeToolRun(blocks: readonly NativeChatBlock[]): string { + const parts: string[] = [] + for (const block of blocks) { + if (!isToolCallBlock(block)) { + continue + } + const name = block.name.trim() + if (!name) { + continue + } + const detail = briefToolArg(block.input) + parts.push(detail ? `${name} ${detail}` : name) + if (parts.length >= MAX_TOOL_RUN_SUMMARY_PARTS) { + break + } + } + return parts.join(' · ') +} + +export function countToolCalls(blocks: readonly NativeChatBlock[]): number { + return blocks.filter(isToolCallBlock).length +} + +function toRawPreview(input: unknown): string { + if (input === null || input === undefined) { + return '' + } + if (typeof input === 'string') { + return input + } + if (typeof input !== 'object') { + return String(input) + } + try { + return JSON.stringify(boundedPreviewValue(input, 0, new WeakSet())) ?? '' + } catch { + return '' + } +} + +function boundedPreviewValue(value: unknown, depth: number, seen: WeakSet): unknown { + if (typeof value === 'string') { + return value.length > MAX_PREVIEW_STRING_INPUT + ? `${value.slice(0, MAX_PREVIEW_STRING_INPUT)}…` + : value + } + if (!value || typeof value !== 'object') { + return value + } + if (seen.has(value)) { + return '[circular]' + } + if (depth >= MAX_PREVIEW_DEPTH) { + return '[…]' + } + seen.add(value) + if (Array.isArray(value)) { + const result = value + .slice(0, MAX_PREVIEW_COLLECTION_ITEMS) + .map((item) => boundedPreviewValue(item, depth + 1, seen)) + if (value.length > MAX_PREVIEW_COLLECTION_ITEMS) { + result.push('…') + } + return result + } + const result: Record = {} + let count = 0 + for (const key in value) { + if (!Object.prototype.hasOwnProperty.call(value, key)) { + continue + } + if (count >= MAX_PREVIEW_COLLECTION_ITEMS) { + result['…'] = '…' + break + } + result[key] = boundedPreviewValue((value as Record)[key], depth + 1, seen) + count += 1 + } + return result +} diff --git a/src/shared/new-workspace/workspace-source.test.ts b/src/shared/new-workspace/workspace-source.test.ts index 93e46c4db17..ca1ea34b3a8 100644 --- a/src/shared/new-workspace/workspace-source.test.ts +++ b/src/shared/new-workspace/workspace-source.test.ts @@ -13,7 +13,8 @@ describe('workspace source policy', () => { identifier: 'ENG-42', title: 'Ship mobile parity', url: 'https://linear.app/acme/issue/ENG-42/ship-mobile-parity', - workspaceId: 'workspace-1' + workspaceId: 'workspace-1', + branchName: ' team/eng-42-ship-mobile-parity ' }) it('builds one Linear identity for desktop and mobile create flows', () => { @@ -22,7 +23,8 @@ describe('workspace source policy', () => { number: 0, linearIdentifier: 'ENG-42', linearWorkspaceId: 'workspace-1', - linearOrganizationUrlKey: 'acme' + linearOrganizationUrlKey: 'acme', + linearBranchName: 'team/eng-42-ship-mobile-parity' }) expect(getWorkspaceSourceName(linear)).toEqual({ seedName: 'eng-42-ship-mobile-parity', diff --git a/src/shared/new-workspace/workspace-source.ts b/src/shared/new-workspace/workspace-source.ts index b8c3048e9c5..dfb8d1df016 100644 --- a/src/shared/new-workspace/workspace-source.ts +++ b/src/shared/new-workspace/workspace-source.ts @@ -12,6 +12,7 @@ export type WorkspaceSourceProvider = FolderWorkspaceLinkedTask['provider'] export type WorkspaceSourceLinkedItem = FolderWorkspaceLinkedTask & { linearWorkspaceId?: string linearOrganizationUrlKey?: string + linearBranchName?: string } export type GitHubWorkspaceSource = WorkspaceSourceLinkedItem & { @@ -109,10 +110,19 @@ export function buildGitLabWorkspaceSource(item: { return { provider: 'gitlab', ...item } } +export function getUsableLinearBranchName( + branchName: string | null | undefined +): string | undefined { + // Why: only a non-blank normalized value can safely enter the exact git-ref + // override path; missing values must keep Orca's generated-name fallback. + return branchName?.trim() || undefined +} + export function buildLinearWorkspaceSource( - issue: Pick + issue: Pick ): LinearWorkspaceSource { const organizationUrlKey = getLinearOrganizationUrlKeyFromIssueUrl(issue.url) + const branchName = getUsableLinearBranchName(issue.branchName) return { provider: 'linear', type: 'issue', @@ -122,7 +132,8 @@ export function buildLinearWorkspaceSource( url: issue.url, linearIdentifier: issue.identifier, ...(issue.workspaceId ? { linearWorkspaceId: issue.workspaceId } : {}), - ...(organizationUrlKey ? { linearOrganizationUrlKey: organizationUrlKey } : {}) + ...(organizationUrlKey ? { linearOrganizationUrlKey: organizationUrlKey } : {}), + ...(branchName ? { linearBranchName: branchName } : {}) } } diff --git a/src/shared/quick-open-readdir-budget.ts b/src/shared/quick-open-readdir-budget.ts new file mode 100644 index 00000000000..14fd599cf64 --- /dev/null +++ b/src/shared/quick-open-readdir-budget.ts @@ -0,0 +1,38 @@ +export const QUICK_OPEN_READDIR_MAX_FILES = 10_000 +export const QUICK_OPEN_READDIR_TIMEOUT_MS = 10_000 + +export type QuickOpenReaddirBudget = { + remainingFiles: number + deadlineMs: number +} + +export function createQuickOpenReaddirBudget( + opts: { maxFiles?: number; timeoutMs?: number; nowMs?: number } = {} +): QuickOpenReaddirBudget { + return { + remainingFiles: opts.maxFiles ?? QUICK_OPEN_READDIR_MAX_FILES, + deadlineMs: (opts.nowMs ?? Date.now()) + (opts.timeoutMs ?? QUICK_OPEN_READDIR_TIMEOUT_MS) + } +} + +const FILE_LISTING_TIMED_OUT = 'File listing timed out' +const FILE_LISTING_EXCEEDED_PREFIX = 'File listing exceeded' + +/** Budget errors are the only fallback failures translated into install-rg guidance. */ +export function isQuickOpenReaddirBudgetError(error: unknown): boolean { + const message = error instanceof Error ? error.message : '' + return message === FILE_LISTING_TIMED_OUT || message.startsWith(FILE_LISTING_EXCEEDED_PREFIX) +} + +export function assertQuickOpenReaddirDeadline(budget: QuickOpenReaddirBudget): void { + if (Date.now() > budget.deadlineMs) { + throw new Error(FILE_LISTING_TIMED_OUT) + } +} + +export function consumeQuickOpenReaddirFileBudget(budget: QuickOpenReaddirBudget): void { + if (budget.remainingFiles <= 0) { + throw new Error(`${FILE_LISTING_EXCEEDED_PREFIX} ${QUICK_OPEN_READDIR_MAX_FILES} files`) + } + budget.remainingFiles-- +} diff --git a/src/shared/quick-open-readdir-walk.test.ts b/src/shared/quick-open-readdir-walk.test.ts index 2331bc3b1c3..e6894bd815c 100644 --- a/src/shared/quick-open-readdir-walk.test.ts +++ b/src/shared/quick-open-readdir-walk.test.ts @@ -434,6 +434,17 @@ describe('quick-open readdir walk', () => { ).rejects.toThrow('File listing timed out') }) + it('returns a successful bounded prefix without consuming the safety cap', async () => { + const root = await makeTempRoot() + await writeRel(root, 'a.ts') + await writeRel(root, 'b.ts') + + const files = await listQuickOpenFilesWithReaddir(root, { maxResults: 1 }) + + expect(files).toHaveLength(1) + expect(['a.ts', 'b.ts']).toContain(files[0]) + }) + it('does not list symlinked files or follow symlinked directories', async () => { const root = await makeTempRoot() await writeRel(root, 'src/index.ts') diff --git a/src/shared/quick-open-readdir-walk.ts b/src/shared/quick-open-readdir-walk.ts index ada9442cd23..fbbb24963cc 100644 --- a/src/shared/quick-open-readdir-walk.ts +++ b/src/shared/quick-open-readdir-walk.ts @@ -8,16 +8,22 @@ import { shouldExcludeQuickOpenRelPath, shouldIncludeQuickOpenPath } from './quick-open-filter' +import { + assertQuickOpenReaddirDeadline, + consumeQuickOpenReaddirFileBudget, + createQuickOpenReaddirBudget, + type QuickOpenReaddirBudget +} from './quick-open-readdir-budget' + +export { + createQuickOpenReaddirBudget, + isQuickOpenReaddirBudgetError, + QUICK_OPEN_READDIR_MAX_FILES, + QUICK_OPEN_READDIR_TIMEOUT_MS +} from './quick-open-readdir-budget' -export const QUICK_OPEN_READDIR_MAX_FILES = 10_000 -export const QUICK_OPEN_READDIR_TIMEOUT_MS = 10_000 const QUICK_OPEN_READDIR_CONCURRENCY = 32 -export type QuickOpenReaddirBudget = { - remainingFiles: number - deadlineMs: number -} - export type QuickOpenGitEntryKind = 'keep' | 'fill-nested-repo' | 'drop-placeholder' export type QuickOpenGitLsFilesEntry = { @@ -44,42 +50,6 @@ export function parseQuickOpenGitLsFilesEntry(entry: string): QuickOpenGitLsFile } } -export function createQuickOpenReaddirBudget( - opts: { maxFiles?: number; timeoutMs?: number; nowMs?: number } = {} -): QuickOpenReaddirBudget { - return { - remainingFiles: opts.maxFiles ?? QUICK_OPEN_READDIR_MAX_FILES, - deadlineMs: (opts.nowMs ?? Date.now()) + (opts.timeoutMs ?? QUICK_OPEN_READDIR_TIMEOUT_MS) - } -} - -const FILE_LISTING_TIMED_OUT = 'File listing timed out' -const FILE_LISTING_EXCEEDED_PREFIX = 'File listing exceeded' - -/** - * Why: the readdir walk can exhaust its cap/deadline even on the git path (a - * git monorepo parent with a huge nested repo). Callers translate only these - * budget errors into "install rg" guidance, leaving genuine git failures with - * their own messages. - */ -export function isQuickOpenReaddirBudgetError(error: unknown): boolean { - const message = error instanceof Error ? error.message : '' - return message === FILE_LISTING_TIMED_OUT || message.startsWith(FILE_LISTING_EXCEEDED_PREFIX) -} - -function assertWithinDeadline(budget: QuickOpenReaddirBudget): void { - if (Date.now() > budget.deadlineMs) { - throw new Error(FILE_LISTING_TIMED_OUT) - } -} - -function consumeFileBudget(budget: QuickOpenReaddirBudget): void { - if (budget.remainingFiles <= 0) { - throw new Error(`${FILE_LISTING_EXCEEDED_PREFIX} ${QUICK_OPEN_READDIR_MAX_FILES} files`) - } - budget.remainingFiles-- -} - function shouldDescend(name: string): boolean { return name !== 'node_modules' && !HIDDEN_DIR_BLOCKLIST.has(name) } @@ -162,6 +132,7 @@ export async function listQuickOpenFilesWithReaddir( excludePathPrefixes?: readonly string[] workspaceRelPathPrefix?: string budget?: QuickOpenReaddirBudget + maxResults?: number signal?: AbortSignal } = {} ): Promise { @@ -175,7 +146,8 @@ export async function listQuickOpenFilesWithReaddir( } ], opts.budget ?? createQuickOpenReaddirBudget(), - opts.signal + opts.signal, + opts.maxResults ) } @@ -191,9 +163,13 @@ type QuickOpenReaddirRoot = { async function listQuickOpenFilesFromRoots( roots: readonly QuickOpenReaddirRoot[], budget: QuickOpenReaddirBudget, - signal?: AbortSignal + signal?: AbortSignal, + maxResults?: number ): Promise { const files: string[] = [] + if (maxResults !== undefined && maxResults <= 0) { + return files + } let pendingDirectories = roots.map((root) => ({ root, absPath: root.rootPath, @@ -211,7 +187,7 @@ async function listQuickOpenFilesFromRoots( // so the shared cap remains exact while shallow placeholder-heavy repos // do not pay one relay event-loop turn per directory. throwIfFileListingCancelled(signal) - assertWithinDeadline(budget) + assertQuickOpenReaddirDeadline(budget) const batch = pendingDirectories.slice(offset, offset + QUICK_OPEN_READDIR_CONCURRENCY) const entryGroups = await Promise.all( batch.map(async (pending) => { @@ -240,12 +216,12 @@ async function listQuickOpenFilesFromRoots( // Why: an empty directory has no per-entry checkpoint below. Cancellation // or timeout that lands during readdir must still reject, never resolve []. throwIfFileListingCancelled(signal) - assertWithinDeadline(budget) + assertQuickOpenReaddirDeadline(budget) for (const { pending, entries } of entryGroups) { for (const entry of entries) { throwIfFileListingCancelled(signal) - assertWithinDeadline(budget) + assertQuickOpenReaddirDeadline(budget) const name = entry.name const absPath = join(pending.absPath, name) @@ -266,12 +242,17 @@ async function listQuickOpenFilesFromRoots( (entry.isFile() || (pending.root.includeSymlinks && entry.isSymbolicLink())) && shouldIncludeQuickOpenPath(workspaceRelPath) ) { - consumeFileBudget(budget) + consumeQuickOpenReaddirFileBudget(budget) files.push( pending.root.outputPathPrefix ? `${pending.root.outputPathPrefix}/${relPath}` : relPath ) + // Why: a caller result limit is a successful bounded prefix, while + // the separate traversal budget still rejects incomplete scans. + if (maxResults !== undefined && files.length >= maxResults) { + return files + } } } } @@ -288,6 +269,7 @@ export async function expandQuickOpenGitFileListing(opts: { directoryPaths?: Iterable excludePathPrefixes?: readonly string[] budget?: QuickOpenReaddirBudget + maxResults?: number signal?: AbortSignal }): Promise { const files = new Set() @@ -309,7 +291,7 @@ export async function expandQuickOpenGitFileListing(opts: { for (const rawPath of opts.gitPaths) { throwIfFileListingCancelled(opts.signal) - assertWithinDeadline(budget) + assertQuickOpenReaddirDeadline(budget) const { kind, relPath } = await classifyQuickOpenGitEntry(opts.rootPath, rawPath) if (kind === 'keep') { @@ -325,7 +307,7 @@ export async function expandQuickOpenGitFileListing(opts: { for (const rawPath of opts.directoryPaths ?? []) { throwIfFileListingCancelled(opts.signal) - assertWithinDeadline(budget) + assertQuickOpenReaddirDeadline(budget) const relPath = normalizeGitEntry(rawPath) // Why: Git intentionally leaves collapsed directories unexpanded; reject @@ -356,11 +338,12 @@ export async function expandQuickOpenGitFileListing(opts: { includeSymlinks })), budget, - opts.signal + opts.signal, + opts.maxResults === undefined ? undefined : Math.max(0, opts.maxResults - files.size) ) for (const expandedFile of expandedFiles) { addFinalPath(expandedFile) } - return Array.from(files) + return Array.from(files).slice(0, opts.maxResults) } diff --git a/src/shared/remote-runtime-shared-control-connection.test.ts b/src/shared/remote-runtime-shared-control-connection.test.ts index b355bda7dfa..0a2dd4a2e88 100644 --- a/src/shared/remote-runtime-shared-control-connection.test.ts +++ b/src/shared/remote-runtime-shared-control-connection.test.ts @@ -454,32 +454,37 @@ describe('RemoteRuntimeSharedControlConnection', () => { connection.close() }) - it('tears down the socket when a request times out and reconnects active subscriptions', async () => { - const server = await createServer({ silentMethods: ['worktree.hang'] }) - const connection = new RemoteRuntimeSharedControlConnection(server.pairing) - const onResponse = vi.fn() - const onClose = vi.fn() - - await connection.subscribe('runtime.clientEvents.subscribe', null, 1000, { - onResponse, - onError: vi.fn(), - onClose + it('keeps unrelated pending requests alive when one request times out', async () => { + const server = await createServer({ + silentMethods: ['worktree.hang'], + delayedMethods: ['worktree.ps'] }) - await vi.waitFor(() => expect(onResponse).toHaveBeenCalled()) + const connection = new RemoteRuntimeSharedControlConnection(server.pairing) - // Why: mirrors RemoteRuntimeRequestConnection — a request the server never - // answered marks the socket as suspect and must not leave it 'ready'. - await expect(connection.request('worktree.hang', undefined, 50)).rejects.toThrow('Timed out') - - await vi.waitFor(() => expect(server.connectionCount()).toBe(2), { timeout: 5000 }) - await vi.waitFor( - () => - expect( - server.requests.filter((request) => request.method === 'runtime.clientEvents.subscribe') - ).toHaveLength(2), - { timeout: 5000 } + const timedOut = connection.request('worktree.hang', undefined, 250) + void timedOut.catch(() => undefined) + await vi.waitFor(() => + expect(server.requests.map(({ method }) => method)).toContain('worktree.hang') ) - expect(onClose).not.toHaveBeenCalled() + const survivor = connection.request('worktree.ps', undefined, 1000).then( + (response) => ({ ok: true as const, response }), + (error: unknown) => ({ ok: false as const, error }) + ) + await vi.waitFor(() => + expect(server.requests.map(({ method }) => method)).toContain('worktree.ps') + ) + + await expect(timedOut).rejects.toThrow('Timed out') + // Why: a single slow method is not evidence that a shared socket is dead; + // liveness monitoring owns connection-wide failure detection. + expect(connection.getDiagnostics()).toMatchObject({ state: 'ready', pendingRequestCount: 1 }) + + server.flushDelayedResponses() + await expect(survivor).resolves.toMatchObject({ + ok: true, + response: { ok: true, result: { method: 'worktree.ps' } } + }) + expect(server.connectionCount()).toBe(1) connection.close() }) @@ -515,6 +520,7 @@ async function createServer( // Why: half-open simulation — the socket stays open but never answers // protocol pings, like a wedged tunnel that swallows frames silently. disableAutoPong?: boolean + delayedMethods?: string[] silentMethods?: string[] } = {} ): Promise { @@ -613,6 +619,7 @@ function handleRequest( sendUnknownResponseBeforeResponse?: boolean closeAfterStreamingResponse?: () => boolean closeBeforeResponse?: boolean + delayedMethods?: string[] silentMethods?: string[] }, delayedResponses: (() => void)[] @@ -665,6 +672,10 @@ function handleRequest( delayedResponses.push(sendResponse) return } + if (options.delayedMethods?.includes(request.method)) { + delayedResponses.push(sendResponse) + return + } if (options.responseDelayMs !== undefined) { setTimeout(() => { sendResponse() diff --git a/src/shared/remote-runtime-shared-control-connection.ts b/src/shared/remote-runtime-shared-control-connection.ts index 585d3478cba..88be10796b8 100644 --- a/src/shared/remote-runtime-shared-control-connection.ts +++ b/src/shared/remote-runtime-shared-control-connection.ts @@ -74,10 +74,7 @@ export class RemoteRuntimeSharedControlConnection { timeoutMs, ensureReady: () => this.ensureReadyWithTimeout(timeoutMs), send: (requestId, requestMethod, requestParams) => - this.sendRequest(requestId, requestMethod, requestParams), - // Why: a timed-out request marks the socket as suspect (#7718) — tear - // it down so reconnect+replay runs instead of keeping a zombie socket. - onTimeout: (error) => this.handleSocketClosed(error) + this.sendRequest(requestId, requestMethod, requestParams) }) } diff --git a/src/shared/remote-runtime-shared-control-keepalive-refresh.test.ts b/src/shared/remote-runtime-shared-control-keepalive-refresh.test.ts index 6c1ec5c7db5..2f49bc082d1 100644 --- a/src/shared/remote-runtime-shared-control-keepalive-refresh.test.ts +++ b/src/shared/remote-runtime-shared-control-keepalive-refresh.test.ts @@ -22,10 +22,8 @@ describe('shared control keepalive timeout refresh semantics', () => { function startRequest(options: { refreshTimeoutOnKeepalive?: boolean } = {}): { pendingRequests: Map> promise: Promise - onTimeout: ReturnType } { const pendingRequests = new Map>() - const onTimeout = vi.fn() const promise = requestSharedControl({ pendingRequests, method: 'git.status', @@ -35,16 +33,15 @@ describe('shared control keepalive timeout refresh semantics', () => { // server never answers, modelling a genuinely stuck server-side call. ensureReady: () => Promise.resolve(), send: () => undefined, - onTimeout, refreshTimeoutOnKeepalive: options.refreshTimeoutOnKeepalive }) // Swallow the eventual rejection so unhandled-rejection noise doesn't leak. promise.catch(() => undefined) - return { pendingRequests, promise, onTimeout } + return { pendingRequests, promise } } it('times out a stuck short RPC even while keepalive frames keep arriving', async () => { - const { pendingRequests, promise, onTimeout } = startRequest() + const { pendingRequests, promise } = startRequest() // Periodic keepalives arrive faster than the 1000ms deadline — as they // would while a long-poll subscription streams over the same socket. @@ -55,13 +52,11 @@ describe('shared control keepalive timeout refresh semantics', () => { await vi.advanceTimersByTimeAsync(1) await expect(promise).rejects.toThrow() - // The stuck-request path tears the connection down so reconnect+replay runs. - expect(onTimeout).toHaveBeenCalledTimes(1) expect(pendingRequests.size).toBe(0) }) it('keeps refreshing a long-poll request that opted into keepalive refresh', async () => { - const { pendingRequests, promise, onTimeout } = startRequest({ + const { pendingRequests, promise } = startRequest({ refreshTimeoutOnKeepalive: true }) @@ -72,7 +67,6 @@ describe('shared control keepalive timeout refresh semantics', () => { refreshSharedControlPendingRequestTimeouts(pendingRequests) } - expect(onTimeout).not.toHaveBeenCalled() expect(pendingRequests.size).toBe(1) // It still resolves normally once the server finally answers. @@ -87,12 +81,11 @@ describe('shared control keepalive timeout refresh semantics', () => { }) it('fires the deadline for a short RPC when no keepalives arrive', async () => { - const { pendingRequests, promise, onTimeout } = startRequest() + const { pendingRequests, promise } = startRequest() await vi.advanceTimersByTimeAsync(1001) await expect(promise).rejects.toThrow() - expect(onTimeout).toHaveBeenCalledTimes(1) expect(pendingRequests.size).toBe(0) }) }) diff --git a/src/shared/remote-runtime-shared-control-requests.ts b/src/shared/remote-runtime-shared-control-requests.ts index 91f555af787..759ac4f8757 100644 --- a/src/shared/remote-runtime-shared-control-requests.ts +++ b/src/shared/remote-runtime-shared-control-requests.ts @@ -1,9 +1,5 @@ import { randomUUID } from 'node:crypto' -import type { RemoteRuntimeClientError } from './remote-runtime-client-error' -import { - remoteRuntimeTimeoutError, - remoteRuntimeUnavailableError -} from './remote-runtime-request-frames' +import { remoteRuntimeTimeoutError } from './remote-runtime-request-frames' import type { RuntimeRpcResponse } from './runtime-rpc-envelope' import { toRemoteRuntimeClientError } from './remote-runtime-shared-control-protocol' import { rejectSharedControlPendingRequest } from './remote-runtime-shared-control-state' @@ -16,7 +12,6 @@ export function requestSharedControl(args: { timeoutMs: number ensureReady: () => Promise send: (requestId: string, method: string, params: unknown) => void - onTimeout?: (error: RemoteRuntimeClientError) => void // Why: default off — ordinary short RPCs keep an absolute deadline. Only // long-polls routed through this path opt in so keepalives extend them. refreshTimeoutOnKeepalive?: boolean @@ -29,16 +24,9 @@ export function requestSharedControl(args: { return } args.pendingRequests.delete(requestId) + // Why: one stalled method does not prove the shared socket is dead; + // socket liveness owns connection-wide teardown so other RPCs survive. pending.reject(remoteRuntimeTimeoutError()) - // Why: a request the server never answered means the socket is suspect - // (half-open tunnels swallow frames silently); mirror - // RemoteRuntimeRequestConnection and hand the connection a teardown - // error so reconnect+replay runs instead of keeping a zombie socket. - args.onTimeout?.( - remoteRuntimeUnavailableError( - 'Remote Orca runtime did not answer in time; resetting the control connection.' - ) - ) }, args.timeoutMs) args.pendingRequests.set(requestId, { method: args.method, diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 41a800eb3da..774114b6d75 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -547,6 +547,8 @@ export type RuntimeTerminalFocus = { export type RuntimeTerminalClose = { handle: string tabId: string + /** Present for the durable whole-tab lifecycle without changing legacy receipts. */ + closeMode?: 'tab' ptyKilled: boolean } diff --git a/src/shared/skill-freshness.ts b/src/shared/skill-freshness.ts new file mode 100644 index 00000000000..08383d5e5f5 --- /dev/null +++ b/src/shared/skill-freshness.ts @@ -0,0 +1,102 @@ +import type { SkillProvider, SkillSourceKind } from './skills' + +export type SkillBundleFileIdentity = { + path: string + size: number + executable: boolean + classification: 'text' | 'binary' + exactSha256: string + textNormalizedSha256: string | null + identitySha256: string +} + +export type SkillKnownSnapshot = { + releaseRevision: number + packageDigest: string + gitTreeSha: string + files: SkillBundleFileIdentity[] +} + +export type SkillCurrentBundleEntry = SkillKnownSnapshot & { + name: string + sourcePath: string + appVersion: string +} + +export type SkillBundleManifest = { + schemaVersion: 1 + appVersion: string + skills: SkillCurrentBundleEntry[] +} + +export type SkillSnapshotRegistry = { + schemaVersion: 1 + skills: Record +} + +export type SkillReleaseMapping = { + schemaVersion: 1 + releases: { appVersion: string; skills: Record }[] +} + +export type SkillFreshnessStatus = + | 'current' + | 'outdated' + | 'newer-known' + | 'unrecognized' + | 'inaccessible' + +export type SkillInstallationTopology = + | 'canonical-copy' + | 'provider-alias' + | 'independent-copy' + | 'external-link' + | 'broken-link' + | 'read-only' + | 'repo-scope' + | 'plugin-cache' + +// Why: eligibility and the explanation copy must agree on which placements the +// validated npx rail can converge; a drifted copy would blame a phantom sibling. +export const SUPPORTED_GLOBAL_SKILL_TOPOLOGIES: ReadonlySet = new Set([ + 'canonical-copy', + 'provider-alias' +]) + +export type SkillFreshnessInstallation = { + id: string + name: string + rootId: string + providers: SkillProvider[] + sourceKind: SkillSourceKind + sourceLabel: string + unresolvedPath: string + resolvedPath: string | null + physicalIdentity: string | null + topology: SkillInstallationTopology + status: SkillFreshnessStatus + installedReleaseRevision: number | null + installedAppVersion: string | null + currentReleaseRevision: number + currentPackageDigest: string + currentAppVersion: string + observedPackageDigest: string | null + errorCategory: string | null +} + +export type SkillFreshnessInventory = { + schemaVersion: 1 + installations: SkillFreshnessInstallation[] + eligibleUpdateNames: string[] + scannedAt: number +} + +export function buildTargetedSkillUpdateCommand(names: readonly string[]): string | null { + const canonicalNames = [...new Set(names)].sort((left, right) => left.localeCompare(right, 'en')) + // Why: names become editable shell input. Official manifests use this + // restricted package-name grammar so no entry can introduce shell syntax. + if (canonicalNames.some((name) => !/^[a-z0-9][a-z0-9._-]*$/.test(name))) { + return null + } + return canonicalNames.length > 0 ? `npx skills update ${canonicalNames.join(' ')} --global` : null +} diff --git a/src/shared/terminal-tab-close.ts b/src/shared/terminal-tab-close.ts new file mode 100644 index 00000000000..55186d8046c --- /dev/null +++ b/src/shared/terminal-tab-close.ts @@ -0,0 +1,9 @@ +export type TerminalTabCloseRequest = { + requestId: string + tabId: string +} + +export type TerminalTabCloseResponse = { + requestId: string + error?: string +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 1c337a8db8d..3de8e943d16 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1606,6 +1606,7 @@ export type LinearIssue = { workspaceName?: string identifier: string title: string + branchName?: string description?: string url: string state: { @@ -1862,6 +1863,7 @@ export type { } from './gitlab-types' export type { + JiraAuthType, JiraComment, JiraConnectArgs, JiraConnectionStatus, @@ -2597,6 +2599,9 @@ export type GlobalSettings = { * system tray instead of quitting Orca; off keeps the default quit-on-close. * The tray icon itself is always present on Windows regardless of this flag. */ minimizeToTrayOnClose?: boolean + /** Why: macOS keeps Orca running after its last window closes, so this + * controls the additive menu-bar entry without changing Dock behavior. */ + showMenuBarIcon?: boolean /** Why: Windows terminals conventionally use right-click as a paste gesture, * while macOS/Linux default to their existing context menu behavior. */ terminalRightClickToPaste: boolean @@ -2870,6 +2875,9 @@ export type GlobalSettings = { /** Why: disabling must persist so startup does not reinstall global agent * hook entries right after the user removes them from Settings or CLI. */ agentStatusHooksEnabled: boolean + /** Dismissed freshness tuples grant no write authority; they only keep the + * same exact official placement/revision from nudging more than once. */ + dismissedSkillFreshnessNudges?: string[] /** Why: generated tab titles are semantic but subjective, so they stay opt-in * and manual renames remain the stronger user intent. */ tabAutoGenerateTitle: boolean diff --git a/src/shared/work-items.ts b/src/shared/work-items.ts index 848cde95dae..0a770058cc4 100644 --- a/src/shared/work-items.ts +++ b/src/shared/work-items.ts @@ -26,6 +26,13 @@ export function isGitHubWorkItemsSshRemoteRequiredError(error: unknown): boolean return message.includes(GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE) } +// Why: generic over item shape for the same cross-caller reasons as +// sortWorkItemsByUpdatedAt. Sorting by number descending matches GitHub's +// default Issues view (newest issue number first). +export function sortWorkItemsByNumber(items: T[]): T[] { + return [...items].sort((left, right) => right.number - left.number) +} + // Why: generic over the item shape because main-process callers emit items // without repoId (stamped by the renderer after IPC), while renderer callers // carry the full GitHubWorkItem. Both share only the updatedAt field needed diff --git a/src/shared/workspace-scope.ts b/src/shared/workspace-scope.ts index 28965722671..6aed11b6e2d 100644 --- a/src/shared/workspace-scope.ts +++ b/src/shared/workspace-scope.ts @@ -29,3 +29,19 @@ export function parseWorkspaceKey(value: string): WorkspaceScope | null { export function isWorkspaceKey(value: string): value is WorkspaceKey { return parseWorkspaceKey(value) !== null } + +// Why: folder workspaces are tracked by the scoped active key, while older +// worktree-only paths still read activeWorktreeId. +export function getActiveSidebarWorkspaceId( + activeWorkspaceKey: string | null, + activeWorktreeId: string | null +): string | null { + const scope = activeWorkspaceKey ? parseWorkspaceKey(activeWorkspaceKey) : null + if (scope?.type === 'folder') { + return folderWorkspaceKey(scope.folderWorkspaceId) + } + if (scope?.type === 'worktree') { + return scope.worktreeId + } + return activeWorktreeId +} diff --git a/src/shared/workspace-session-terminal-tab-close.test.ts b/src/shared/workspace-session-terminal-tab-close.test.ts new file mode 100644 index 00000000000..66bb3d5dd63 --- /dev/null +++ b/src/shared/workspace-session-terminal-tab-close.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, it } from 'vitest' +import { getDefaultWorkspaceSession } from './constants' +import type { Tab, TerminalTab, WorkspaceSessionState } from './types' +import { closeTerminalTabInWorkspaceSession } from './workspace-session-terminal-tab-close' + +const WORKTREE_ID = 'worktree-1' + +function terminalTab(id: string, ptyId: string | null, isPinned = false): TerminalTab { + return { + id, + ptyId, + worktreeId: WORKTREE_ID, + title: id, + customTitle: null, + color: null, + isPinned, + sortOrder: 0, + createdAt: 1 + } +} + +function unifiedTab( + id: string, + entityId: string, + contentType: Tab['contentType'], + groupId = 'group-1', + isPinned = false +): Tab { + return { + id, + entityId, + groupId, + worktreeId: WORKTREE_ID, + contentType, + label: id, + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1, + isPinned + } +} + +function session(overrides: Partial = {}): WorkspaceSessionState { + return { + ...getDefaultWorkspaceSession(), + activeWorktreeId: WORKTREE_ID, + activeTabId: 'terminal-1', + tabsByWorktree: { + [WORKTREE_ID]: [terminalTab('terminal-1', 'pty-left')] + }, + terminalLayoutsByTabId: { + 'terminal-1': { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: 'leaf-left' }, + second: { type: 'leaf', leafId: 'leaf-right' } + }, + activeLeafId: 'leaf-left', + expandedLeafId: null, + ptyIdsByLeafId: { 'leaf-left': 'pty-left', 'leaf-right': 'pty-right' } + } + }, + activeTabIdByWorktree: { [WORKTREE_ID]: 'terminal-1' }, + unifiedTabs: { + [WORKTREE_ID]: [unifiedTab('terminal-1', 'terminal-1', 'terminal')] + }, + tabGroups: { + [WORKTREE_ID]: [ + { + id: 'group-1', + worktreeId: WORKTREE_ID, + activeTabId: 'terminal-1', + tabOrder: ['terminal-1'], + recentTabIds: ['terminal-1'] + } + ] + }, + tabGroupLayouts: { [WORKTREE_ID]: { type: 'leaf', groupId: 'group-1' } }, + activeGroupIdByWorktree: { [WORKTREE_ID]: 'group-1' }, + defaultTerminalTabsAppliedByWorktreeId: { [WORKTREE_ID]: true }, + ...overrides + } +} + +describe('closeTerminalTabInWorkspaceSession', () => { + it('atomically removes a dormant split tab and returns every exact PTY', () => { + const result = closeTerminalTabInWorkspaceSession( + session({ + remoteSessionIdsByTabId: { 'terminal-1': 'pty-remote' }, + sleepingAgentSessionsByPaneKey: { + 'terminal-1:leaf-left': { + paneKey: 'terminal-1:leaf-left', + tabId: 'terminal-1', + worktreeId: WORKTREE_ID, + agent: 'codex', + providerSession: { key: 'session_id', id: 'session-1' }, + prompt: 'continue', + state: 'working', + capturedAt: 1, + updatedAt: 1 + } + } + }), + WORKTREE_ID, + 'terminal-1' + ) + + expect(result).toMatchObject({ closed: true, pinned: false }) + expect(result.ptyIdsToKill.sort()).toEqual(['pty-left', 'pty-remote', 'pty-right']) + expect(result.session.tabsByWorktree[WORKTREE_ID]).toEqual([]) + expect(result.session.terminalLayoutsByTabId['terminal-1']).toBeUndefined() + expect(result.session.remoteSessionIdsByTabId?.['terminal-1']).toBeUndefined() + expect(result.session.sleepingAgentSessionsByPaneKey).toEqual({}) + expect(result.session.defaultTerminalTabsAppliedByWorktreeId?.[WORKTREE_ID]).toBe(true) + }) + + it('does not kill a PTY still owned by another terminal tab', () => { + const current = session({ + tabsByWorktree: { + [WORKTREE_ID]: [ + terminalTab('terminal-1', 'shared-pty'), + terminalTab('terminal-2', 'shared-pty') + ] + }, + terminalLayoutsByTabId: { + 'terminal-1': { + root: { type: 'leaf', leafId: 'leaf-1' }, + activeLeafId: 'leaf-1', + expandedLeafId: null, + ptyIdsByLeafId: { 'leaf-1': 'shared-pty' } + }, + 'terminal-2': { + root: { type: 'leaf', leafId: 'leaf-2' }, + activeLeafId: 'leaf-2', + expandedLeafId: null, + ptyIdsByLeafId: { 'leaf-2': 'shared-pty' } + } + } + }) + + const result = closeTerminalTabInWorkspaceSession(current, WORKTREE_ID, 'terminal-1') + + expect(result.ptyIdsToKill).toEqual([]) + expect(result.session.tabsByWorktree[WORKTREE_ID]?.map((tab) => tab.id)).toEqual(['terminal-2']) + }) + + it('lands on the active browser survivor instead of an empty terminal group', () => { + const current = session({ + browserTabsByWorktree: { + [WORKTREE_ID]: [ + { + id: 'browser-1', + worktreeId: WORKTREE_ID, + url: 'https://example.com', + title: 'Docs', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 1 + } + ] + }, + activeBrowserTabIdByWorktree: { [WORKTREE_ID]: 'browser-1' }, + unifiedTabs: { + [WORKTREE_ID]: [ + unifiedTab('terminal-1', 'terminal-1', 'terminal'), + unifiedTab('browser-1', 'browser-1', 'browser') + ] + }, + tabGroups: { + [WORKTREE_ID]: [ + { + id: 'group-1', + worktreeId: WORKTREE_ID, + activeTabId: 'terminal-1', + tabOrder: ['terminal-1', 'browser-1'], + recentTabIds: ['browser-1', 'terminal-1'] + } + ] + } + }) + + const result = closeTerminalTabInWorkspaceSession(current, WORKTREE_ID, 'terminal-1') + + expect(result.session.tabGroups?.[WORKTREE_ID]?.[0]?.activeTabId).toBe('browser-1') + expect(result.session.activeTabTypeByWorktree?.[WORKTREE_ID]).toBe('browser') + expect(result.session.activeBrowserTabIdByWorktree?.[WORKTREE_ID]).toBe('browser-1') + expect(result.session.activeWorktreeId).toBe(WORKTREE_ID) + }) + + it('rejects pinned tabs without mutating the session', () => { + const current = session({ + tabsByWorktree: { [WORKTREE_ID]: [terminalTab('terminal-1', 'pty-left', true)] } + }) + + const result = closeTerminalTabInWorkspaceSession(current, WORKTREE_ID, 'terminal-1') + + expect(result).toEqual({ session: current, ptyIdsToKill: [], closed: false, pinned: true }) + }) + + it('has no bounded replay window after more than 32 closes', () => { + let current = getDefaultWorkspaceSession() + for (let index = 0; index < 40; index += 1) { + const id = `terminal-${index}` + current = { + ...current, + tabsByWorktree: { + ...current.tabsByWorktree, + [WORKTREE_ID]: [terminalTab(id, `pty-${index}`)] + }, + terminalLayoutsByTabId: { + ...current.terminalLayoutsByTabId, + [id]: { + root: { type: 'leaf', leafId: `leaf-${index}` }, + activeLeafId: `leaf-${index}`, + expandedLeafId: null, + ptyIdsByLeafId: { [`leaf-${index}`]: `pty-${index}` } + } + } + } + current = closeTerminalTabInWorkspaceSession(current, WORKTREE_ID, id).session + } + + expect(current.tabsByWorktree[WORKTREE_ID]).toEqual([]) + expect(current.terminalLayoutsByTabId).toEqual({}) + }) +}) diff --git a/src/shared/workspace-session-terminal-tab-close.ts b/src/shared/workspace-session-terminal-tab-close.ts new file mode 100644 index 00000000000..2b0b17a9d3a --- /dev/null +++ b/src/shared/workspace-session-terminal-tab-close.ts @@ -0,0 +1,276 @@ +import type { + Tab, + TabGroup, + TabGroupLayoutNode, + WorkspaceSessionState, + WorkspaceVisibleTabType +} from './types' + +export type WorkspaceSessionTerminalTabCloseResult = { + session: WorkspaceSessionState + ptyIdsToKill: string[] + closed: boolean + pinned: boolean +} + +function pickNextActiveTab(group: TabGroup, closingIds: ReadonlySet): string | null { + const remaining = group.tabOrder.filter((id) => !closingIds.has(id)) + for (let index = (group.recentTabIds?.length ?? 0) - 1; index >= 0; index -= 1) { + const id = group.recentTabIds![index] + if (remaining.includes(id)) { + return id + } + } + const closingIndex = group.tabOrder.findIndex((id) => closingIds.has(id)) + return ( + remaining.find((id) => group.tabOrder.indexOf(id) > closingIndex) ?? remaining.at(-1) ?? null + ) +} + +function pruneGroupLayout( + node: TabGroupLayoutNode | undefined, + validGroupIds: ReadonlySet +): TabGroupLayoutNode | undefined { + if (!node) { + return undefined + } + if (node.type === 'leaf') { + return validGroupIds.has(node.groupId) ? node : undefined + } + const first = pruneGroupLayout(node.first, validGroupIds) + const second = pruneGroupLayout(node.second, validGroupIds) + if (!first) { + return second + } + if (!second) { + return first + } + return { ...node, first, second } +} + +function collectTabPtyIds( + session: WorkspaceSessionState, + tabId: string, + rowPtyId?: string | null +): Set { + const ids = new Set() + if (rowPtyId) { + ids.add(rowPtyId) + } + for (const ptyId of Object.values(session.terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId ?? {})) { + ids.add(ptyId) + } + const remoteSessionId = session.remoteSessionIdsByTabId?.[tabId] + if (remoteSessionId) { + ids.add(remoteSessionId) + } + return ids +} + +function findUnifiedTerminalTabs( + session: WorkspaceSessionState, + worktreeId: string, + tabId: string +): Tab[] { + return (session.unifiedTabs?.[worktreeId] ?? []).filter( + (tab) => tab.contentType === 'terminal' && (tab.entityId === tabId || tab.id === tabId) + ) +} + +function deriveActiveSurface( + session: WorkspaceSessionState, + worktreeId: string, + tabs: Tab[], + groups: TabGroup[], + activeGroupId: string | undefined +): { + terminalTabId: string | null + browserTabId: string | null + fileId: string | null + type: WorkspaceVisibleTabType +} { + const activeGroup = groups.find((group) => group.id === activeGroupId) ?? groups[0] ?? null + const activeUnified = activeGroup?.activeTabId + ? (tabs.find((tab) => tab.id === activeGroup.activeTabId && tab.groupId === activeGroup.id) ?? + null) + : null + const terminalTabs = session.tabsByWorktree[worktreeId] ?? [] + const browsers = session.browserTabsByWorktree?.[worktreeId] ?? [] + const files = session.openFilesByWorktree?.[worktreeId] ?? [] + const priorTerminal = session.activeTabIdByWorktree?.[worktreeId] + const terminalFallback = terminalTabs.some((tab) => tab.id === priorTerminal) + ? (priorTerminal ?? null) + : (terminalTabs[0]?.id ?? null) + const priorBrowser = session.activeBrowserTabIdByWorktree?.[worktreeId] + const browserFallback = browsers.some((tab) => tab.id === priorBrowser) + ? (priorBrowser ?? null) + : (browsers[0]?.id ?? null) + const priorFile = session.activeFileIdByWorktree?.[worktreeId] + const fileFallback = files.some((file) => file.filePath === priorFile) + ? (priorFile ?? null) + : (files[0]?.filePath ?? null) + + if (activeUnified?.contentType === 'terminal') { + return { + terminalTabId: activeUnified.entityId, + browserTabId: browserFallback, + fileId: fileFallback, + type: 'terminal' + } + } + if (activeUnified?.contentType === 'browser') { + return { + terminalTabId: terminalFallback, + browserTabId: activeUnified.entityId, + fileId: fileFallback, + type: 'browser' + } + } + if (activeUnified) { + return { + terminalTabId: terminalFallback, + browserTabId: browserFallback, + fileId: activeUnified.entityId, + type: activeUnified.contentType === 'simulator' ? 'simulator' : 'editor' + } + } + if (fileFallback) { + return { + terminalTabId: terminalFallback, + browserTabId: browserFallback, + fileId: fileFallback, + type: 'editor' + } + } + if (browserFallback) { + return { + terminalTabId: terminalFallback, + browserTabId: browserFallback, + fileId: null, + type: 'browser' + } + } + return { terminalTabId: terminalFallback, browserTabId: null, fileId: null, type: 'terminal' } +} + +export function closeTerminalTabInWorkspaceSession( + session: WorkspaceSessionState, + worktreeId: string, + tabId: string +): WorkspaceSessionTerminalTabCloseResult { + const terminalRow = session.tabsByWorktree[worktreeId]?.find((tab) => tab.id === tabId) + const unifiedTerminalTabs = findUnifiedTerminalTabs(session, worktreeId, tabId) + if (!terminalRow && unifiedTerminalTabs.length === 0) { + return { session, ptyIdsToKill: [], closed: false, pinned: false } + } + if (terminalRow?.isPinned || unifiedTerminalTabs.some((tab) => tab.isPinned)) { + return { session, ptyIdsToKill: [], closed: false, pinned: true } + } + + const closingPtyIds = collectTabPtyIds(session, tabId, terminalRow?.ptyId) + const otherPtyIds = new Set() + for (const tabs of Object.values(session.tabsByWorktree)) { + for (const tab of tabs) { + if (tab.id !== tabId) { + for (const ptyId of collectTabPtyIds(session, tab.id, tab.ptyId)) { + otherPtyIds.add(ptyId) + } + } + } + } + const ptyIdsToKill = [...closingPtyIds].filter((ptyId) => !otherPtyIds.has(ptyId)) + const closedVisibleIds = new Set(unifiedTerminalTabs.map((tab) => tab.id)) + closedVisibleIds.add(tabId) + const nextTabs = (session.unifiedTabs?.[worktreeId] ?? []).filter( + (tab) => !closedVisibleIds.has(tab.id) + ) + const nextGroups = (session.tabGroups?.[worktreeId] ?? []) + .map((group) => { + const tabOrder = group.tabOrder.filter((id) => !closedVisibleIds.has(id)) + const activeTabId = closedVisibleIds.has(group.activeTabId ?? '') + ? pickNextActiveTab(group, closedVisibleIds) + : group.activeTabId && tabOrder.includes(group.activeTabId) + ? group.activeTabId + : (tabOrder[0] ?? null) + return { + ...group, + tabOrder, + activeTabId, + recentTabIds: group.recentTabIds?.filter((id) => tabOrder.includes(id)) + } + }) + .filter((group) => group.tabOrder.length > 0) + const validGroupIds = new Set(nextGroups.map((group) => group.id)) + const priorActiveGroupId = session.activeGroupIdByWorktree?.[worktreeId] + const nextActiveGroupId = validGroupIds.has(priorActiveGroupId ?? '') + ? priorActiveGroupId + : nextGroups[0]?.id + const nextLayout = pruneGroupLayout(session.tabGroupLayouts?.[worktreeId], validGroupIds) + + const next: WorkspaceSessionState = { + ...session, + tabsByWorktree: { + ...session.tabsByWorktree, + [worktreeId]: (session.tabsByWorktree[worktreeId] ?? []).filter((tab) => tab.id !== tabId) + }, + terminalLayoutsByTabId: { ...session.terminalLayoutsByTabId }, + unifiedTabs: { ...session.unifiedTabs, [worktreeId]: nextTabs }, + tabGroups: { ...session.tabGroups, [worktreeId]: nextGroups }, + tabGroupLayouts: { ...session.tabGroupLayouts }, + activeGroupIdByWorktree: { ...session.activeGroupIdByWorktree }, + remoteSessionIdsByTabId: { ...session.remoteSessionIdsByTabId }, + sleepingAgentSessionsByPaneKey: { ...session.sleepingAgentSessionsByPaneKey } + } + delete next.terminalLayoutsByTabId[tabId] + delete next.remoteSessionIdsByTabId![tabId] + if (nextLayout) { + next.tabGroupLayouts![worktreeId] = nextLayout + } else { + delete next.tabGroupLayouts![worktreeId] + } + if (nextActiveGroupId) { + next.activeGroupIdByWorktree![worktreeId] = nextActiveGroupId + } else { + delete next.activeGroupIdByWorktree![worktreeId] + } + for (const [paneKey, record] of Object.entries(next.sleepingAgentSessionsByPaneKey ?? {})) { + if (paneKey.startsWith(`${tabId}:`) || record.tabId === tabId) { + delete next.sleepingAgentSessionsByPaneKey![paneKey] + } + } + const surface = deriveActiveSurface(next, worktreeId, nextTabs, nextGroups, nextActiveGroupId) + next.activeTabIdByWorktree = { + ...session.activeTabIdByWorktree, + [worktreeId]: surface.terminalTabId + } + next.activeBrowserTabIdByWorktree = { + ...session.activeBrowserTabIdByWorktree, + [worktreeId]: surface.browserTabId + } + next.activeFileIdByWorktree = { + ...session.activeFileIdByWorktree, + [worktreeId]: surface.fileId + } + next.activeTabTypeByWorktree = { + ...session.activeTabTypeByWorktree, + [worktreeId]: surface.type + } + if (session.activeWorktreeId === worktreeId) { + next.activeTabId = surface.terminalTabId + const hasSurface = + nextTabs.length > 0 || + (next.tabsByWorktree[worktreeId]?.length ?? 0) > 0 || + (next.browserTabsByWorktree?.[worktreeId]?.length ?? 0) > 0 || + (next.openFilesByWorktree?.[worktreeId]?.length ?? 0) > 0 + if (!hasSurface) { + next.activeWorktreeId = null + next.activeWorkspaceKey = null + } + } + if ((next.tabsByWorktree[worktreeId]?.length ?? 0) === 0) { + next.activeWorktreeIdsOnShutdown = next.activeWorktreeIdsOnShutdown?.filter( + (id) => id !== worktreeId + ) + } + return { session: next, ptyIdsToKill, closed: true, pinned: false } +} diff --git a/tests/e2e/browser-tab.spec.ts b/tests/e2e/browser-tab.spec.ts index b61ca1541bc..1370fae41a0 100644 --- a/tests/e2e/browser-tab.spec.ts +++ b/tests/e2e/browser-tab.spec.ts @@ -121,6 +121,84 @@ async function startBrowserFormServer(): Promise<{ } } +async function startBrowserLinkServer(): Promise<{ + sourceUrl: string + close: () => Promise +}> { + const server = createServer((request, response) => { + const origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}` + const pathname = new URL(request.url ?? '/', origin).pathname + response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }) + if (pathname === '/destination') { + response.end( + `Linked destinationDestination Return` + ) + return + } + if (pathname === '/frame-destination') { + response.end( + `Frame destinationFrame destination Return` + ) + return + } + if (pathname === '/frame-modifier-destination') { + response.end( + 'Frame modifier destinationFrame modifier destination' + ) + return + } + if (pathname === '/frame-middle-destination') { + response.end( + 'Frame middle destinationFrame middle destination' + ) + return + } + if (pathname === '/frame') { + response.end( + `Open frame destinationOpen frame modifier destinationOpen frame middle destination` + ) + return + } + if (pathname === '/modifier-destination') { + response.end( + 'Modifier destinationModifier destination' + ) + return + } + if (pathname === '/middle-destination') { + response.end( + 'Middle-click destinationMiddle-click destination' + ) + return + } + response.end(` + + + Source page + + Open destination + Open with modifier + Open with middle click + Handle in page + + + + + `) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const port = (server.address() as AddressInfo).port + return { + sourceUrl: `http://127.0.0.1:${port}/source`, + close: () => closeServer(server) + } +} + async function closeServer(server: Server): Promise { await new Promise((resolve, reject) => server.close((error) => { @@ -133,6 +211,88 @@ async function closeServer(server: Server): Promise { ) } +async function clickBrowserLink( + page: Parameters[0], + browserTabId: string, + selector: string, + options: { + modifiers?: ('meta' | 'control')[] + button?: 'left' | 'middle' + frameSelector?: string + } = {} +): Promise { + await page.evaluate( + async ({ targetBrowserTabId, targetSelector, inputModifiers, button, frameSelector }) => { + const slot = [...document.querySelectorAll('[data-browser-overlay-tab-id]')].find( + (candidate) => candidate.getAttribute('data-browser-overlay-tab-id') === targetBrowserTabId + ) + const webview = slot?.querySelector('webview') as Electron.WebviewTag | null + if (!webview) { + throw new Error(`Missing webview for browser tab ${targetBrowserTabId}`) + } + const point = (await webview.executeJavaScript(`(async () => { + const deadline = Date.now() + 5000 + while (Date.now() < deadline) { + const frame = ${JSON.stringify(frameSelector)} + ? document.querySelector(${JSON.stringify(frameSelector)}) + : null + const root = frame?.contentDocument ?? document + const linkRect = root.querySelector(${JSON.stringify(targetSelector)})?.getBoundingClientRect() + if (linkRect) { + const frameRect = frame?.getBoundingClientRect() + return { + x: (frameRect?.left ?? 0) + linkRect.left + linkRect.width / 2, + y: (frameRect?.top ?? 0) + linkRect.top + linkRect.height / 2 + } + } + await new Promise((resolve) => setTimeout(resolve, 25)) + } + return null + })()`)) as { x: number; y: number } | null + if (!point) { + throw new Error(`Missing browser link ${targetSelector}`) + } + await webview.sendInputEvent({ type: 'mouseMove', modifiers: inputModifiers, ...point }) + await webview.sendInputEvent({ + type: 'mouseDown', + button, + clickCount: 1, + modifiers: inputModifiers, + ...point + }) + await webview.sendInputEvent({ + type: 'mouseUp', + button, + clickCount: 1, + modifiers: inputModifiers, + ...point + }) + }, + { + targetBrowserTabId: browserTabId, + targetSelector: selector, + inputModifiers: options.modifiers ?? [], + button: options.button ?? 'left', + frameSelector: options.frameSelector ?? null + } + ) +} + +async function expectBrowserTabActive( + page: Parameters[0], + title: string +): Promise { + const resolveTabId = (): Promise => + page.locator('[data-tab-id]').evaluateAll((tabs, exactTitle) => { + const tab = tabs.find((candidate) => candidate.textContent?.trim() === exactTitle) + return tab?.getAttribute('data-tab-id') ?? null + }, title) + await expect.poll(resolveTabId, { timeout: 10_000 }).not.toBeNull() + const tabId = await resolveTabId() + expect(tabId).toBeTruthy() + await expect(page.locator(`[data-browser-overlay-tab-id="${tabId}"]`)).toHaveCSS('opacity', '1') +} + async function readBrowserInputValue( page: Parameters[0], browserTabId: string @@ -301,6 +461,80 @@ test.describe('Browser Tab', () => { } }) + test('plain links stay current while explicit new-tab gestures activate Orca tabs', async ({ + electronApp, + orcaPage + }) => { + const linkServer = await startBrowserLinkServer() + try { + const worktreeId = (await getActiveWorktreeId(orcaPage))! + const sourceTab = await createBrowserTab( + orcaPage, + worktreeId, + linkServer.sourceUrl, + 'Source page' + ) + expect(sourceTab?.id).toBeTruthy() + + const baseWindowCount = await electronApp.evaluate( + ({ BaseWindow }) => BaseWindow.getAllWindows().length + ) + const baseTabCount = await orcaPage.locator('[data-tab-id]').count() + await clickBrowserLink(orcaPage, sourceTab!.id, '#external-link') + + const sourceTabLocator = orcaPage.locator(`[data-tab-id="${sourceTab!.id}"]`) + await expect(sourceTabLocator).toContainText('Linked destination', { timeout: 10_000 }) + await expect(orcaPage.locator('[data-tab-id]')).toHaveCount(baseTabCount) + + await clickBrowserLink(orcaPage, sourceTab!.id, '#return-link') + await expect(sourceTabLocator).toContainText('Source page', { timeout: 10_000 }) + await clickBrowserLink(orcaPage, sourceTab!.id, '#frame-link', { + frameSelector: '#link-frame' + }) + await expect(sourceTabLocator).toContainText('Frame destination', { timeout: 10_000 }) + await expect(orcaPage.locator('[data-tab-id]')).toHaveCount(baseTabCount) + + await clickBrowserLink(orcaPage, sourceTab!.id, '#return-link') + await expect(sourceTabLocator).toContainText('Source page', { timeout: 10_000 }) + + await clickBrowserLink(orcaPage, sourceTab!.id, '#frame-modifier-link', { + frameSelector: '#link-frame', + modifiers: process.platform === 'darwin' ? ['meta'] : ['control'] + }) + await expectBrowserTabActive(orcaPage, 'Frame modifier destination') + await switchToBrowserTab(orcaPage, worktreeId, sourceTab!.id) + await clickBrowserLink(orcaPage, sourceTab!.id, '#frame-middle-link', { + button: 'middle', + frameSelector: '#link-frame' + }) + await expectBrowserTabActive(orcaPage, 'Frame middle destination') + await switchToBrowserTab(orcaPage, worktreeId, sourceTab!.id) + + await clickBrowserLink(orcaPage, sourceTab!.id, '#modifier-link', { + modifiers: process.platform === 'darwin' ? ['meta'] : ['control'] + }) + await expectBrowserTabActive(orcaPage, 'Modifier destination') + await switchToBrowserTab(orcaPage, worktreeId, sourceTab!.id) + + const tabCountBeforeCancelledClick = await orcaPage.locator('[data-tab-id]').count() + await clickBrowserLink(orcaPage, sourceTab!.id, '#cancelled-link') + await expect( + orcaPage.locator('[data-tab-id]').filter({ hasText: 'Click handled in page' }) + ).toBeVisible({ timeout: 10_000 }) + await expect(orcaPage.locator('[data-tab-id]')).toHaveCount(tabCountBeforeCancelledClick) + + await clickBrowserLink(orcaPage, sourceTab!.id, '#middle-link', { button: 'middle' }) + await expectBrowserTabActive(orcaPage, 'Middle-click destination') + await expect + .poll(() => electronApp.evaluate(({ BaseWindow }) => BaseWindow.getAllWindows().length), { + timeout: 5_000 + }) + .toBe(baseWindowCount) + } finally { + await linkServer.close() + } + }) + /** * User Prompt: * - Browser works and also retains state when switching tabs etc. diff --git a/tests/e2e/terminal-codex-skill-preview-artifact-repro.spec.ts b/tests/e2e/terminal-codex-skill-preview-artifact-repro.spec.ts index 1365e54fa90..a0ced05497f 100644 --- a/tests/e2e/terminal-codex-skill-preview-artifact-repro.spec.ts +++ b/tests/e2e/terminal-codex-skill-preview-artifact-repro.spec.ts @@ -32,7 +32,7 @@ const CODEX_TRUST_PROMPT_RE = const CODEX_UPDATE_PROMPT_RE = /update available|install update|Skip for now|Skip until next/i const CODEX_SKILL_PREVIEW_RE = /Press enter to insert|esc to close|electron|orca-cli|orca-emulator/i const SETUP_PANE_ACTIVITY_RE = /install-orca-skills|pnpm|Progress:|Packages:|Lockfile/i -const CLEAN_SKILL_ROW_RE = /^ [A-Za-z][A-Za-z0-9 -]{1,32}\s+\[Skill\]\s/ +const CLEAN_SKILL_ROW_RE = /^ [A-Za-z][A-Za-z0-9 .-]{1,32}\s+\[Skill\]\s/ const CODEX_READY_SETTLE_MS = 3_500 const SETUP_CHANGES_AFTER_PREVIEW = 3 @@ -44,6 +44,12 @@ type PaneDescriptor = { rect: { x: number; y: number; width: number; height: number } cols: number rows: number + proposed: { cols: number; rows: number } | null + appliedPtySize: { cols: number; rows: number } | null + viewportY: number + baseY: number + isUserScrolling: boolean | null + screenToPaneGap: number | null hasWebgl: boolean } @@ -259,7 +265,7 @@ async function forceTerminalWebgl(page: Page): Promise { } async function describeActiveTerminalPanes(page: Page): Promise { - return page.evaluate(() => { + return page.evaluate(async () => { const state = window.__store?.getState() const worktreeId = state?.activeWorktreeId const tabId = @@ -274,29 +280,54 @@ async function describeActiveTerminalPanes(page: Page): Promise { - const aRect = a.container.getBoundingClientRect() - const bRect = b.container.getBoundingClientRect() - return aRect.x - bRect.x || aRect.y - bRect.y - }) - .map((pane) => { - if (!pane.container.dataset.ptyId) { - throw new Error(`Terminal pane ${pane.id} has no PTY binding`) - } - const rect = pane.container.getBoundingClientRect() - const rendering = diagnostics.find((diagnostic) => diagnostic.paneId === pane.id) - return { - tabId, - paneId: pane.id, - leafId: pane.leafId, - ptyId: pane.container.dataset.ptyId, - rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, - cols: pane.terminal.cols, - rows: pane.terminal.rows, - hasWebgl: rendering?.hasWebgl ?? false - } - }) + return Promise.all( + [...panes] + .sort((a, b) => { + const aRect = a.container.getBoundingClientRect() + const bRect = b.container.getBoundingClientRect() + return aRect.x - bRect.x || aRect.y - bRect.y + }) + .map(async (pane) => { + const ptyId = pane.container.dataset.ptyId + if (!ptyId) { + throw new Error(`Terminal pane ${pane.id} has no PTY binding`) + } + const rect = pane.container.getBoundingClientRect() + const screenRect = pane.container + .querySelector('.xterm-screen') + ?.getBoundingClientRect() + const rendering = diagnostics.find((diagnostic) => diagnostic.paneId === pane.id) + let proposed: { cols: number; rows: number } | null = null + try { + proposed = pane.fitAddon.proposeDimensions() ?? null + } catch { + proposed = null + } + const appliedPtySize = await window.api.pty.getSize(ptyId).catch(() => null) + const terminalCore = pane.terminal as typeof pane.terminal & { + _core?: { _bufferService?: { isUserScrolling?: boolean } } + } + return { + tabId, + paneId: pane.id, + leafId: pane.leafId, + ptyId, + rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, + cols: pane.terminal.cols, + rows: pane.terminal.rows, + proposed, + appliedPtySize, + viewportY: pane.terminal.buffer.active.viewportY, + baseY: pane.terminal.buffer.active.baseY, + isUserScrolling: + typeof terminalCore._core?._bufferService?.isUserScrolling === 'boolean' + ? terminalCore._core._bufferService.isUserScrolling + : null, + screenToPaneGap: screenRect ? rect.right - screenRect.right : null, + hasWebgl: rendering?.hasWebgl ?? false + } + }) + ) }) } @@ -490,6 +521,10 @@ async function captureClickEvidence( const beforeWindowPath = persistEvidenceFile('full-window-before-click.png', beforeFullPage) const afterPanePath = persistEvidenceFile('left-pane-after-click.png', afterPane) const bufferPath = persistEvidenceFile('left-pane-buffer.txt', beforeContent) + const metricsPath = persistEvidenceFile( + 'left-pane-metrics.json', + `${JSON.stringify(pane, null, 2)}\n` + ) await testInfo.attach('codex-skill-preview-left-pane-before-click', { body: beforePane, @@ -509,7 +544,13 @@ async function captureClickEvidence( }) testInfo.annotations.push({ type: 'codex-skill-preview-evidence-files', - description: JSON.stringify({ beforePanePath, beforeWindowPath, afterPanePath, bufferPath }) + description: JSON.stringify({ + beforePanePath, + beforeWindowPath, + afterPanePath, + bufferPath, + metricsPath + }) }) return { @@ -563,6 +604,9 @@ test.describe('Codex skill preview terminal artifact repro @headful', () => { const rightPane = await getRightTerminalPane(orcaPage) expect(leftPane.hasWebgl).toBe(true) expect(rightPane.hasWebgl).toBe(true) + expect(leftPane.proposed).toEqual({ cols: leftPane.cols, rows: leftPane.rows }) + expect(leftPane.appliedPtySize).toEqual({ cols: leftPane.cols, rows: leftPane.rows }) + expect(leftPane.isUserScrolling).toBe(false) await waitForPaneContent( orcaPage, rightPane.tabId, diff --git a/tests/e2e/terminal-link-hover-after-worktree-return.spec.ts b/tests/e2e/terminal-link-hover-after-worktree-return.spec.ts new file mode 100644 index 00000000000..b0bcd1bc4b3 --- /dev/null +++ b/tests/e2e/terminal-link-hover-after-worktree-return.spec.ts @@ -0,0 +1,332 @@ +import { randomUUID } from 'node:crypto' +import { rmSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import type { Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { + ensureTerminalVisible, + getAllWorktreeIds, + switchToWorktree, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { + getTerminalContent, + sendToTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager +} from './helpers/terminal' +import { waitForPtyShellEcho } from './terminal-pty-readiness' + +/** + * Repro for: a clickable terminal link (file path, URL, …) becomes dead after + * switching to another worktree and returning, until the user scrolls the + * terminal a little. + * + * xterm's Linkifier only re-runs link providers on mousemove when the hovered + * buffer cell changes vs its `_lastBufferCell` cache. Hiding the surface fires + * mouseleave (clearing the current link) but leaves that cache, so returning + * the pointer to the same cell short-circuits and the link is never + * re-established — `currentLink` stays null. File-path links are the worst case + * because their geometry click fallback does not compensate after reveal. + */ +type HoverProbe = { col: number; row: number; tabId: string } + +async function locateHoverProbe(page: Page, needle: string): Promise { + return page.evaluate((needle) => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId ?? null + const tabId = + state?.activeTabType === 'terminal' + ? (state?.activeTabId ?? null) + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!tabId || !pane) { + throw new Error('active terminal pane unavailable') + } + const terminal = pane.terminal + const buffer = terminal.buffer.active + let hit: { row: number; col: number } | null = null + for (let row = 0; row < terminal.rows; row += 1) { + const line = buffer.getLine(buffer.viewportY + row) + if (!line) { + continue + } + const idx = line.translateToString(true).indexOf(needle) + if (idx >= 0) { + hit = { row, col: idx } + break + } + } + if (!hit) { + throw new Error('link text not visible in terminal viewport') + } + const screen = terminal.element?.querySelector('.xterm-screen') + if (!screen) { + throw new Error('xterm-screen element unavailable') + } + // Aim at the middle of the link text so the pointer lands squarely inside + // the link range regardless of rounding. + return { + col: hit.col + Math.floor(needle.length / 2), + row: hit.row, + tabId + } + }, needle) +} + +/** + * Dispatch a hover mousemove at the probe coordinates and return the text of + * the link the linkifier considers active (or null). Callers poll this because + * Orca's file-path provider resolves link candidates asynchronously. + */ +async function hoverAndReadActiveLinkText(page: Page, probe: HoverProbe): Promise { + await page.evaluate(({ col, row, tabId }) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + const screen = pane?.terminal.element?.querySelector('.xterm-screen') + if (!pane || !screen) { + throw new Error('xterm-screen element unavailable') + } + const rect = screen.getBoundingClientRect() + const clientX = rect.left + (col + 0.5) * (rect.width / pane.terminal.cols) + const clientY = rect.top + (row + 0.5) * (rect.height / pane.terminal.rows) + screen.dispatchEvent( + new MouseEvent('mousemove', { bubbles: true, cancelable: true, clientX, clientY }) + ) + }, probe) + return page.evaluate(({ tabId }) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + const core = pane?.terminal as unknown as + | { _core?: { linkifier?: { currentLink?: { link?: { text?: string } } } } } + | undefined + return core?._core?.linkifier?.currentLink?.link?.text ?? null + }, probe) +} + +async function readTerminalCursor(page: Page, tabId: string): Promise { + return page.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + const screen = pane?.terminal.element?.querySelector('.xterm-screen') + return screen ? getComputedStyle(screen).cursor : null + }, tabId) +} + +async function isTerminalSurfaceVisible(page: Page, tabId: string): Promise { + return page.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + return Boolean(pane?.container.isConnected && pane.container.getClientRects().length > 0) + }, tabId) +} + +async function activateHoveredLink(page: Page, probe: HoverProbe): Promise { + await page.evaluate(({ col, row, tabId }) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + const screen = pane?.terminal.element?.querySelector('.xterm-screen') + if (!pane || !screen) { + throw new Error('xterm-screen element unavailable') + } + const rect = screen.getBoundingClientRect() + const clientX = rect.left + (col + 0.5) * (rect.width / pane.terminal.cols) + const clientY = rect.top + (row + 0.5) * (rect.height / pane.terminal.rows) + const isMac = navigator.userAgent.includes('Mac') + const modifier = { metaKey: isMac, ctrlKey: !isMac } + screen.dispatchEvent( + new MouseEvent('mousedown', { + bubbles: true, + cancelable: true, + button: 0, + buttons: 1, + clientX, + clientY, + ...modifier + }) + ) + screen.dispatchEvent( + new MouseEvent('mouseup', { + bubbles: true, + cancelable: true, + button: 0, + clientX, + clientY, + ...modifier + }) + ) + }, probe) +} + +async function dispatchScreenMouseLeave(page: Page, tabId: string): Promise { + await page.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + const screen = pane?.terminal.element?.querySelector('.xterm-screen') + // Mimics the pointer leaving as the surface hides on a worktree switch: + // clears the linkifier's current link but keeps its cell cache. + screen?.dispatchEvent(new MouseEvent('mouseleave', { bubbles: false, cancelable: true })) + }, tabId) +} + +async function activeWorktreePath(page: Page): Promise { + return page.evaluate(() => { + const state = window.__store?.getState() + const id = state?.activeWorktreeId + return ( + Object.values(state?.worktreesByRepo ?? {}) + .flat() + .find((w) => w.id === id)?.path ?? '' + ) + }) +} + +/** + * Drive the full repro and assert the link re-establishes on hover after + * returning to the worktree. `contains` accommodates the file provider's + * display text differing slightly from the raw echoed token. + */ +async function assertLinkRecoversAfterReturn( + page: Page, + args: { + firstWorktreeId: string + secondWorktreeId: string + needle: string + expectContains: string + } +): Promise { + const probe = await locateHoverProbe(page, args.needle) + + // Baseline: hovering establishes the link before any switch. + await expect + .poll(() => hoverAndReadActiveLinkText(page, probe), { + timeout: 5_000, + message: 'baseline hover never established the link' + }) + .toContain(args.expectContains) + + await dispatchScreenMouseLeave(page, probe.tabId) + await switchToWorktree(page, args.secondWorktreeId) + await waitForActiveTerminalManager(page, 30_000) + // Wait for React to commit the intermediate hidden surface; switching back + // before that commit would batch away the lifecycle transition under test. + await expect.poll(() => isTerminalSurfaceVisible(page, probe.tabId)).toBe(false) + + await switchToWorktree(page, args.firstWorktreeId) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) + await expect.poll(() => isTerminalSurfaceVisible(page, probe.tabId)).toBe(true) + + // Hover the SAME cell without scrolling. Pre-fix this never re-establishes + // the link (dead until a scroll); post-fix the reveal reset re-linkifies. + await expect + .poll(() => hoverAndReadActiveLinkText(page, probe), { + timeout: 5_000, + message: 'link did not re-establish on hover after returning to the worktree' + }) + .toContain(args.expectContains) + + // The pointer cursor is the user-visible hover affordance; currentLink is + // also checked above because it is the backing state xterm requires to click. + await expect.poll(() => readTerminalCursor(page, probe.tabId)).toBe('pointer') + return probe +} + +test.describe('Terminal link hover after worktree return', () => { + test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + }) + + test('re-establishes a file-path link on hover after switching worktrees and back', async ({ + orcaPage + }) => { + const firstWorktreeId = await waitForActiveWorktree(orcaPage) + const secondWorktreeId = (await getAllWorktreeIds(orcaPage)).find( + (id) => id !== firstWorktreeId + ) + test.skip(!secondWorktreeId, 'link-hover repro needs the seeded secondary worktree') + if (!secondWorktreeId) { + return + } + + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + const ptyId = await waitForActivePanePtyId(orcaPage) + await waitForPtyShellEcho(orcaPage, ptyId, 15_000) + + const worktreePath = await activeWorktreePath(orcaPage) + const fileName = `orca-linkfile-${randomUUID().slice(0, 8)}.txt` + const filePath = path.join(worktreePath, fileName) + writeFileSync(filePath, 'orca file link target\n') + const needle = `./${fileName}` + + try { + await sendToTerminal(orcaPage, ptyId, `echo ${needle}\r`) + await expect + .poll(() => getTerminalContent(orcaPage, 4000), { + timeout: 10_000, + message: 'file-link fixture did not reach the terminal buffer' + }) + .toContain(fileName) + + const probe = await assertLinkRecoversAfterReturn(orcaPage, { + firstWorktreeId, + secondWorktreeId, + needle, + expectContains: fileName + }) + await activateHoveredLink(orcaPage, probe) + // The editor header is the user-visible result of a successful terminal + // link activation; store state alone could pass with a blank editor. + await expect(orcaPage.locator('.editor-header-path').first()).toContainText(fileName, { + timeout: 20_000 + }) + } finally { + await orcaPage.evaluate((filePath) => { + const state = window.__store?.getState() + if (state?.openFiles.some((file) => file.filePath === filePath)) { + state.closeFile(filePath) + } + }, filePath) + rmSync(filePath, { force: true }) + } + }) + + test('re-establishes a URL link on hover after switching worktrees and back', async ({ + orcaPage + }) => { + const firstWorktreeId = await waitForActiveWorktree(orcaPage) + const secondWorktreeId = (await getAllWorktreeIds(orcaPage)).find( + (id) => id !== firstWorktreeId + ) + test.skip(!secondWorktreeId, 'link-hover repro needs the seeded secondary worktree') + if (!secondWorktreeId) { + return + } + + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + const ptyId = await waitForActivePanePtyId(orcaPage) + await waitForPtyShellEcho(orcaPage, ptyId, 15_000) + + const url = `https://example.com/orca-link-${randomUUID()}` + await sendToTerminal(orcaPage, ptyId, `echo ${url}\r`) + await expect + .poll(() => getTerminalContent(orcaPage, 4000), { + timeout: 10_000, + message: 'URL fixture did not reach the terminal buffer' + }) + .toContain(url) + + await assertLinkRecoversAfterReturn(orcaPage, { + firstWorktreeId, + secondWorktreeId, + needle: url, + expectContains: url + }) + }) +}) diff --git a/tests/e2e/terminal-pane-divider-capture-loss.spec.ts b/tests/e2e/terminal-pane-divider-capture-loss.spec.ts new file mode 100644 index 00000000000..5f3bfca177b --- /dev/null +++ b/tests/e2e/terminal-pane-divider-capture-loss.spec.ts @@ -0,0 +1,190 @@ +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { + splitActiveTerminalPane, + waitForActiveTerminalManager, + waitForPaneCount +} from './helpers/terminal' +import { ensureTerminalVisible } from './helpers/store' + +type PaneGeometry = { + width: number + flex: string + cols: number | null + rows: number | null + proposed: { cols: number; rows: number } | null +} + +type DividerGeometry = { + first: PaneGeometry + second: PaneGeometry +} + +test.use({ seedTestRepo: false }) + +async function setFullscreen(electronApp: ElectronApplication, page: Page): Promise { + await expect + .poll(async () => { + try { + return await electronApp.evaluate(({ BrowserWindow }) => { + const window = BrowserWindow.getAllWindows()[0] + if (!window) { + return false + } + if (window.isMinimized()) { + window.restore() + } + window.show() + window.focus() + window.setFullScreen(true) + return window.isFullScreen() + }) + } catch { + return false + } + }) + .toBe(true) + await expect.poll(() => page.evaluate(() => innerWidth >= 1000 && innerHeight >= 700)).toBe(true) + await page.waitForTimeout(1200) +} + +async function addTestRepo(page: Page, repoPath: string): Promise { + const repoId = await page.evaluate(async (path) => { + const result = await window.api.repos.add({ path }) + if ('error' in result) { + throw new Error(result.error) + } + return result.repo.id + }, repoPath) + await expect + .poll(() => + page.evaluate(async (id) => { + const store = window.__store + if (!store) { + return false + } + await store.getState().fetchRepos() + await store.getState().fetchWorktrees(id) + const worktree = store + .getState() + .worktreesByRepo[id]?.find((candidate) => candidate.isMainWorktree) + if (!worktree) { + return false + } + store.getState().setActiveWorktree(worktree.id) + return true + }, repoId) + ) + .toBe(true) +} + +async function readDividerGeometry(page: Page): Promise { + return page.evaluate(() => { + const divider = document.querySelector('.pane-divider.is-vertical') + const firstElement = divider?.previousElementSibling as HTMLElement | null + const secondElement = divider?.nextElementSibling as HTMLElement | null + if (!divider || !firstElement || !secondElement) { + throw new Error('Divider unavailable') + } + const readPane = (element: HTMLElement): PaneGeometry => { + const paneElement = element.matches('.pane[data-pty-id]') + ? element + : element.querySelector('.pane[data-pty-id]') + const ptyId = paneElement?.dataset.ptyId + const pane = ptyId + ? Array.from(window.__paneManagers?.values() ?? []) + .flatMap((manager) => manager.getPanes()) + .find((candidate) => candidate.container.dataset.ptyId === ptyId) + : null + let proposed = null + try { + proposed = pane?.fitAddon.proposeDimensions() ?? null + } catch { + proposed = null + } + return { + width: element.getBoundingClientRect().width, + flex: element.style.flex, + cols: pane?.terminal.cols ?? null, + rows: pane?.terminal.rows ?? null, + proposed + } + } + return { first: readPane(firstElement), second: readPane(secondElement) } + }) +} + +function gridsMatch(geometry: DividerGeometry): boolean { + return [geometry.first, geometry.second].every( + (pane) => + pane.proposed !== null && pane.cols === pane.proposed.cols && pane.rows === pane.proposed.rows + ) +} + +test('@headful keeps resizing after the divider loses pointer capture', async ({ + electronApp, + orcaPage, + testRepoPath +}, testInfo) => { + await setFullscreen(electronApp, orcaPage) + await addTestRepo(orcaPage, testRepoPath) + await ensureTerminalVisible(orcaPage, 30_000) + await waitForActiveTerminalManager(orcaPage, 30_000) + await splitActiveTerminalPane(orcaPage, 'vertical') + await waitForPaneCount(orcaPage, 2, 30_000) + + const divider = orcaPage.locator('.pane-divider.is-vertical').first() + await expect(divider).toBeVisible() + const box = await divider.boundingBox() + if (!box) { + throw new Error('Divider has no bounding box') + } + await divider.evaluate((element) => { + element.dataset.captureLossCount = '0' + element.addEventListener('pointerdown', (event) => { + element.dataset.captureLossPointerId = String(event.pointerId) + }) + element.addEventListener('lostpointercapture', () => { + element.dataset.captureLossCount = String(Number(element.dataset.captureLossCount ?? '0') + 1) + }) + }) + + const before = await readDividerGeometry(orcaPage) + const startX = box.x + box.width / 2 + const startY = box.y + box.height / 2 + await orcaPage.mouse.move(startX, startY) + await orcaPage.mouse.down() + await orcaPage.mouse.move(startX + 140, startY, { steps: 10 }) + await expect + .poll(async () => + Math.abs((await readDividerGeometry(orcaPage)).first.width - before.first.width) + ) + .toBeGreaterThan(80) + + // Why: this is the real browser event observed during field failures; later + // window-level pointer events must remain authoritative after capture drops. + await divider.evaluate((element) => { + const pointerId = Number(element.dataset.captureLossPointerId) + if (!Number.isInteger(pointerId) || !element.hasPointerCapture(pointerId)) { + throw new Error('Divider did not acquire pointer capture') + } + element.releasePointerCapture(pointerId) + }) + await expect + .poll(() => divider.evaluate((element) => Number(element.dataset.captureLossCount ?? '0'))) + .toBe(1) + + await orcaPage.mouse.move(startX + 260, startY, { steps: 10 }) + await orcaPage.mouse.up() + await expect.poll(async () => gridsMatch(await readDividerGeometry(orcaPage))).toBe(true) + const after = await readDividerGeometry(orcaPage) + await testInfo.attach('divider-capture-loss-geometry', { + body: Buffer.from(JSON.stringify({ before, after }, null, 2)), + contentType: 'application/json' + }) + + expect(Math.abs(after.first.width - before.first.width)).toBeGreaterThan(180) + expect(Math.abs(after.second.width - before.second.width)).toBeGreaterThan(180) + expect(after.first.flex).not.toBe(before.first.flex) + expect(after.second.flex).not.toBe(before.second.flex) +}) diff --git a/tests/e2e/terminal-pinned-viewport-streaming-switch.spec.ts b/tests/e2e/terminal-pinned-viewport-streaming-switch.spec.ts new file mode 100644 index 00000000000..23672bf7dd7 --- /dev/null +++ b/tests/e2e/terminal-pinned-viewport-streaming-switch.spec.ts @@ -0,0 +1,210 @@ +import { randomUUID } from 'node:crypto' +import { rmSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import type { Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { + ensureTerminalVisible, + getAllWorktreeIds, + switchToWorktree, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { + getTerminalContent, + sendToTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager +} from './helpers/terminal' +import { nodeTerminalCommand } from './terminal-node-command' +import { waitForPtyShellEcho } from './terminal-pty-readiness' + +// A Codex-like agent: pre-fills scrollback, then keeps streaming — commits a +// row and redraws a synchronized-output "Working…" status frame every tick. +// The stream continues while the pane is hidden, which is what routes the +// return through the hidden-output snapshot restore. +function streamingAgentFixtureScript(runId: string): string { + return ` +async function writeStdout(chunk) { + await new Promise((resolve) => process.stdout.write(chunk, resolve)) +} +let row = 0 +let pre = '' +for (; row < 300; row += 1) { + pre += 'STREAMING_SWITCH_${runId}_ROW_' + String(row).padStart(4, '0') + '\\n' +} +await writeStdout(pre + 'STREAMING_SWITCH_${runId}_PRESTREAM_DONE\\n') +const spinner = ['|', '/', '-', '\\\\'] +for (let tick = 0; tick < 800; tick += 1) { + let frame = '\\x1b[?2026h' + if (tick % 3 === 0) { + frame += '\\r\\x1b[2KSTREAMING_SWITCH_${runId}_ROW_' + String(row).padStart(4, '0') + '\\n' + row += 1 + } + frame += '\\r\\x1b[2KWorking… ' + spinner[tick % 4] + ' tick=' + tick + '\\x1b[?2026l' + await writeStdout(frame) + await new Promise((resolve) => setTimeout(resolve, 50)) +} +` +} + +async function closeFeatureTips(page: Page): Promise { + await page.evaluate(() => { + const store = window.__store + store?.getState().markFeatureTipsSeen(['orca-cli', 'cmd-j-palette', 'voice-dictation']) + if (store?.getState().activeModal === 'feature-tips') { + store.getState().closeModal() + } + }) +} + +async function pinActiveTerminalNearBottom(page: Page): Promise<{ + tabId: string + targetViewportY: number + baseY: number +}> { + return page.evaluate(() => { + const store = window.__store + const state = 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 (!tabId || !pane) { + throw new Error('Active terminal pane unavailable') + } + const target = pane.container.querySelector('.xterm') ?? pane.container + target.dispatchEvent( + new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + deltaMode: WheelEvent.DOM_DELTA_PIXEL, + deltaY: -240 + }) + ) + const buffer = pane.terminal.buffer.active + const targetViewportY = Math.max(0, buffer.baseY - 6) + pane.terminal.scrollToLine(targetViewportY) + pane.container + .querySelector('.xterm-viewport') + ?.dispatchEvent(new Event('scroll', { bubbles: true })) + return { tabId, targetViewportY, baseY: buffer.baseY } + }) +} + +async function readSettledViewport( + page: Page, + tabId: string +): Promise<{ viewportY: number; baseY: number }> { + // Wait until the replay has actually parsed (scrollback regrew) and the + // viewport stopped moving, then report where it settled. + let last: { viewportY: number; baseY: number } | null = null + let stableCount = 0 + await expect + .poll( + async () => { + const current = await page.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + const buffer = pane?.terminal?.buffer?.active + return buffer ? { viewportY: buffer.viewportY, baseY: buffer.baseY } : null + }, tabId) + if (!current || current.baseY < 100) { + stableCount = 0 + last = current + return false + } + if (last && current.viewportY === last.viewportY) { + stableCount += 1 + } else { + stableCount = 0 + } + last = current + return stableCount >= 3 + }, + { + timeout: 20_000, + intervals: [250], + message: 'terminal viewport did not settle after returning to the streaming worktree' + } + ) + .toBe(true) + if (!last) { + throw new Error('viewport settle poll finished without a sample') + } + return last +} + +test.describe('Terminal pinned viewport with streaming agent across worktree switch', () => { + test('returning to a pinned pane with an active stream does not land at the top', async ({ + orcaPage, + testRepoPath + }) => { + await waitForSessionReady(orcaPage) + await closeFeatureTips(orcaPage) + const firstWorktreeId = await waitForActiveWorktree(orcaPage) + const secondWorktreeId = (await getAllWorktreeIds(orcaPage)).find( + (id) => id !== firstWorktreeId + ) + test.skip(!secondWorktreeId, 'streaming pinned repro needs the seeded secondary worktree') + if (!secondWorktreeId) { + return + } + + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + const ptyId = await waitForActivePanePtyId(orcaPage) + await waitForPtyShellEcho(orcaPage, ptyId, 15_000) + const runId = randomUUID() + const scriptPath = path.join(testRepoPath, `.orca-streaming-switch-${runId}.mjs`) + writeFileSync(scriptPath, streamingAgentFixtureScript(runId)) + + try { + await sendToTerminal(orcaPage, ptyId, `${nodeTerminalCommand([scriptPath])}\r`) + await expect + .poll(() => getTerminalContent(orcaPage, 30_000), { + timeout: 15_000, + message: 'streaming fixture did not reach terminal scrollback' + }) + .toContain(`STREAMING_SWITCH_${runId}_PRESTREAM_DONE`) + + const pinned = await pinActiveTerminalNearBottom(orcaPage) + expect(pinned.baseY).toBeGreaterThan(100) + await orcaPage.waitForTimeout(150) + + // Stream continues while hidden; hidden byte drops mark the pane for a + // snapshot restore on return. + await switchToWorktree(orcaPage, secondWorktreeId) + await waitForActiveTerminalManager(orcaPage, 30_000) + await orcaPage.waitForTimeout(3_000) + + await switchToWorktree(orcaPage, firstWorktreeId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + + const settled = await readSettledViewport(orcaPage, pinned.tabId) + const bottomDistance = settled.baseY - settled.viewportY + // The user pinned six rows above the bottom. A faithful restore keeps + // them near the pin; the bug clamps to the very top of the scrollback. + expect( + settled.viewportY, + `settled at viewportY=${settled.viewportY} baseY=${settled.baseY} (pinned ${JSON.stringify(pinned)})` + ).toBeGreaterThan(20) + expect( + bottomDistance, + `settled ${bottomDistance} rows above the bottom (pinned 6 rows above)` + ).toBeGreaterThan(1) + expect( + bottomDistance, + `settled ${bottomDistance} rows above the bottom (pinned 6 rows above)` + ).toBeLessThan(80) + } finally { + rmSync(scriptPath, { force: true }) + } + }) +}) diff --git a/tests/e2e/terminal-push-delivery-loss-recovery.spec.ts b/tests/e2e/terminal-push-delivery-loss-recovery.spec.ts index 395353cbe48..9d3b4914147 100644 --- a/tests/e2e/terminal-push-delivery-loss-recovery.spec.ts +++ b/tests/e2e/terminal-push-delivery-loss-recovery.spec.ts @@ -95,8 +95,8 @@ test.describe('terminal push-delivery loss recovery', () => { async () => orcaPage.evaluate( () => - (window as DeliveryWatchdogWindow).__terminalDeliveryWatchdog?.snapshot()?.healCount ?? - 0 + (window as DeliveryWatchdogWindow).__terminalDeliveryWatchdog?.snapshot() + ?.healCount ?? 0 ), { timeout: 30_000 } ) diff --git a/tests/e2e/terminal-scroll-intent-follow.spec.ts b/tests/e2e/terminal-scroll-intent-follow.spec.ts index d3873805948..c4d8b171fed 100644 --- a/tests/e2e/terminal-scroll-intent-follow.spec.ts +++ b/tests/e2e/terminal-scroll-intent-follow.spec.ts @@ -5,9 +5,11 @@ import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } fro import { execInTerminal, sendToTerminal, + waitForActivePaneHookDescriptor, waitForActivePanePtyId, waitForActiveTerminalManager } from './helpers/terminal' +import { waitForTerminalPtyDataInjector } from './helpers/terminal-pty-injection' const STREAMING_FIXTURE_PATH = path.join( process.cwd(), @@ -72,6 +74,32 @@ async function waitForMarkerAtBottom(page: Page, marker: string): Promise async function dispatchSubRowWheelUp(page: Page): Promise { await page.evaluate(() => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!pane?.terminal.element) { + throw new Error('Active terminal pane unavailable') + } + pane.terminal.element.dispatchEvent( + new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + deltaMode: WheelEvent.DOM_DELTA_PIXEL, + deltaY: -2 + }) + ) + }) +} + +async function dispatchRealWheel(page: Page, deltaY: number): Promise { + const point = await page.evaluate(() => { const state = window.__store?.getState() const worktreeId = state?.activeWorktreeId const tabId = @@ -90,19 +118,13 @@ async function dispatchSubRowWheelUp(page: Page): Promise { throw new Error('Active terminal screen unavailable') } const rect = screen.getBoundingClientRect() - // A -2px delta is far below one cell height: xterm scrolls zero rows, the - // viewport stays at the bottom, but the wheel listener still observes an - // upward wheel — the phantom-pin shape from trackpad jitter. - 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: -2 - }) - pane.terminal.element.dispatchEvent(event) + return { + x: rect.left + rect.width / 2, + y: rect.top + Math.min(rect.height - 1, 40) + } }) + await page.mouse.move(point.x, point.y) + await page.mouse.wheel(0, deltaY) } async function dispatchPlainHomeKeydown(page: Page): Promise { @@ -142,6 +164,60 @@ async function dispatchPlainHomeKeydown(page: Page): Promise { }) } +async function injectQueuedWriteThenType(page: Page, paneKey: string): Promise { + await page.evaluate((targetPaneKey) => { + const injectionTarget = window as Window & { + __terminalPtyDataInjection?: { inject: (paneKey: string, data: string) => boolean } + } + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!pane) { + throw new Error('Active terminal pane unavailable') + } + const terminal = pane.terminal + const originalWrite = terminal.write + const holder: { write: { data: string; callback?: () => void } | null } = { write: null } + terminal.write = ((data: string, callback?: () => void) => { + holder.write = { data, callback } + }) as typeof terminal.write + try { + const payload = '\x1b[?2026h\r\x1b[2KWorking in-flight\x1b[?2026l' + if (!injectionTarget.__terminalPtyDataInjection?.inject(targetPaneKey, payload)) { + throw new Error('PTY injector unavailable') + } + const textarea = pane.container.querySelector('.xterm-helper-textarea') + if (!textarea) { + throw new Error('xterm helper textarea unavailable') + } + textarea.focus() + const event = new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + key: 'x', + code: 'KeyX' + }) + Object.defineProperty(event, 'keyCode', { configurable: true, value: 88 }) + Object.defineProperty(event, 'which', { configurable: true, value: 88 }) + textarea.dispatchEvent(event) + } finally { + terminal.write = originalWrite + } + const heldWrite = holder.write + if (!heldWrite) { + throw new Error('Foreground terminal write was not captured') + } + originalWrite.call(terminal, heldWrite.data, heldWrite.callback) + }, paneKey) +} + async function startStreamingFixturePhase1(page: Page): Promise { await waitForSessionReady(page) await waitForActiveWorktree(page) @@ -159,6 +235,8 @@ test.describe('terminal scroll intent keeps following output', () => { }) => { const ptyId = await startStreamingFixturePhase1(orcaPage) + // A -2px delta is far below one cell height: xterm scrolls zero rows, but + // the intent listener still observes the trackpad-jitter-shaped wheel. await dispatchSubRowWheelUp(orcaPage) await orcaPage.waitForTimeout(INTENT_SETTLE_WAIT_MS) @@ -177,4 +255,58 @@ test.describe('terminal scroll intent keeps following output', () => { await dispatchPlainHomeKeydown(orcaPage) await waitForMarkerAtBottom(orcaPage, 'STREAM_PHASE2_DONE') }) + + test('a real wheel pin stays fixed while visible output streams', async ({ orcaPage }) => { + const ptyId = await startStreamingFixturePhase1(orcaPage) + + await dispatchRealWheel(orcaPage, -240) + await expect + .poll(async () => { + const probe = await probeActiveViewport(orcaPage, 'STREAM_PHASE1_DONE') + return probe ? probe.baseY - probe.viewportY : 0 + }) + .toBeGreaterThan(1) + const pinned = await probeActiveViewport(orcaPage, 'STREAM_PHASE1_DONE') + if (!pinned) { + throw new Error('terminal viewport unavailable after wheel pin') + } + + await sendToTerminal(orcaPage, ptyId, 'g') + await expect + .poll( + async () => { + const probe = await probeActiveViewport(orcaPage, 'STREAM_PHASE2_DONE') + return Boolean(probe && probe.containsMarker && probe.viewportY === pinned.viewportY) + }, + { timeout: 30_000, message: 'visible streaming output moved the wheel-pinned viewport' } + ) + .toBe(true) + }) + + test('typing after a pinned write is queued resumes follow-output', async ({ orcaPage }) => { + await startStreamingFixturePhase1(orcaPage) + const { paneKey } = await waitForActivePaneHookDescriptor(orcaPage) + await waitForTerminalPtyDataInjector(orcaPage, paneKey) + + await dispatchRealWheel(orcaPage, -320) + await expect + .poll(async () => { + const probe = await probeActiveViewport(orcaPage, 'STREAM_PHASE1_DONE') + return probe ? probe.baseY - probe.viewportY : 0 + }) + .toBeGreaterThan(2) + + // Hold the xterm write call so typing deterministically lands between the + // old per-write intent capture and its completion-time enforcement from #8625. + await injectQueuedWriteThenType(orcaPage, paneKey) + await expect + .poll( + async () => { + const probe = await probeActiveViewport(orcaPage, 'STREAM_PHASE1_DONE') + return probe ? probe.baseY - probe.viewportY : Number.NaN + }, + { timeout: 5_000, intervals: [25] } + ) + .toBe(0) + }) }) diff --git a/tests/e2e/terminal-tab-close-restart-persistence.spec.ts b/tests/e2e/terminal-tab-close-restart-persistence.spec.ts new file mode 100644 index 00000000000..10efd6491c7 --- /dev/null +++ b/tests/e2e/terminal-tab-close-restart-persistence.spec.ts @@ -0,0 +1,112 @@ +import { existsSync, readFileSync } from 'node:fs' +import type { ElectronApplication } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { TEST_REPO_PATH_FILE } from './global-setup' +import { waitForActiveTerminalManager, waitForPaneCount } from './helpers/terminal' +import { + ensureTerminalVisible, + getActiveTabId, + getWorktreeTabs, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart' +import { RuntimeClient } from '../../src/cli/runtime/client' +import type { + RuntimeTerminalClose, + RuntimeTerminalListResult, + RuntimeTerminalSplit +} from '../../src/shared/runtime-types' + +test.describe.configure({ mode: 'serial' }) + +test('durable whole-tab close removes a split tab across restart', async (// oxlint-disable-next-line no-empty-pattern -- This lifecycle test owns both Electron launches and intentionally opts out of the default app fixture. +{}, testInfo) => { + const repoPath = readFileSync(TEST_REPO_PATH_FILE, 'utf8').trim() + if (!repoPath || !existsSync(repoPath)) { + test.skip(true, 'Global setup did not produce a seeded test repo') + return + } + + const session = createRestartSession(testInfo) + let firstApp: ElectronApplication | null = null + let secondApp: ElectronApplication | null = null + + try { + const firstLaunch = await session.launch() + firstApp = firstLaunch.app + const worktreeId = await attachRepoAndOpenTerminal(firstLaunch.page, repoPath) + await waitForSessionReady(firstLaunch.page) + await waitForActiveWorktree(firstLaunch.page) + await ensureTerminalVisible(firstLaunch.page) + + const hasPaneManager = await waitForActiveTerminalManager(firstLaunch.page, 30_000) + .then(() => true) + .catch(() => false) + test.skip( + !hasPaneManager, + 'Electron automation in this environment never mounted the TerminalPane manager.' + ) + await waitForPaneCount(firstLaunch.page, 1, 30_000) + + const closedTabId = await getActiveTabId(firstLaunch.page) + if (!closedTabId) { + throw new Error('First launch did not expose an active terminal tab') + } + expect(await getWorktreeTabs(firstLaunch.page, worktreeId)).toHaveLength(1) + + const client = new RuntimeClient(session.userDataDir, 30_000) + const active = await client.call<{ handle: string }>('terminal.resolveActive', { + worktree: `id:${worktreeId}` + }) + const split = await client.call<{ split: RuntimeTerminalSplit }>('terminal.split', { + terminal: active.result.handle, + direction: 'vertical' + }) + expect(split.result.split.tabId).toBe(closedTabId) + await waitForPaneCount(firstLaunch.page, 2, 30_000) + + const close = await client.call<{ close: RuntimeTerminalClose }>('terminal.closeTab', { + terminal: split.result.split.handle + }) + expect(close.result.close).toMatchObject({ + handle: split.result.split.handle, + tabId: closedTabId, + closeMode: 'tab' + }) + await expect + .poll(() => getWorktreeTabs(firstLaunch.page, worktreeId), { + message: 'The acknowledged close left the split terminal tab in renderer state' + }) + .toEqual([]) + + const afterClose = await client.call('terminal.list', { + worktree: `id:${worktreeId}` + }) + expect( + afterClose.result.terminals.filter((terminal) => terminal.tabId === closedTabId) + ).toEqual([]) + + await session.close(firstApp) + firstApp = null + + const secondLaunch = await session.launch() + secondApp = secondLaunch.app + await waitForSessionReady(secondLaunch.page) + const restoredWorktreeId = await attachRepoAndOpenTerminal(secondLaunch.page, repoPath) + expect(restoredWorktreeId).toBe(worktreeId) + + // Why: wait past initial worktree effects so this checks resurrection, not + // only the first hydrated frame before default-tab logic has run. + await secondLaunch.page.waitForTimeout(1_000) + expect(await getWorktreeTabs(secondLaunch.page, worktreeId)).toEqual([]) + } finally { + if (firstApp) { + await session.close(firstApp) + } + if (secondApp) { + await session.close(secondApp) + } + await session.dispose() + } +}) diff --git a/tests/e2e/terminal-warm-reattach-split-live-output.spec.ts b/tests/e2e/terminal-warm-reattach-split-live-output.spec.ts new file mode 100644 index 00000000000..2d4a85ec3b7 --- /dev/null +++ b/tests/e2e/terminal-warm-reattach-split-live-output.spec.ts @@ -0,0 +1,263 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { TEST_REPO_PATH_FILE } from './global-setup' +import { + execInTerminal, + splitActiveTerminalPane, + waitForActiveTerminalManager, + waitForPaneCount, + waitForPaneIdentitySnapshot +} from './helpers/terminal' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart' +import { PTY_SESSION_ID_SEPARATOR } from '../../src/shared/pty-session-id-format' + +type PaneProbe = { + content: string + cols: number + rows: number + proposed: { cols: number; rows: number } | null + rect: { width: number; height: number } +} + +function frameNumber(content: string): number { + const matches = [...content.matchAll(/REATTACH_FRAME_(\d+)/g)] + return Number(matches.at(-1)?.[1] ?? 0) +} + +function heartbeatNumber(path: string): number { + return Number(readFileSync(path, 'utf8').trim()) +} + +function writeStreamingTui(scriptPath: string): void { + mkdirSync(path.dirname(scriptPath), { recursive: true }) + writeFileSync( + scriptPath, + [ + "import { writeFileSync } from 'node:fs'", + 'const heartbeatPath = process.argv[2]', + 'let frame = 0', + "process.stdout.write('\\x1b[?1049h\\x1b[?25l')", + 'setInterval(() => {', + ' frame += 1', + ' writeFileSync(heartbeatPath, String(frame))', + ' const body = [', + " '╭────────────────────────────────────────────────────╮',", + " `│ Orca warm reattach frame ${String(frame).padStart(6, '0')} 🟢 │`,", + " '├────────────────────────────────────────────────────┤',", + ' `│ REATTACH_FRAME_${frame} live daemon output │`,', + " '╰────────────────────────────────────────────────────╯'", + " ].join('\\r\\n')", + ' process.stdout.write(`\\x1b[?2026h\\x1b[2J\\x1b[H${body}\\x1b[?2026l`)', + '}, 50)', + '' + ].join('\n') + ) +} + +async function setFullscreen(app: ElectronApplication, page: Page): Promise { + await app.evaluate(({ BrowserWindow }) => { + const window = BrowserWindow.getAllWindows()[0] + if (!window) { + throw new Error('No Electron window') + } + window.show() + window.setFullScreen(true) + }) + await expect + .poll( + async () => ({ + fullscreen: await app.evaluate( + ({ BrowserWindow }) => BrowserWindow.getAllWindows()[0]?.isFullScreen() ?? false + ), + size: await page.evaluate(() => ({ width: innerWidth, height: innerHeight })) + }), + { timeout: 15_000 } + ) + .toMatchObject({ + fullscreen: true, + size: { width: expect.any(Number), height: expect.any(Number) } + }) + await page.waitForTimeout(1_200) +} + +async function createActiveDecoyTab(page: Page, worktreeId: string): Promise { + await page.evaluate((worktreeId) => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + const state = store.getState() + const tab = state.createTab(worktreeId, undefined, undefined, { activate: true }) + state.setActiveTab(tab.id) + state.setActiveTabType('terminal') + }, worktreeId) + await waitForActiveTerminalManager(page, 30_000) + await waitForPaneCount(page, 1, 30_000) +} + +async function findRestoredTabForPty(page: Page, ptyId: string): Promise { + return page.evaluate((ptyId) => { + const layouts = window.__store?.getState().terminalLayoutsByTabId ?? {} + for (const [tabId, layout] of Object.entries(layouts)) { + if (Object.values(layout.ptyIdsByLeafId ?? {}).includes(ptyId)) { + return tabId + } + } + return null + }, ptyId) +} + +async function activateTabWithoutFocus(page: Page, tabId: string): Promise { + await page.evaluate((tabId) => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + store.getState().setActiveTabType('terminal') + store.getState().setActiveTab(tabId) + }, tabId) +} + +async function probePane(page: Page, tabId: string, ptyId: string): Promise { + return page.evaluate( + ({ tabId, ptyId }) => { + const pane = window.__paneManagers + ?.get(tabId) + ?.getPanes?.() + .find((candidate) => candidate.container.dataset.ptyId === ptyId) + if (!pane) { + return null + } + let proposed: { cols: number; rows: number } | null = null + try { + proposed = pane.fitAddon.proposeDimensions() ?? null + } catch { + proposed = null + } + const rect = pane.container.getBoundingClientRect() + return { + content: pane.serializeAddon.serialize(), + cols: pane.terminal.cols, + rows: pane.terminal.rows, + proposed, + rect: { width: rect.width, height: rect.height } + } + }, + { tabId, ptyId } + ) +} + +test.describe.configure({ mode: 'serial' }) + +test('restored hidden split drains live alternate-screen output without a click @headful', async (// oxlint-disable-next-line no-empty-pattern -- This restart test owns both app launches. +{}, testInfo) => { + test.setTimeout(300_000) + test.skip(process.platform === 'win32', 'The streaming fixture uses a POSIX shell command') + const repoPath = readFileSync(TEST_REPO_PATH_FILE, 'utf8').trim() + test.skip(!repoPath || !existsSync(repoPath), 'Global setup did not produce a seeded test repo') + + const scriptPath = testInfo.outputPath('streaming-tui.mjs') + const heartbeatPath = testInfo.outputPath('streaming-tui-heartbeat.txt') + writeStreamingTui(scriptPath) + + const session = createRestartSession(testInfo) + let firstApp: ElectronApplication | null = null + let secondApp: ElectronApplication | null = null + try { + const first = await session.launch() + firstApp = first.app + await setFullscreen(first.app, first.page) + const worktreeId = await attachRepoAndOpenTerminal(first.page, repoPath) + await waitForSessionReady(first.page) + await waitForActiveWorktree(first.page) + await ensureTerminalVisible(first.page) + await waitForActiveTerminalManager(first.page, 30_000) + await waitForPaneCount(first.page, 1, 30_000) + + await splitActiveTerminalPane(first.page, 'horizontal') + await waitForPaneCount(first.page, 2, 30_000) + const split = await waitForPaneIdentitySnapshot(first.page, 2) + const streamingPane = split.panes.find((pane) => pane.leafId === split.activeLeafId) + if (!streamingPane?.ptyId) { + throw new Error('Split did not expose its active PTY') + } + expect(streamingPane.ptyId).toContain(PTY_SESSION_ID_SEPARATOR) + await execInTerminal( + first.page, + streamingPane.ptyId, + `node ${JSON.stringify(scriptPath)} ${JSON.stringify(heartbeatPath)}` + ) + await expect + .poll( + async () => + frameNumber( + (await probePane(first.page, split.tabId, streamingPane.ptyId))?.content ?? '' + ), + { + timeout: 20_000, + message: 'Streaming TUI did not start in the split pane' + } + ) + .toBeGreaterThan(5) + + // Hide the split tab before quit so relaunch restores the same cold-view boundary as the field incident. + await createActiveDecoyTab(first.page, worktreeId) + await first.page.waitForTimeout(1_000) + const beforeCloseHeartbeat = heartbeatNumber(heartbeatPath) + await session.close(firstApp) + firstApp = null + await new Promise((resolve) => setTimeout(resolve, 750)) + expect(heartbeatNumber(heartbeatPath)).toBeGreaterThan(beforeCloseHeartbeat) + + const second = await session.launch() + secondApp = second.app + await waitForSessionReady(second.page) + await waitForActiveWorktree(second.page) + await ensureTerminalVisible(second.page) + + let restoredTabId: string | null = null + await expect + .poll( + async () => { + restoredTabId = await findRestoredTabForPty(second.page, streamingPane.ptyId!) + return restoredTabId + }, + { timeout: 20_000, message: 'Persisted split PTY was not restored into a tab layout' } + ) + .not.toBeNull() + await activateTabWithoutFocus(second.page, restoredTabId!) + await waitForActiveTerminalManager(second.page, 30_000) + await waitForPaneCount(second.page, 2, 30_000) + + const initial = await probePane(second.page, restoredTabId!, streamingPane.ptyId) + expect(initial, 'Streaming pane did not remount after tab activation').not.toBeNull() + const initialFrame = frameNumber(initial!.content) + const initialHeartbeat = heartbeatNumber(heartbeatPath) + await second.page.waitForTimeout(2_000) + const later = await probePane(second.page, restoredTabId!, streamingPane.ptyId) + const laterHeartbeat = heartbeatNumber(heartbeatPath) + + expect( + laterHeartbeat, + 'Fixture process stopped instead of exercising live output' + ).toBeGreaterThan(initialHeartbeat + 20) + expect(later?.rect.width ?? 0).toBeGreaterThan(0) + expect(later?.rect.height ?? 0).toBeGreaterThan(0) + expect(later?.proposed).toEqual({ cols: later?.cols, rows: later?.rows }) + expect( + frameNumber(later?.content ?? ''), + `Renderer frame did not advance while daemon heartbeat moved ${initialHeartbeat} -> ${laterHeartbeat}; initial=${JSON.stringify(initial)} later=${JSON.stringify(later)}` + ).toBeGreaterThan(initialFrame + 20) + } finally { + if (secondApp) { + await session.close(secondApp) + } + if (firstApp) { + await session.close(firstApp) + } + await session.dispose() + } +})