diff --git a/.github/workflows/mobile-android-release.yml b/.github/workflows/mobile-android-release.yml index 3e0045f240f..0fcf6ff2a45 100644 --- a/.github/workflows/mobile-android-release.yml +++ b/.github/workflows/mobile-android-release.yml @@ -9,22 +9,8 @@ on: - 'mobile-android-v*' workflow_dispatch: inputs: - bump_patch_version: - description: 'Bump the mobile marketing version patch number before release' - required: false - default: false - type: boolean release_version: - description: 'Optional exact mobile marketing version override, e.g. 0.0.15' - required: false - type: string - bump_android_version_code: - description: 'Increment the Android versionCode before release' - required: false - default: false - type: boolean - android_version_code: - description: 'Optional exact Android versionCode override' + description: 'Optional exact mobile marketing version assertion, e.g. 0.0.22' required: false type: string publish_github_release: @@ -69,9 +55,6 @@ jobs: id: release env: MOBILE_ANDROID_RELEASE_VERSION: ${{ github.event.inputs.release_version }} - MOBILE_ANDROID_BUMP_PATCH_VERSION: ${{ github.event.inputs.bump_patch_version }} - MOBILE_ANDROID_VERSION_CODE: ${{ github.event.inputs.android_version_code }} - MOBILE_ANDROID_BUMP_VERSION_CODE: ${{ github.event.inputs.bump_android_version_code }} MOBILE_ANDROID_PUBLISH_RELEASE: ${{ github.event.inputs.publish_github_release }} run: node scripts/prepare-android-release.mjs diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml index 157a1ba773e..0d31113a0e0 100644 --- a/.github/workflows/mobile.yml +++ b/.github/workflows/mobile.yml @@ -9,6 +9,9 @@ on: - ready_for_review paths: - 'mobile/**' + # Why: the mobile terminal link parsers are conformance-tested against + # these shared fixtures; desktop-side fixture edits must re-run this suite. + - 'src/shared/terminal-file-link-conformance.ts' jobs: verify: diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index b89fefe2094..d9f19dcc1b8 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -66,6 +66,9 @@ jobs: - name: Check styled scrollbars run: pnpm check:styled-scrollbars + - name: Check reliability gate manifest + run: pnpm run check:reliability-gates + # 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/release-cut.yml b/.github/workflows/release-cut.yml index df152e9fb31..96af9bd0cd1 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -1,6 +1,6 @@ name: Cut Release -# Why: single entry point for manually and scheduled cutting releases. +# Why: single entry point for manually cutting releases. # Replaces the old local `pnpm release:*` scripts and the standalone scheduled # RC workflow so releases are always reproducible from CI and can never be # accidentally tagged against an uncommitted or non-main working tree. @@ -37,16 +37,10 @@ on: type: string default: main dry_run: - description: Validate a scheduled-RC run without creating a tag + description: Validate an RC release cut without creating a tag required: false default: false type: boolean - schedule: - # Why: GitHub scheduled workflows are not guaranteed to start exactly on - # time and can be delayed or dropped around high-load periods. We retry - # across the full target hour and dedupe per PT slot. Cron is UTC, so run - # during both PST and PDT equivalents and let the PT-hour gate decide. - - cron: '*/5 10,11,22,23 * * *' permissions: contents: write @@ -182,8 +176,8 @@ jobs: - name: Publish complete release-cut RC drafts from prior runs id: publish_drafts - # Why: this must run even when the current slot already cut a tag; a - # later cron retry may be the first chance to unstick a complete RC draft. + # Why: a manual RC dispatch should unstick any complete RC draft before + # deciding whether to cut another tag. if: steps.window.outputs.allowed == 'true' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -889,7 +883,32 @@ jobs: if: matrix.platform == 'win' shell: pwsh run: | - Set-PSRepository -Name PSGallery -InstallationPolicy Trusted + $ErrorActionPreference = 'Stop' + # Why: force TLS 1.2 so gallery downloads work on older hosted images. + [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 + + # Why: on some hosted Windows images `Register-PSRepository -Default` + # fails inside the legacy nuget.exe provider with "Missing option value + # for: '-source'", so PSGallery is never registered and the install + # below dies with "No repository with the name 'PSGallery'". PSResourceGet + # (bundled with PowerShell 7.4+) has PSGallery registered by default and + # avoids that code path, so prefer it and fall back to PowerShellGet only + # when it is absent. + $useResourceGet = $null -ne (Get-Command -Name Install-PSResource -ErrorAction SilentlyContinue) + + if ($useResourceGet) { + if ($null -eq (Get-PSResourceRepository -Name PSGallery -ErrorAction SilentlyContinue)) { + Register-PSResourceRepository -PSGallery -Trusted + } else { + Set-PSResourceRepository -Name PSGallery -Trusted + } + } else { + Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force | Out-Null + if ($null -eq (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue)) { + Register-PSRepository -Default -InstallationPolicy Trusted + } + Set-PSRepository -Name PSGallery -InstallationPolicy Trusted + } $trimChars = [char[]]@([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) $documentsRoot = [System.IO.Path]::GetFullPath([Environment]::GetFolderPath('MyDocuments')).TrimEnd($trimChars) @@ -918,7 +937,11 @@ jobs: } try { - Install-Module -Name SignPath -Repository PSGallery -MinimumVersion 4.0.0 -MaximumVersion 4.999.999 -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop + if ($useResourceGet) { + Install-PSResource -Name SignPath -Version '[4.0.0,5.0.0)' -Repository PSGallery -Scope CurrentUser -TrustRepository -Reinstall -ErrorAction Stop + } else { + Install-Module -Name SignPath -Repository PSGallery -MinimumVersion 4.0.0 -MaximumVersion 4.999.999 -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop + } Import-Module SignPath -ErrorAction Stop Get-Command -Name Get-SignedArtifact -Module SignPath -ErrorAction Stop break diff --git a/.gitignore b/.gitignore index 0bfbb0e5e84..7cdf3374035 100644 --- a/.gitignore +++ b/.gitignore @@ -60,6 +60,9 @@ pnpm-debug.log* # Coverage coverage/ +# Benchmark run output +/tools/benchmarks/results/*.json + # Temp tmp/ .tmp/ diff --git a/README.md b/README.md index e5f11ec0b90..1043bc9d670 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@

- Español · 中文 · 日本語 · 한국어 + Español · Português · 中文 · 日本語 · 한국어

@@ -36,7 +36,7 @@ Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere. -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.18](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.22](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) @@ -230,7 +230,7 @@ yay -S stably-orca-bin Pair with your desktop app to monitor and steer your agents from your phone. - **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA) -- **Android:** [Download APK 0.0.18](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk) +- **Android:** [Download APK 0.0.22](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) --- @@ -256,6 +256,10 @@ Want to contribute or run locally? See our [CONTRIBUTING.md](.github/CONTRIBUTIN Orca contributors +

+ GitHub star history chart for stablyai/orca +

+ ## License Orca is free and open source under the [MIT License](LICENSE). diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc new file mode 100644 index 00000000000..bd90a3370b8 --- /dev/null +++ b/config/reliability-gates.jsonc @@ -0,0 +1,2139 @@ +{ + "schemaVersion": 1, + "updatedAt": "2026-07-03", + "policy": { + "maturityLevels": [ + "experimental", + "soak", + "blocking", + "accepted-gap", + "deprecated" + ], + "blockingPromotion": { + "minimumSoakRuns": 100, + "minimumSoakDays": 14, + "maximumUnexplainedFlakes": 0 + } + }, + "gates": [ + { + "id": "terminal-session.snapshot-freshness", + "title": "Stale liveness snapshots cannot close newer PTY bindings", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-runtime", + "layer": "renderer-unit", + "surfaces": [ + "terminal lifecycle", + "dead-session reconciliation", + "tab creation" + ], + "platforms": [ + "macos", + "linux", + "windows" + ], + "providers": [ + "local", + "daemon" + ], + "coveredPlatforms": [ + "macos" + ], + "coveredProviders": [], + "coverageNotes": "Local macOS evidence over the reconcile guards that exist on main@1282f5c2d. Broader targeted-hasPty resume paths, no-hot listing counts, and live Electron survival arrive with the pending reliability stack.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/issues/6773", + "https://github.com/stablyai/orca/pull/6514", + "https://github.com/stablyai/orca/pull/6796", + "https://github.com/stablyai/orca/pull/6801" + ], + "invariant": "A local or daemon liveness snapshot requested before a pane binds a PTY cannot prove that newer binding dead or route it through exit teardown.", + "oracle": "The decision layer rejects reconciliation when ptyBoundAt is greater than or equal to snapshotRequestedAt, still reconciles genuinely absent older local ids, and treats rejected provider listing as unknown.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-dead-session-reconcile.test.ts" + ], + "testFiles": [ + "src/renderer/src/components/terminal-pane/terminal-dead-session-reconcile.test.ts" + ], + "assertionRefs": [ + { + "file": "src/renderer/src/components/terminal-pane/terminal-dead-session-reconcile.test.ts", + "assertions": [ + "a newborn pane bound after the snapshot was requested is not reconciled (boundAt >= requestedAt freshness guard)", + "a rejected listSessions is treated as unknown and reconciles nothing", + "remote, SSH, and mid-spawn panes are skipped by the reconcile path", + "targeted liveness probes receive the request timestamp and resolved live-session ids" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-07-03", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-dead-session-reconcile.test.ts", + "result": "passed", + "durationSeconds": 1.5, + "summary": "1 test file(s) passed, 17 tests passed on main@1282f5c2d in a clean checkout." + } + ], + "runtimeBudget": { + "p95Seconds": 10, + "scope": "local unit test" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "Registered after existing targeted tests were found; needs soak history before blocking promotion." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "Unit tests encode the stale snapshot/newborn race and fail if the freshness guard is removed. Needs saved CI or intentional-break artifact before blocking promotion." + }, + "performanceBudget": { + "required": true, + "evidence": "The gate itself is cheap. Any PR changing reconciliation loops, hidden-pane scans, or provider polling must also run a terminal throughput or event-loop-delay measurement before blocking promotion." + }, + "promotionCriteria": [ + "Run in soak for at least 100 consecutive passes or 14 days across required CI platforms.", + "Attach red/green evidence from the freshness guard regression.", + "Add an integration/provider-contract follow-up that proves tab survival plus input/output after stale snapshot release." + ], + "knownGaps": [ + "Current command asserts the pure decision and orchestration timestamp forwarding, not a full Electron tab-survival/input echo flow.", + "SSH and remote providers are intentionally unknown-liveness paths and need separate provider-contract gates." + ], + "demotionRule": "Demote or quarantine if the gate flakes once without a product bug or harness bug filed to the owner." + }, + { + "id": "agent-session.provider-ownership", + "title": "Provider sessions are resumed once per workspace ownership claim", + "maturity": "experimental", + "protection": "partial", + "owner": "agent-session", + "layer": "renderer-state", + "surfaces": [ + "agent launch", + "workspace activation", + "sleep and hibernate restore", + "provider session dedupe", + "sidebar and mobile identity" + ], + "platforms": [ + "macos", + "linux", + "windows" + ], + "providers": [ + "local", + "daemon", + "ssh", + "wsl", + "remote-runtime" + ], + "coveredPlatforms": [ + "macos" + ], + "coveredProviders": [], + "coverageNotes": "Local macOS evidence over the ownership/dedupe suite on main@1282f5c2d. Queued/pending resume-claim indexing, same-session and wrong-session hook proofs, and Electron repeat-activation coverage arrive with the pending stack (#7008).", + "motivatingLinks": [ + "https://github.com/stablyai/orca/pull/6800", + "https://github.com/stablyai/orca/pull/5240", + "https://github.com/stablyai/orca/pull/6411", + "https://github.com/stablyai/orca/pull/6833" + ], + "invariant": "Workspace activation, launch, restore, sleep, hibernate, dedupe, clearing, and reconnect code must not replay or resume a provider session id already owned, queued, pending, or live in that workspace.", + "oracle": "The current renderer-state slice asserts provider-session claim keys are owned by preserved active tabs, inactive split leaves, visible non-focused split groups, live records, quit records, worktree-sleep records, queued startup payloads, time-bounded resume bridge claims, and same-session live hook evidence; duplicates clear without launching a second resume command. The provider list is the risk scope, not proof that every provider has a live integration gate.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/resume-sleeping-agent-session.test.ts" + ], + "testFiles": [ + "src/renderer/src/lib/resume-sleeping-agent-session.test.ts" + ], + "assertionRefs": [ + { + "file": "src/renderer/src/lib/resume-sleeping-agent-session.test.ts", + "assertions": [ + "preserved panes claim their provider session and only stale duplicates are cleared", + "one launch per provider session: skipped duplicates are cleared instead of relaunched", + "active stable-pane records owned by preserved or visible panes are not resumed again", + "hibernated stable panes with cleared live PTY bindings are skipped" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-07-03", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/resume-sleeping-agent-session.test.ts", + "result": "passed", + "durationSeconds": 1.9, + "summary": "1 test file(s) passed, 30 tests passed on main@1282f5c2d in a clean checkout." + } + ], + "runtimeBudget": { + "p95Seconds": 15, + "scope": "local renderer state test" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "Registered after targeted tests were found; needs soak history before blocking promotion." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "Tests encode provider-session dedupe and ownership claims across active/inactive/visible split records, queued pendingStartupByTabId resume payloads, time-bounded runtime automaticAgentResumeClaimsByTabId bridge claims, live same-session hook evidence, wrong-session hook rejection, and a bounded queued-claim index over many records/tabs. Needs saved red/green artifact for the class-level replay invariant." + }, + "performanceBudget": { + "required": true, + "evidence": "Current state tests are cheap and assert queued pending-startup provider-session ids are indexed once per activation. PRs adding new ownership scans must show bounded work over records and no hidden-pane wake loop before blocking promotion." + }, + "promotionCriteria": [ + "Run in soak for at least 100 consecutive passes or 14 days across required CI platforms.", + "Add bounded-work assertions for delayed hook/status ownership scans if those paths grow.", + "Attach red/green evidence that display/replay evidence alone cannot claim ownership." + ], + "knownGaps": [ + "Providers listed on this gate are affected identity surfaces; the current executable command is renderer-state coverage, not live local/daemon/SSH/WSL/remote-runtime coverage.", + "Current command models live same-session and wrong-session hook evidence, but does not run the real hook timing through Electron.", + "Current command does not run a real workspace activation loop repeatedly through Electron." + ], + "demotionRule": "Demote or quarantine if failures are non-actionable or if a duplicate resume escape occurs outside the modeled matrix." + }, + { + "id": "terminal-geometry.visible-convergence", + "title": "Visible desktop terminals converge across xterm, fit, PTY, shell, and runtime mirror size", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-rendering", + "layer": "renderer-provider-contract", + "surfaces": [ + "PTY sizing", + "split layout", + "restore", + "hidden-to-visible transitions" + ], + "platforms": [ + "macos", + "linux", + "windows" + ], + "providers": [ + "local", + "daemon", + "ssh", + "remote-runtime" + ], + "coveredPlatforms": [ + "macos" + ], + "coveredProviders": [], + "coverageNotes": "Local macOS evidence on main@1282f5c2d, including #7192's runtime-mirror geometry authority slice. Shell-visible size, SSH/remote geometry, Windows ConPTY readback, and resume-time reassertion coverage arrive with the pending stack and #7006.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/pull/6644", + "https://github.com/stablyai/orca/pull/6649", + "https://github.com/stablyai/orca/pull/6725", + "https://github.com/stablyai/orca/pull/6785", + "https://github.com/stablyai/orca/pull/6853", + "https://github.com/stablyai/orca/pull/6939", + "https://github.com/stablyai/orca/pull/7192" + ], + "invariant": "A visible desktop-owned terminal cannot trust 0x0, stale requested size, or renderer-only size; xterm, fit/proposed size, applied PTY size, shell-visible size, and the runtime mirror's parse dimensions must converge or enter explicit degraded state, and mirror resize reflow must stay ordered with queued output writes.", + "oracle": "The current executable slice uses deterministic frame schedulers and fake providers to force 0x0 first fit, delayed layout settle, dropped resize/readback drift, and hidden-to-visible activation, then asserts the renderer forwards a usable size, pty:getSize reports applied rather than merely requested size where available, and visibility resume reasserts real drift without hot listSessions. Shell-visible stty/echo-wrap convergence remains a live-gate follow-up.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts src/main/ipc/pty.test.ts src/main/runtime/orca-runtime.test.ts" + ], + "testFiles": [ + "src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts", + "src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts", + "src/main/ipc/pty.test.ts", + "src/main/runtime/orca-runtime.test.ts" + ], + "assertionRefs": [ + { + "file": "src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts", + "assertions": [ + "hidden delayed narrow settle is forwarded instead of stopping on a fixed frame budget", + "unmeasurable frames do not count as settled", + "applied-size readback divergence is re-forwarded before handoff" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts", + "assertions": [ + "0x0 split-right spawn recovers when the container becomes measurable", + "visible permanently-unmeasurable 0x0 pane gets a nonzero safe fallback", + "hidden 0x0 background spawn is not forced to phantom desktop size" + ] + }, + { + "file": "src/main/ipc/pty.test.ts", + "assertions": [ + "accepted desktop resizes fan out to the runtime after provider resize", + "rejected desktop resizes do not fan out to the runtime" + ] + }, + { + "file": "src/main/runtime/orca-runtime.test.ts", + "assertions": [ + "the headless mirror resizes after an accepted desktop PTY resize (#7192)", + "headless mirror resizes are ordered behind queued PTY writes (#7192)" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-07-03", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts src/main/ipc/pty.test.ts src/main/runtime/orca-runtime.test.ts", + "result": "passed", + "durationSeconds": 8.4, + "summary": "4 test file(s) passed, 777 tests passed on main@1282f5c2d in a clean checkout." + } + ], + "runtimeBudget": { + "p95Seconds": 60, + "scope": "provider-contract or focused Electron test" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "Deterministic Vitest slices passed locally; no CI soak history yet." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "#7192 proved the mirror slice red before its fix (snapshot stayed 80x24, queued write parsed at the wrong width). The renderer 0x0/settle slices do not have recorded red runs." + }, + "performanceBudget": { + "required": true, + "evidence": "Must include resize/throughput budget so convergence loops do not add frame or SIGWINCH churn." + }, + "promotionCriteria": [ + "Build deterministic fault hooks before adding broad E2E coverage.", + "Keep stress variants non-blocking until runtime and flake history are known." + ], + "knownGaps": [ + "No live Electron geometry command yet beyond the local live PTY resize slice.", + "The runtime mirror geometry slice is unregistered: #7192's red tests (orca-runtime.test.ts headless-mirror resize and ordering, pty.test.ts desktop resize fan-out) exist on fresh main but not at this branch's merge-base, so they can only be registered during the rebase.", + "Does not yet prove shell-visible size, provider-applied size, and xterm size converge across SSH/remote paths.", + "Does not yet prove Windows ConPTY geometry/readback.", + "Current command uses deterministic fake providers for the main/renderer contracts, not a real remote PTY." + ], + "demotionRule": "Cannot promote without deterministic oracle and runtime history." + }, + { + "id": "xterm-addon.boundary-containment", + "title": "xterm addon failures stay pane-scoped and input survives", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-rendering", + "layer": "renderer-unit", + "surfaces": [ + "terminal search", + "links", + "WebGL", + "decorations", + "keyboard navigation" + ], + "platforms": [ + "macos", + "linux", + "windows" + ], + "providers": [ + "local", + "daemon", + "ssh", + "remote-runtime" + ], + "coveredPlatforms": [ + "macos" + ], + "coveredProviders": [], + "coverageNotes": "Local macOS evidence over the WebGL/link/search containment suites on main@1282f5c2d, adopting the #6949 atlas-recovery rename and #7133's reveal hardening tests. Core addon-load throw containment and live typed-input survival arrive with #7004 and a live follow-up.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/pull/6852", + "https://github.com/stablyai/orca/pull/6872", + "https://github.com/stablyai/orca/pull/6855" + ], + "invariant": "Search, link, WebGL, decoration, input-protocol, or keyboard-navigation errors must not unmount the terminal surface, crash React/window, break focus, or stop PTY input/output.", + "oracle": "The current executable slice injects a core addon load throw and asserts pane opening continues through later addons, covers link provider throws as pane-scoped no-link results, covers search decoration positive-integer failures as false results instead of renderer crashes, and covers WebGL attach/refresh/recovery containment. Focused typed input echo after addon failure still needs a live/component follow-up.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/pane-manager/terminal-link-provider-guard.test.ts src/renderer/src/components/terminal-search-safe-find.test.ts src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts src/renderer/src/lib/pane-manager/pane-reveal-repaint.test.ts src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts" + ], + "testFiles": [ + "src/renderer/src/lib/pane-manager/terminal-link-provider-guard.test.ts", + "src/renderer/src/components/terminal-search-safe-find.test.ts", + "src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts", + "src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts", + "src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts", + "src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts", + "src/renderer/src/lib/pane-manager/pane-reveal-repaint.test.ts", + "src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts" + ], + "assertionRefs": [ + { + "file": "src/renderer/src/lib/pane-manager/terminal-link-provider-guard.test.ts", + "assertions": [ + "link-provider throws are reproduced and contained without escaping the surface" + ] + }, + { + "file": "src/renderer/src/components/terminal-search-safe-find.test.ts", + "assertions": [ + "the xterm positive-integers decoration crash is swallowed while unrelated errors re-throw" + ] + }, + { + "file": "src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts", + "assertions": [ + "WebGL teardown/refresh lifecycle stays pane-scoped and cancels on dispose" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts", + "assertions": [ + "risky-output atlas recovery is bounded, coalesced, and survives a throwing manager" + ] + }, + { + "file": "src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts", + "assertions": [ + "failed attach backs off and context-lost panes recover on the next rendering resume" + ] + }, + { + "file": "src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts", + "assertions": [ + "attach bail disposes the addon instead of orphaning it (#7133 single-addon invariant)" + ] + }, + { + "file": "src/renderer/src/lib/pane-manager/pane-reveal-repaint.test.ts", + "assertions": [ + "reveal repaint waits for a settled frame and keeps repainting when one pane throws (#7133)" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts", + "assertions": [ + "tab reveal, heavy reveal, and window wake schedule pane-scoped repaint (#7133)" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-07-03", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/pane-manager/terminal-link-provider-guard.test.ts src/renderer/src/components/terminal-search-safe-find.test.ts src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts src/renderer/src/lib/pane-manager/pane-reveal-repaint.test.ts src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts", + "result": "passed", + "durationSeconds": 0.8, + "summary": "8 test file(s) passed, 33 tests passed on main@1282f5c2d in a clean checkout." + } + ], + "runtimeBudget": { + "p95Seconds": 30, + "scope": "renderer unit or component test" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "Focused renderer-unit containment slice passed locally on 2026-07-02; needs soak history before promotion." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "#7133 proved the reveal-hardening slice against live corrupted specimens; the link/search guards reproduce their original crashes in-test. No recorded red runs for the refresh-lifecycle slice." + }, + "performanceBudget": { + "required": false, + "evidence": "Required only if containment adds retry loops, polling, or renderer fallback churn." + }, + "promotionCriteria": [ + "Add deterministic addon throw injection.", + "Prove input/output survives after boundary failure." + ], + "knownGaps": [ + "Current command is renderer-unit containment, not live Electron typed input/output survival after addon failure.", + "Does not yet inject WebGL dispose/reset throws and active/hidden/resumed pane focus recovery in one component-level oracle." + ], + "demotionRule": "Cannot promote while it only checks visual rendering." + }, + { + "id": "startup-upgrade.persisted-session-corpus", + "title": "Current Orca preserves or recovers old production persisted sessions", + "maturity": "experimental", + "protection": "none", + "owner": "startup-persistence", + "layer": "upgrade-fixture", + "surfaces": [ + "startup", + "upgrade", + "session restore", + "daemon restore" + ], + "platforms": [ + "macos", + "linux", + "windows" + ], + "providers": [ + "local", + "daemon", + "ssh", + "wsl" + ], + "coveredPlatforms": [], + "coveredProviders": [], + "coverageNotes": "Registered gap only; no executable coverage is wired yet.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/issues/5356", + "https://github.com/stablyai/orca/pull/5234", + "https://github.com/stablyai/orca/pull/5240" + ], + "invariant": "Startup and restore fixes must preserve or explicitly recover sessions from the last affected production persisted-state schema, not only from state written by current code.", + "oracle": "Boot current Orca against immutable copied user-data fixtures from affected versions and reject blank replacement panes, duplicate resume tabs, silent session loss, or works-only-after-current-write behavior.", + "commands": [], + "testFiles": [], + "assertionRefs": [], + "evidenceRuns": [], + "runtimeBudget": { + "p95Seconds": 120, + "scope": "focused Electron upgrade fixture" + }, + "flakeHistory": { + "status": "not-started", + "evidence": "Fixture corpus not implemented." + }, + "redGreenEvidence": { + "status": "missing", + "evidence": "Needs immutable pre-fix persisted-state fixture." + }, + "performanceBudget": { + "required": true, + "evidence": "Startup fixture must record startup latency and avoid adding blocking migration scans." + }, + "promotionCriteria": [ + "Land immutable old-version fixture with documented source version.", + "Run second restart after current code writes upgraded state.", + "Record startup timing and failure artifact." + ], + "knownGaps": [ + "No fixture corpus or command yet." + ], + "demotionRule": "Cannot promote without old production fixture provenance." + }, + { + "id": "terminal-platform.live-pty-liveness", + "title": "A real Electron local PTY accepts input, survives workspace restore, resizes, and exits cleanly", + "maturity": "experimental", + "protection": "none", + "owner": "terminal-runtime", + "layer": "electron-live-pty", + "surfaces": [ + "active terminal PTY binding", + "xterm focus", + "keyboard input", + "PTY output", + "workspace hide and restore", + "resize", + "exit cleanup" + ], + "platforms": [ + "linux", + "macos" + ], + "providers": [ + "local" + ], + "coveredPlatforms": [], + "coveredProviders": [], + "coverageNotes": "Registered gap on main. The live Electron Playwright slice exists only on the pending reliability stack. It registers here with its owning split PR.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/pull/6801", + "https://github.com/stablyai/orca/pull/6842", + "https://github.com/stablyai/orca/pull/6939", + "https://github.com/stablyai/orca/pull/6989" + ], + "invariant": "An active local Electron terminal has one active PTY id listed exactly once, accepts real keyboard input through focused xterm, renders process output visibly, keeps the same live PTY across repeated workspace hide/restore cycles, applies an actual size change, and removes the old PTY from liveness state after exit.", + "oracle": "A deterministic raw-mode probe prints a ready marker, ordered key markers from real keyboard input, the active PTY id appears exactly once in pty:listSessions, remains bound to the same PTY id across two worktree switch-away/switch-back cycles, accepts more keyboard input after each restore, changes pty:getSize after viewport resize, prints the same process-visible size, and prints an exit marker; after shell exit, pty:listSessions no longer contains the old id.", + "commands": [], + "testFiles": [], + "assertionRefs": [], + "evidenceRuns": [], + "runtimeBudget": { + "p95Seconds": 75, + "scope": "focused Electron live PTY gate including e2e build/setup" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "Focused Playwright gate passed locally after adding two workspace hide/restore cycles, exact active-PTY listing, and actual resize convergence on 2026-07-02: 1 test passed, 8.6s test body, 58.6s full command including build/setup. Needs repeated CI/runtime history before promotion." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "The gate asserts PTY binding, active PTY listed exactly once, real xterm keyboard delivery, same-PTY ownership across repeated workspace hide/restore cycles, post-restore input delivery, actual pty:getSize change, matching process-visible size, and listSessions cleanup after shell exit. Needs saved intentional-break evidence before promotion." + }, + "performanceBudget": { + "required": true, + "evidence": "Must record key latencies, size snapshots, and final liveness state. The gate may use broad listing as a test oracle; product hot paths must not use broad provider listing as a synchronization primitive." + }, + "promotionCriteria": [ + "Implement without blind sleeps; wait only on PTY binding, probe markers, size marker, and liveness absence.", + "Run in soak on Linux before blocking; keep macOS as soak until stable.", + "Attach red/green evidence for focus, PTY write, resize, and exit cleanup failures." + ], + "knownGaps": [ + "No executable coverage on main yet; the slice lives on the pending fix-terminal-reliability stack.", + "Needs soak/runtime history before promotion.", + "Does not yet cover tab switch inside one worktree, scrollback after restore, or app restart persistence.", + "Does not cover Windows ConPTY, SSH, WSL, daemon, or remote-runtime providers." + ], + "demotionRule": "Cannot promote without deterministic user-visible oracle, failure artifacts, and stable runtime history." + }, + { + "id": "terminal-platform.windows-conpty-liveness", + "title": "Windows ConPTY terminals stay input-live, render-live, and geometry-live", + "maturity": "experimental", + "protection": "none", + "owner": "terminal-platform", + "layer": "windows-electron-conpty", + "surfaces": [ + "Windows ConPTY", + "PowerShell and cmd", + "keyboard protocol", + "CJK repaint", + "cursor and resize" + ], + "platforms": [ + "windows" + ], + "providers": [ + "local", + "daemon", + "wsl" + ], + "coveredPlatforms": [], + "coveredProviders": [], + "coverageNotes": "Registered gap only; no executable coverage is wired yet.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/pull/6541", + "https://github.com/stablyai/orca/pull/6858", + "https://github.com/stablyai/orca/pull/6876", + "https://github.com/stablyai/orca/pull/6968", + "https://github.com/stablyai/orca/pull/6970", + "https://github.com/stablyai/orca/pull/6999" + ], + "invariant": "Windows local and daemon terminals must spawn with the intended shell, accept normal Enter/Backspace/Arrow input after agent or TUI exit, render cursor/CJK/wide-glyph redraws without stale cells, and converge to nonzero applied size.", + "oracle": "A Windows live gate proves shell echo, command submission, keyboard reset, resize readback, cursor visibility, and CJK/wide-glyph redraw using PTY input logs plus visible buffer/pixel evidence.", + "commands": [], + "testFiles": [], + "assertionRefs": [], + "evidenceRuns": [], + "runtimeBudget": { + "p95Seconds": 90, + "scope": "Windows focused Electron ConPTY gate" + }, + "flakeHistory": { + "status": "not-started", + "evidence": "Windows live E2E remains a known weak area; gate must soak before blocking." + }, + "redGreenEvidence": { + "status": "missing", + "evidence": "Needs proof against stale keyboard-protocol mode, shell resolution failure, dropped resize, and CJK repaint regression." + }, + "performanceBudget": { + "required": true, + "evidence": "Must include input latency and no broad session listing while typing or switching terminals." + }, + "promotionCriteria": [ + "Start as Windows nightly/soak because Windows Electron E2E has been flaky.", + "Use deterministic PTY markers for input/resize and reserve screenshots for repaint diagnostics.", + "Split shell parity, keyboard reset, and CJK repaint into smaller gates if a combined gate is flaky." + ], + "knownGaps": [ + "No manifest command yet.", + "Real IME composition may require a separate lower-layer/native-text-forwarding gate." + ], + "demotionRule": "Cannot promote while Windows E2E is flaky, silently skipped, or screenshot-only." + }, + { + "id": "terminal-performance.no-hot-list-sessions", + "title": "Hot terminal interactions do not call global PTY session listing", + "maturity": "experimental", + "protection": "none", + "owner": "terminal-performance", + "layer": "ipc-count-contract", + "surfaces": [ + "typing", + "focus", + "tab switch", + "workspace switch", + "visibility resume", + "resize", + "render" + ], + "platforms": [ + "macos", + "linux", + "windows" + ], + "providers": [ + "local", + "daemon", + "ssh", + "wsl", + "remote-runtime" + ], + "coveredPlatforms": [], + "coveredProviders": [], + "coverageNotes": "Registered gap on main. The targeted-hasPty product hardening and no-hot count assertions exist only on the pending reliability stack. It registers here with its owning split PR.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/pull/7002", + "https://github.com/stablyai/orca/pull/6858" + ], + "invariant": "Typing, focus, terminal switch, workspace switch, visibility resume, resize, render, and per-pane liveness paths must not call global pty:listSessions; they must use targeted per-PTY APIs or cached provider-owned state.", + "oracle": "The current executable slice asserts targeted visibility/first-input liveness, resize re-assertion after visibility resume, light tab/active-state resume, SSH/remote skip behavior, and closed Resource Manager status badges avoid pty:listSessions; targeted hasPty/getSize calls are allowed for liveness/resize slices and forbidden for light tab/active-state resume. The full hot-path oracle still needs instrumentation around raw focus, split focus, workspace switch, render ticks, and high-session local/daemon/SSH fixtures.", + "commands": [], + "testFiles": [], + "assertionRefs": [], + "evidenceRuns": [], + "runtimeBudget": { + "p95Seconds": 20, + "scope": "unit or focused Electron count gate" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "Focused count tests now run locally; needs soak history before promotion." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "Tests assert visibility resume prefers targeted hasPty over listSessions, first input after visibility resume calls targeted hasPty once, resize re-assertion after visibility resume uses getSize/resize without listSessions, light tab switches and visible active-state resume avoid listSessions/hasPty/getSize fanout while still allowing the active PTY scheduler hint, SSH/remote broad listing is skipped, Resource Manager broad session inventory polling is scoped to the open popover rather than its closed badge, and panes close only on authoritative false. Needs broader raw focus/workspace-switch/render/high-session count coverage before promotion." + }, + "performanceBudget": { + "required": true, + "evidence": "This gate is the performance budget for global session listing in hot paths." + }, + "promotionCriteria": [ + "Add deterministic call-count instrumentation.", + "Cover typing, focus, switch, visibility resume, resize, and render separately.", + "Run with enough preserved sessions/providers to make a broad listing observable." + ], + "knownGaps": [ + "No executable coverage on main yet; the slice lives on the pending fix-terminal-reliability stack.", + "Current command covers targeted visibility/first-input liveness, resize re-assertion on visibility resume, light tab/active-state resume, SSH/remote skip behavior, and closed Resource Manager session-poll avoidance, but not every hot interaction listed in the invariant.", + "No Electron or IPC-level high-session counter gate yet proves raw focus, workspace switch, render, or high-session typing stay at zero global listSessions calls." + ], + "demotionRule": "Cannot promote if the test allows broad listing in any hot interaction path." + }, + { + "id": "terminal-observability.lifecycle-breadcrumbs", + "title": "Terminal lifecycle anomalies enter crash diagnostics as compact breadcrumbs", + "maturity": "experimental", + "protection": "none", + "owner": "terminal-runtime", + "layer": "renderer-observability", + "surfaces": [ + "terminal lifecycle", + "reattach", + "restore", + "provider ownership", + "diagnostics bundle" + ], + "platforms": [ + "macos", + "linux", + "windows" + ], + "providers": [ + "local", + "daemon", + "ssh", + "wsl", + "remote-runtime" + ], + "coveredPlatforms": [], + "coveredProviders": [], + "coverageNotes": "Registered gap on main. The crash-breadcrumb recording and its test exist only on the pending reliability stack. It registers here with its owning split PR.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/pull/6800", + "https://github.com/stablyai/orca/issues/6773" + ], + "invariant": "Terminal lifecycle anomalies around reattach, restore, provider ownership, stale liveness, and fallback routing must leave compact, deduped, privacy-safe breadcrumbs in crash diagnostics so future reports can be attributed from evidence.", + "oracle": "The current executable slice calls warnTerminalLifecycleAnomaly with terminal identity, provider, PTY id, binding epoch, and reason, then asserts the existing console warning is preserved and a compact terminal_lifecycle_anomaly crash breadcrumb is recorded once per lifecycle identity. Full pane transition traces and diagnostics-bundle artifact proof remain follow-ups.", + "commands": [], + "testFiles": [], + "assertionRefs": [], + "evidenceRuns": [], + "runtimeBudget": { + "p95Seconds": 10, + "scope": "renderer observability unit test" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "Focused unit slice passed locally once; no CI soak history yet." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "Tests would fail if lifecycle anomalies stopped recording crash breadcrumbs or stopped deduping repeated identities. Needs diagnostics-bundle artifact proof and full transition-trace evidence before promotion." + }, + "performanceBudget": { + "required": true, + "evidence": "Breadcrumb recording is deduped and capped by the existing lifecycle anomaly guard; full trace buffers must include size and event-count caps before promotion." + }, + "promotionCriteria": [ + "Add full compact pane lifecycle trace buffer with event-count caps.", + "Prove diagnostics bundle includes recent terminal lifecycle breadcrumbs or traces.", + "Add forbidden-transition tests for stale close, unknown owner fallback, and stuck zero-size panes." + ], + "knownGaps": [ + "No executable coverage on main yet; the slice lives on the pending fix-terminal-reliability stack.", + "Current command records anomaly breadcrumbs only, not a full pane lifecycle state-machine trace.", + "Current command does not prove crash/diagnostics bundle export includes the breadcrumb.", + "Current command does not assert forbidden transitions across live Electron/provider flows." + ], + "demotionRule": "Cannot promote if diagnostics are console-only, unbounded, or missing from support artifacts." + }, + { + "id": "terminal-performance.output-backpressure-budget", + "title": "Terminal output backpressure stays bounded from daemon to renderer", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-performance", + "layer": "provider-ipc-renderer-perf", + "surfaces": [ + "daemon stream", + "main PTY batching", + "renderer ACK", + "xterm scheduler", + "hidden output" + ], + "platforms": [ + "macos", + "linux", + "windows" + ], + "providers": [ + "local", + "daemon", + "ssh", + "remote-runtime" + ], + "coveredPlatforms": [ + "macos" + ], + "coveredProviders": [], + "coverageNotes": "Local macOS evidence over the main-process pending-output caps that exist on main@1282f5c2d. 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", + "https://github.com/stablyai/orca/pull/7002", + "https://github.com/stablyai/orca/pull/7054" + ], + "invariant": "High-volume terminal output must stay bounded across daemon socket writes, 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, 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" + ], + "testFiles": [ + "src/main/ipc/pty.test.ts" + ], + "assertionRefs": [ + { + "file": "src/main/ipc/pty.test.ts", + "assertions": [ + "total renderer in-flight output is capped across many PTYs", + "active PTY pending output is prioritized during renderer backpressure", + "combined pending output exceeding the interactive size limit is batched" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-07-03", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty.test.ts", + "result": "passed", + "durationSeconds": 1.5, + "summary": "1 test file(s) passed, 209 tests passed on main@1282f5c2d in a clean checkout." + } + ], + "runtimeBudget": { + "p95Seconds": 120, + "scope": "deterministic provider/IPC contract now; terminal perf soak/report gate before promotion" + }, + "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." + }, + "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, 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. Existing renderer tests cover replay/backlog slices. Needs intentional-break proof and broader hidden-output/input-latency perf artifacts before promotion." + }, + "performanceBudget": { + "required": true, + "evidence": "Suggested ceilings: renderer in-flight <=8MB total, <=512KB per PTY plus active reserve, renderer queued chars <=2MB, dropped backlogs 0, hidden restore <=1000ms, active key median/worst <=75ms/300ms in perf scenarios." + }, + "promotionCriteria": [ + "Split daemon stream backpressure into a deterministic provider/IPC contract if full E2E is flaky.", + "Record JSON artifacts with bytes, dropped counts, queue depth, timer drift, and key latency.", + "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.", + "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." + ], + "demotionRule": "Cannot promote without metric artifacts and bounded-byte assertions." + }, + { + "id": "terminal-provider.daemon-startup-degraded-contract", + "title": "Daemon startup reconcile and degraded fallback preserve provider identity", + "maturity": "experimental", + "protection": "none", + "owner": "terminal-provider", + "layer": "provider-contract", + "surfaces": [ + "daemon startup", + "degraded daemon", + "fallback PTY", + "provider ownership", + "startup restore" + ], + "platforms": [ + "macos", + "linux", + "windows" + ], + "providers": [ + "daemon", + "local" + ], + "coveredPlatforms": [], + "coveredProviders": [], + "coverageNotes": "Registered gap on main. The fail-closed degraded-daemon hardening and its contracts exist only on the pending reliability stack. It registers here with its owning split PR.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/pull/6830", + "https://github.com/stablyai/orca/pull/6866", + "https://github.com/stablyai/orca/pull/7002" + ], + "invariant": "Daemon startup reconciliation must preserve valid live daemon sessions, reap only true orphans, and degraded mode must not route an existing-looking daemon session through local fallback unless the caller explicitly marks a fresh degraded-mode spawn.", + "oracle": "The current provider-contract corpus covers valid current-worktree ids, folder/floating workspace ids, invalid removed-worktree ids, mixed live/orphan dry-run reconciliation, hyphenated worktree ids, malformed ids, daemon sessions discovered after restart, router discovery before existing-session spawn, router fail-closed behavior when legacy ownership cannot be listed or a known session has exited, degraded daemon fallback for fresh sessions, fail-closed behavior for unknown restored ids, benign inspection defaults for unknown ownership, and synthetic exits on daemon restart. Prior-worktree aliases and renamed-worktree startup wiring are promotion gaps, not current proof.", + "commands": [], + "testFiles": [], + "assertionRefs": [], + "evidenceRuns": [], + "runtimeBudget": { + "p95Seconds": 20, + "scope": "provider contract unit/integration test" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "Focused provider contract tests now run locally; needs soak history before promotion." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "Tests assert discovered daemon sessions route to the daemon, fresh degraded-mode PTYs route to fallback only when marked new, router spawn discovers uncached existing sessions before choosing an adapter, router spawn fails closed instead of falling through to current when legacy listing fails or a known session has exited, targeted hasPty discovery caches legacy ownership before later write/resize-style operations, real daemon adapter listProcesses discovery seeds targeted hasPty liveness, folder and floating terminal workspace ids survive startup reconcile when valid, restored worktree-scoped and legacy/non-scoped ids do not fall back through spawn after ownership is lost or unknown, operations on unknown or exited existing ids fail closed, startup reconcile can dry-run orphan detection without killing live sessions, and process inspection returns benign defaults for unknown ownership. Production startup reconcile wiring remains unproven." + }, + "performanceBudget": { + "required": true, + "evidence": "Reconcile must not add startup-blocking scans beyond the explicit daemon session inventory and must not leak global listing into hot paths." + }, + "promotionCriteria": [ + "Wire reconcileOnStartup or mark the production wiring gap explicitly.", + "Cover priorWorktreeIds so renamed worktrees are not falsely reaped." + ], + "knownGaps": [ + "No executable coverage on main yet; the slice lives on the pending fix-terminal-reliability stack.", + "Production startup reconcile wiring remains unproven.", + "Prior-worktree aliases and renamed-worktree startup reconcile are not covered by the current executable corpus.", + "Real daemon restart behavior is still covered only by lower-level synthetic exit and provider-contract tests." + ], + "demotionRule": "Cannot promote while restored daemon ids or routing operations can silently route to local fallback." + }, + { + "id": "terminal-provider.ssh-remote-reattach-contract", + "title": "SSH and remote terminal restore never turn unknown liveness into destructive cleanup", + "maturity": "experimental", + "protection": "none", + "owner": "terminal-provider", + "layer": "provider-contract", + "surfaces": [ + "SSH deferred restore", + "remote-runtime mirror polling", + "provider listing", + "reattach", + "unknown liveness" + ], + "platforms": [ + "macos", + "linux", + "windows" + ], + "providers": [ + "ssh", + "remote-runtime", + "wsl" + ], + "coveredPlatforms": [], + "coveredProviders": [], + "coverageNotes": "Registered gap on main. The SSH unknown-liveness hardening and deferred-reattach contracts exist only on the pending reliability stack. It registers here with its owning split PR.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/pull/6951", + "https://github.com/stablyai/orca/pull/6955", + "https://github.com/stablyai/orca/pull/6979", + "https://github.com/stablyai/orca/pull/7009" + ], + "invariant": "SSH, WSL, and remote-runtime restore paths must treat provider listing failures and unknown liveness as unknown, not dead, while still avoiding duplicate spawn and clearing expired relay leases exactly once.", + "oracle": "The current executable slice asserts saved SSH sessions use relay attach, expired relay attach does not silently fresh-spawn inside the provider, provider listing and targeted-liveness failures are unknown rather than dead, deferred passphrase cancellation does not auto-reconnect, deferred SSH attach uses saved leaf/tab session ids once connected, transient deferred reattach failure preserves the saved restore id without fresh-spawning, deferred SSH no-result cleanup clears the pending serializer without consuming the saved restore id, disconnected SSH relay ids are retained as deferred reconnect metadata and sidebar wake hints rather than attached PTY proof, and expired deferred relay state clears stale pane/tab bindings before one fresh replacement spawn. WSL and remote-runtime mirror polling require separate follow-up contracts.", + "commands": [], + "testFiles": [], + "assertionRefs": [], + "evidenceRuns": [], + "runtimeBudget": { + "p95Seconds": 45, + "scope": "provider contract plus optional SSH soak" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "Focused provider/main/renderer SSH contract tests now run locally; needs soak history before promotion." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "Tests assert SSH attach uses relay pty.attach for saved sessions, expired relay attach does not fresh-spawn in the provider, a failed SSH listProcesses observation does not clear previously learned ownership, a rejected SSH hasPty probe resolves as unknown liveness, deferred SSH passphrase cancellation does not auto-reconnect, deferred attach uses saved leaf/tab session ids once connected, transient deferred reattach failure preserves the saved session id without clearing pane/tab bindings or fresh-spawning, deferred SSH no-result cleanup clears the pending serializer without clearing pane/tab bindings or consuming the saved restore id, disconnected SSH relay ids are retained as deferred reconnect metadata and sidebar wake hints rather than attached PTY proof, and expired deferred relay state clears stale pane/tab bindings before one fresh replacement spawn. Needs WSL, remote-runtime mirror polling, live SSH, and saved intentional-break artifacts before promotion." + }, + "performanceBudget": { + "required": true, + "evidence": "Provider listing and polling must be bounded, targeted where possible, and absent from typing/focus hot paths." + }, + "promotionCriteria": [ + "Use deterministic fake providers for failure and unknown-liveness cases.", + "Keep environment-dependent live SSH tests as soak until stable.", + "Include reconnect/polling artifacts that identify provider, connection id, and lease state." + ], + "knownGaps": [ + "No executable coverage on main yet; the slice lives on the pending fix-terminal-reliability stack.", + "Current command covers store wake-hint metadata, main-process SSH provider failure semantics, provider attach/expired-attach behavior, and renderer deferred SSH reconnect/transient-failure/expired-relay fallback with mocked transports.", + "The store coverage asserts recoverability metadata, not a live attached PTY or user-visible connected state.", + "Live SSH/remote coverage remains environment-dependent.", + "WSL and remote-runtime mirror polling contracts are not wired yet.", + "The deferred SSH coverage does not boot Electron against a real SSH relay or exercise real network reconnect timing." + ], + "demotionRule": "Cannot promote if provider failure can close panes or if the oracle is screenshot-only." + }, + { + "id": "terminal-provider.wsl-restore-contract", + "title": "WSL terminals preserve launch identity, liveness, and restore boundaries", + "maturity": "experimental", + "protection": "none", + "owner": "terminal-provider", + "layer": "windows-provider-contract", + "surfaces": [ + "WSL", + "Windows host boundary", + "cwd identity", + "startup command", + "provider liveness", + "restore" + ], + "platforms": [ + "windows" + ], + "providers": [ + "wsl" + ], + "coveredPlatforms": [], + "coveredProviders": [], + "coverageNotes": "Registered gap only; no executable coverage is wired yet.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/pull/6801", + "https://github.com/stablyai/orca/pull/6830", + "https://github.com/stablyai/orca/pull/6866", + "https://github.com/stablyai/orca/pull/6951" + ], + "invariant": "WSL terminal restore must preserve Windows-host and Linux-guest path/cwd identity, shell launch args, startup command delivery, provider liveness semantics, and saved session ownership without being treated as ordinary local Windows ConPTY or generic SSH.", + "oracle": "No executable slice yet. Promotion needs a Windows provider contract plus one focused live WSL smoke proving cwd/path mapping, shell echo, startup command delivery, targeted liveness unknown handling, and restore without duplicate panes or destructive false-dead cleanup.", + "commands": [], + "testFiles": [], + "assertionRefs": [], + "evidenceRuns": [], + "runtimeBudget": { + "p95Seconds": 90, + "scope": "Windows WSL provider contract plus focused live smoke" + }, + "flakeHistory": { + "status": "not-started", + "evidence": "WSL is split out from SSH/remote planning so it cannot be mistaken for covered provider behavior." + }, + "redGreenEvidence": { + "status": "missing", + "evidence": "Needs intentional-break evidence for path/cwd mapping, startup command delivery, liveness unknown handling, and restore ownership." + }, + "performanceBudget": { + "required": true, + "evidence": "WSL restore and liveness checks must avoid broad provider scans on typing, focus, switch, render, or resize hot paths." + }, + "promotionCriteria": [ + "Start with deterministic provider contracts for path/cwd mapping and launch args.", + "Add one focused Windows live WSL smoke after provider contracts are stable.", + "Keep WSL separate from SSH and local ConPTY evidence in PR descriptions." + ], + "knownGaps": [ + "No manifest command yet.", + "No live Windows WSL CI evidence.", + "No provider contract yet separates Windows host paths from Linux guest cwd identity." + ], + "demotionRule": "Cannot promote while WSL restore is inferred from SSH, local ConPTY, or remote-runtime gates." + }, + { + "id": "terminal-output.scrollback-replay-fifo", + "title": "Replay, scrollback, and hidden-output restore preserve ordering and clear semantics", + "maturity": "experimental", + "protection": "none", + "owner": "terminal-rendering", + "layer": "renderer-unit-provider-contract", + "surfaces": [ + "snapshot replay", + "scrollback restore", + "hidden output", + "metadata-only replay", + "WebGL recovery" + ], + "platforms": [ + "macos", + "linux", + "windows" + ], + "providers": [ + "local", + "daemon", + "ssh", + "remote-runtime" + ], + "coveredPlatforms": [], + "coveredProviders": [], + "coverageNotes": "Registered gap on main. The replay FIFO/burst coalescing product change and its tests exist only on the pending reliability stack; main still uses a single pendingReplayData slot. It registers here with its owning split PR.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/pull/7012", + "https://github.com/stablyai/orca/pull/7054", + "https://github.com/stablyai/orca/pull/6949" + ], + "invariant": "Multiple replay notifications, metadata-only eager replays, hidden-output restores, and WebGL recovery must preserve FIFO ordering, apply clear-before-replay only when intended, and never overlay stale bytes on newer terminal output.", + "oracle": "The current executable slice injects overlapping replay notifications while async replay drains and asserts pre-parse coalescing, in-flight replay preservation, FIFO draining after xterm parsing starts, and bounded burst-tail coalescing. Metadata-only replay, hidden output chunks, live output interleaving, clear semantics, and stale-overlap checks remain promotion-scope gaps.", + "commands": [], + "testFiles": [], + "assertionRefs": [], + "evidenceRuns": [], + "runtimeBudget": { + "p95Seconds": 20, + "scope": "renderer unit or provider-contract test" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "Focused remote replay tests now run locally; needs soak history before promotion." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "Focused tests assert pre-parse replay coalescing, in-flight replay preservation, FIFO draining for multiple replay notifications accepted after xterm parsing starts, and bounded burst-tail coalescing that keeps the newest snapshot. Needs saved red/green evidence and broader clear/metadata/hidden-output cases before promotion." + }, + "performanceBudget": { + "required": true, + "evidence": "Replay fixes must not add unbounded renderer parsing, hidden-pane wakeups, or retry loops." + }, + "promotionCriteria": [ + "Extend deterministic multi-replay coverage before changing replay code.", + "Prove metadata-only replay does not clear restored scrollback.", + "Keep long-table/golden visual tests as supporting evidence, not the primary oracle." + ], + "knownGaps": [ + "No executable coverage on main yet; the slice lives on the pending fix-terminal-reliability stack.", + "Current command covers remote replay overlap, in-flight preservation, FIFO ordering for multiple in-progress replay notifications, and bounded burst-tail coalescing only.", + "Does not yet cover clearBeforeReplay metadata on the fresh main implementation.", + "Does not yet cover hidden-output restore plus live-output interleaving." + ], + "demotionRule": "Cannot promote without ordered replay and clear-semantics assertions." + }, + { + "id": "terminal-input.ime-and-synthetic-forwarding", + "title": "IME, native text, and synthetic input commit exactly once and do not leak preedit bytes", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-input", + "layer": "renderer-unit-platform-soak", + "surfaces": [ + "IME", + "native text forwarding", + "synthetic input", + "paste", + "keyboard bypass", + "JIS yen" + ], + "platforms": [ + "macos", + "linux", + "windows" + ], + "providers": [ + "local", + "daemon", + "ssh", + "remote-runtime" + ], + "coveredPlatforms": [ + "macos" + ], + "coveredProviders": [], + "coverageNotes": "Local macOS evidence on main@1282f5c2d; identical files to the pending stack. Real OS IME automation, Windows ConPTY post-agent reset, and the CJK/Vietnamese/Arabic matrix remain registered gaps.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/pull/6699", + "https://github.com/stablyai/orca/pull/6682", + "https://github.com/stablyai/orca/pull/6513", + "https://github.com/stablyai/orca/pull/6999" + ], + "invariant": "Composition, native text forwarding, synthetic input, paste, and platform keyboard bypass paths must not send preedit/control bytes before commit and must commit text exactly once to the intended PTY.", + "oracle": "The current renderer-unit slice asserts native text commits route to the intended PTY once, composition/preedit bookkeeping does not leak premature text, input-source classification handles synthetic/native paths, and paste/runtime forwarding avoids duplicate terminal payloads for covered fixtures. Real IME paths and the full CJK/Vietnamese/Arabic/JIS-yen matrix run in follow-up platform soak where automation is possible.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts" + ], + "testFiles": [ + "src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts", + "src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts", + "src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts" + ], + "assertionRefs": [ + { + "file": "src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts", + "assertions": [ + "native text commits route once to the intended PTY", + "composition/preedit bookkeeping does not leak premature text" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts", + "assertions": [ + "synthetic and native input-source paths are classified for covered fixtures" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts", + "assertions": [ + "paste/runtime forwarding avoids duplicate terminal payloads" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-07-03", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts", + "result": "passed", + "durationSeconds": 0.9, + "summary": "3 test file(s) passed, 63 tests passed on main@1282f5c2d in a clean checkout." + } + ], + "runtimeBudget": { + "p95Seconds": 30, + "scope": "renderer unit plus platform soak" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "Focused renderer input tests are now registered; needs soak history and platform IME evidence before promotion." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "Focused tests cover existing native-text, input-source, and paste/runtime forwarding contracts. Needs intentional-break proof for duplicate native text forwarding and composition preedit leakage, plus the broader language/platform matrix." + }, + "performanceBudget": { + "required": false, + "evidence": "Required if input forwarding adds polling, repeated event listeners, or renderer hot-path work." + }, + "promotionCriteria": [ + "Cover deterministic byte/cell oracles first.", + "Mark true OS IME automation gaps explicitly by platform.", + "Pair with Windows ConPTY keyboard reset for Windows standard-key behavior." + ], + "knownGaps": [ + "Current command is a renderer-unit slice; real OS IME automation may need manual or soak evidence.", + "Backspace/Enter during composition, JIS yen, Arabic/RTL, paste edge cases, and Windows ConPTY post-agent key reset still need representative gate coverage." + ], + "demotionRule": "Cannot promote if success is based only on DOM text without PTY byte/cell evidence." + }, + { + "id": "terminal-input.windows-conpty-keyboard-reset", + "title": "Windows ConPTY resets keyboard protocol state after agents and TUIs exit", + "maturity": "experimental", + "protection": "none", + "owner": "terminal-platform", + "layer": "windows-electron-conpty", + "surfaces": [ + "Windows ConPTY", + "keyboard protocol", + "agent exit", + "TUI exit", + "standard key input" + ], + "platforms": [ + "windows" + ], + "providers": [ + "local", + "daemon", + "wsl" + ], + "coveredPlatforms": [], + "coveredProviders": [], + "coverageNotes": "Registered gap only; no executable coverage is wired yet.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/pull/6999", + "https://github.com/stablyai/orca/pull/6858" + ], + "invariant": "After an agent or TUI exits on Windows ConPTY, ordinary Enter, Backspace, Arrow, paste, and printable keys must be delivered as standard shell input, not stale Kitty/CSI-u or application-mode bytes.", + "oracle": "A Windows gate records PTY input bytes before and after agent/TUI exit, submits real shell commands, and proves standard keys work without protocol leakage.", + "commands": [], + "testFiles": [], + "assertionRefs": [], + "evidenceRuns": [], + "runtimeBudget": { + "p95Seconds": 60, + "scope": "Windows provider-contract or focused Electron gate" + }, + "flakeHistory": { + "status": "not-started", + "evidence": "Gate is registered as a known Windows ConPTY gap; no command is wired." + }, + "redGreenEvidence": { + "status": "missing", + "evidence": "Needs intentional-break proof for stale keyboard-protocol mode after agent/TUI exit." + }, + "performanceBudget": { + "required": true, + "evidence": "Must not add per-key global session listing or repeated protocol reset work on the typing hot path." + }, + "promotionCriteria": [ + "Use byte-level PTY input evidence, not only screenshots.", + "Start as Windows soak until skip and flake behavior is known.", + "Fail promotion on silent Windows environment skips." + ], + "knownGaps": [ + "No manifest command yet.", + "Real Windows ConPTY execution is not covered by macOS/Linux CI." + ], + "demotionRule": "Cannot promote if standard-key behavior is inferred without PTY byte evidence." + }, + { + "id": "terminal-render.windows-cjk-repaint", + "title": "Windows ConPTY wide glyphs and cursor rewrites repaint without stale cells", + "maturity": "experimental", + "protection": "none", + "owner": "terminal-rendering", + "layer": "windows-electron-conpty", + "surfaces": [ + "Windows ConPTY", + "CJK", + "emoji", + "wide glyphs", + "cursor repaint", + "rewrite output" + ], + "platforms": [ + "windows" + ], + "providers": [ + "local", + "daemon", + "wsl" + ], + "coveredPlatforms": [], + "coveredProviders": [], + "coverageNotes": "Registered gap only; no executable coverage is wired yet.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/pull/6876", + "https://github.com/stablyai/orca/pull/6968", + "https://github.com/stablyai/orca/pull/6970" + ], + "invariant": "Windows terminal repaint must not leave stale cells, clipped wide glyphs, or hidden cursor state after CJK, emoji, or cursor-rewrite output.", + "oracle": "A Windows gate combines xterm buffer assertions with bounded screenshot/canvas evidence for representative CJK, emoji, and rewrite sequences.", + "commands": [], + "testFiles": [], + "assertionRefs": [], + "evidenceRuns": [], + "runtimeBudget": { + "p95Seconds": 90, + "scope": "Windows focused Electron render gate" + }, + "flakeHistory": { + "status": "not-started", + "evidence": "Gate is registered as a known Windows rendering gap; no command is wired." + }, + "redGreenEvidence": { + "status": "missing", + "evidence": "Needs intentional-break proof for stale wide-glyph repaint or cursor restore." + }, + "performanceBudget": { + "required": true, + "evidence": "Must record refresh count or frame budget so repaint fixes do not mask excessive redraw churn." + }, + "promotionCriteria": [ + "Use buffer/cell evidence as the primary oracle and screenshots as diagnostics.", + "Keep stress cases non-blocking until Windows runtime history is stable.", + "Fail promotion on silent Windows environment skips." + ], + "knownGaps": [ + "No manifest command yet.", + "No Windows CJK/emoji repaint command is wired." + ], + "demotionRule": "Cannot promote if the oracle is screenshot-only or environment-skipped." + }, + { + "id": "terminal-shell.windows-resolution-parity", + "title": "Windows local and daemon providers resolve shells and startup commands consistently", + "maturity": "experimental", + "protection": "none", + "owner": "terminal-platform", + "layer": "provider-contract-windows", + "surfaces": [ + "shell resolution", + "startup command", + "cwd", + "environment", + "local provider", + "daemon provider" + ], + "platforms": [ + "windows" + ], + "providers": [ + "local", + "daemon", + "wsl" + ], + "coveredPlatforms": [], + "coveredProviders": [], + "coverageNotes": "Registered gap only; no executable coverage is wired yet.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/pull/6541", + "https://github.com/stablyai/orca/pull/6858" + ], + "invariant": "Windows local and daemon terminals must resolve equivalent shell paths, args, cwd, env, startup command delivery, and fallback behavior for PowerShell 5/7, cmd, Git Bash, WSL, and missing pwsh.", + "oracle": "Provider-contract fixtures compare resolved command descriptors and live shell markers for supported Windows shells across local and daemon providers.", + "commands": [], + "testFiles": [], + "assertionRefs": [], + "evidenceRuns": [], + "runtimeBudget": { + "p95Seconds": 60, + "scope": "Windows provider-contract gate" + }, + "flakeHistory": { + "status": "not-started", + "evidence": "Gate is registered as a known Windows shell parity gap; no command is wired." + }, + "redGreenEvidence": { + "status": "missing", + "evidence": "Needs intentional-break proof for mismatched daemon/local shell resolution or lost startup command." + }, + "performanceBudget": { + "required": false, + "evidence": "Required if resolution adds probing or PATH scans on hot startup paths." + }, + "promotionCriteria": [ + "Start with provider-contract fixtures before live Electron coverage.", + "Cover missing-shell fallback explicitly.", + "Fail promotion on silent Windows environment skips." + ], + "knownGaps": [ + "No manifest command yet.", + "No local-vs-daemon Windows shell parity command is wired." + ], + "demotionRule": "Cannot promote if local and daemon provider behavior are tested separately without parity assertions." + }, + { + "id": "terminal-performance.input-throughput", + "title": "Terminal input latency and throughput stay inside explicit budgets", + "maturity": "experimental", + "protection": "none", + "owner": "terminal-performance", + "layer": "electron-perf", + "surfaces": [ + "typing latency", + "output throughput", + "hidden output", + "renderer CPU", + "resize churn" + ], + "platforms": [ + "macos", + "linux", + "windows" + ], + "providers": [ + "local", + "daemon", + "ssh", + "remote-runtime" + ], + "coveredPlatforms": [], + "coveredProviders": [], + "coverageNotes": "Registered gap only; no executable coverage is wired yet.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/pull/7002", + "https://github.com/stablyai/orca/pull/6858" + ], + "invariant": "Terminal typing, paste, resize, and output rendering must stay within latency and throughput budgets under realistic active and hidden-output load.", + "oracle": "Perf artifacts record median/p95/max key latency, event-loop delay, renderer CPU, output bytes/sec, resize count, hidden restore latency, queue depth, and dropped-output counts.", + "commands": [], + "testFiles": [], + "assertionRefs": [], + "evidenceRuns": [], + "runtimeBudget": { + "p95Seconds": 120, + "scope": "focused terminal perf report gate" + }, + "flakeHistory": { + "status": "not-started", + "evidence": "Existing terminal perf scripts exist, but this gate is not wired with budgets or artifacts." + }, + "redGreenEvidence": { + "status": "missing", + "evidence": "Needs intentional-break proof for hot-path listing, unbounded scheduler work, and input starvation." + }, + "performanceBudget": { + "required": true, + "evidence": "Suggested initial budgets: active key median <=75ms, worst <=300ms in perf scenarios, dropped output 0, and bounded queue depths recorded per run." + }, + "promotionCriteria": [ + "Wire existing perf scripts to emit machine-readable artifacts.", + "Make budgets platform-aware before blocking.", + "Do not promote stress variants until runtime history is stable." + ], + "knownGaps": [ + "No manifest command yet.", + "Existing perf scripts are not registered with explicit budgets or flake history." + ], + "demotionRule": "Cannot promote without metric artifacts and stable p95 runtime history." + }, + { + "id": "terminal-performance.daemon-stream-backpressure", + "title": "Daemon terminal streams respect socket backpressure under output floods", + "maturity": "experimental", + "protection": "none", + "owner": "terminal-performance", + "layer": "daemon-provider-contract", + "surfaces": [ + "daemon stream", + "socket write", + "drain", + "hidden output", + "input starvation" + ], + "platforms": [ + "macos", + "linux", + "windows" + ], + "providers": [ + "daemon" + ], + "coveredPlatforms": [], + "coveredProviders": [], + "coverageNotes": "Registered gap on main. The daemon batcher write(false)/drain contracts exist only on the pending reliability stack. It registers here with its owning split PR.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/pull/6836", + "https://github.com/stablyai/orca/pull/6858" + ], + "invariant": "Daemon terminal output floods must pause when socket writes return false, resume on drain, keep buffered bytes bounded, and not starve focused input.", + "oracle": "The current executable slice injects a slow socket and asserts write(false)/drain ordering, bounded queued daemon stream bytes, close/error listener cleanup, newest-tail preservation under sustained pressure, and flush-immediate cross-session priority ahead of unrelated background backlog on drain. Active input latency remains part of the broader perf gate.", + "commands": [], + "testFiles": [], + "assertionRefs": [], + "evidenceRuns": [], + "runtimeBudget": { + "p95Seconds": 60, + "scope": "daemon provider-contract gate" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "Focused daemon stream backpressure contract runs locally; needs soak 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 and close/error clear pending drain listeners, and queued bytes are bounded by preserving priority output plus the newest tail. Needs intentional-break artifact plus active-input latency proof before promotion." + }, + "performanceBudget": { + "required": true, + "evidence": "Must record queued bytes, drain count, write(false) count, dropped output, and active input latency." + }, + "promotionCriteria": [ + "Keep deterministic socket mocks as the blocking candidate only after soak.", + "Record byte and drain artifacts on failure.", + "Pair with the broader output-backpressure budget before blocking." + ], + "knownGaps": [ + "No executable coverage on main yet; the slice lives on the pending fix-terminal-reliability stack.", + "Current command is daemon batcher contract coverage, not live daemon/Electron perf.", + "Active input latency is not proven in live daemon/Electron perf." + ], + "demotionRule": "Cannot promote if backpressure is inferred only from renderer output success." + }, + { + "id": "terminal-performance.store-and-git-hot-paths", + "title": "Terminal-adjacent store and git polling work stays off hot interaction paths", + "maturity": "experimental", + "protection": "none", + "owner": "terminal-performance", + "layer": "renderer-main-perf-contract", + "surfaces": [ + "store selectors", + "git polling", + "startup hydration", + "terminal focus", + "tab switch", + "workspace switch", + "agent status" + ], + "platforms": [ + "macos", + "linux", + "windows" + ], + "providers": [ + "local", + "daemon", + "ssh", + "remote-runtime" + ], + "coveredPlatforms": [], + "coveredProviders": [], + "coverageNotes": "Registered gap on main. The boot-hydration counters and their tests exist only on the pending reliability stack. It registers here with its owning split PR.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/pull/7002" + ], + "invariant": "Terminal typing, focus, resize, tab/workspace switch, and agent/session restore must not trigger unbounded store projection, git status, provider listing, or per-pane polling work.", + "oracle": "The current executable slice instruments boot-time local PTY registry hydration with repo counts, local-vs-remote repo skips, worktree enumeration counts, adapter/session listing counts, registration/skipped-session counts, duration, and failure phase. The broader oracle still needs instrumentation that counts store selector recomputes, git status requests, provider listings, and session scans during scripted hot interactions with many worktrees and terminal panes.", + "commands": [], + "testFiles": [], + "assertionRefs": [], + "evidenceRuns": [], + "runtimeBudget": { + "p95Seconds": 60, + "scope": "renderer-main perf/count gate" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "Focused boot-hydration counter slice passed locally once; needs CI/runtime history before promotion." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "Tests would fail if boot hydration enumerated SSH worktrees, stopped recording provider-unavailable retry state, lost the startup counter surface, stopped recording fatal hydration failures, or missed router adapter fanout/list-failure counters. Needs intentional-break proof for git/status polling or store scans triggered by terminal hot paths." + }, + "performanceBudget": { + "required": true, + "evidence": "Current slice records boot-hydration repo, worktree, adapter, session, skip, register, and duration counters. Promotion still requires count budgets for listSessions, git status, provider scans, store recomputes, and elapsed hot-interaction time." + }, + "promotionCriteria": [ + "Use deterministic counters before broad perf scenarios.", + "Cover high-session and high-worktree fixtures.", + "Keep stress variants non-blocking until stable." + ], + "knownGaps": [ + "No executable coverage on main yet; the slice lives on the pending fix-terminal-reliability stack.", + "Current command covers boot hydration counters only, not interactive terminal hot paths.", + "No Electron count gate yet proves focus, typing, tab switch, workspace switch, render, or high-session provider fanout avoids broad store/git/provider scans." + ], + "demotionRule": "Cannot promote without count budgets and actionable artifacts." + }, + { + "id": "terminal-output.scrollback-restore", + "title": "Scrollback restore, hidden output, and replay clear semantics do not overlap stale bytes", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-rendering", + "layer": "renderer-provider-contract", + "surfaces": [ + "scrollback", + "hidden output", + "snapshot replay", + "metadata-only replay", + "clear semantics" + ], + "platforms": [ + "macos", + "linux", + "windows" + ], + "providers": [ + "local", + "daemon", + "ssh", + "remote-runtime" + ], + "coveredPlatforms": [ + "macos" + ], + "coveredProviders": [], + "coverageNotes": "Local macOS evidence over the merged #7133/#7173 restore and hidden-output ordering tests on main@1282f5c2d. The dirty-state exactness contract, normal-buffer clear semantics, and metadata-only replay remain pending-stack work.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/pull/7173", + "https://github.com/stablyai/orca/pull/7133", + "https://github.com/stablyai/orca/pull/7012", + "https://github.com/stablyai/orca/pull/7054", + "https://github.com/stablyai/orca/pull/6949" + ], + "invariant": "Hidden-output restore, snapshot replay, metadata-only replay, and clear-before-replay must preserve order and never overlay stale bytes on newer live terminal output; restoring a snapshot onto an already-dirty pane must yield a buffer exactly equal to the snapshot frame.", + "oracle": "Apply snapshots onto adversarially dirty pane states (already on the alternate screen, stale content occupying cells the new frame leaves blank, scrollback present, wide glyphs, revived sessions with restarted PTY seq counters) and assert exact buffer equality with the snapshot frame; fault injection interleaves hidden chunks, live output, metadata-only replay, and clear-before-replay, then asserts ordered terminal buffer content, clear decisions, and replay diagnostics. Marker-presence checks are not acceptable evidence on restore paths.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts" + ], + "testFiles": [ + "src/renderer/src/components/terminal-pane/pty-connection.test.ts" + ], + "assertionRefs": [ + { + "file": "src/renderer/src/components/terminal-pane/pty-connection.test.ts", + "assertions": [ + "restoring an alternate-screen snapshot clears only the alternate screen (#7133)", + "hidden Codex output is restored when the pty seq counter restarts, with or without an observed exit (#7173)", + "pending hidden restore chunks are not replayed after a terminal clear", + "a scrolled-up viewport is preserved after hidden-backlog snapshot replay" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-07-03", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts", + "result": "passed", + "durationSeconds": 8.4, + "summary": "1 test file(s) passed, 311 tests passed on main@1282f5c2d in a clean checkout." + } + ], + "runtimeBudget": { + "p95Seconds": 45, + "scope": "renderer provider-contract gate" + }, + "flakeHistory": { + "status": "not-started", + "evidence": "Remote replay FIFO has a focused slice, but the broader scrollback restore gate is not wired." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "#7133 and #7173 each proved their slices red before fix (stale alt-screen bleed-through; stale background chunk written after restore; frozen revived seq counters). The broader dirty-state exactness contract has no red runs yet." + }, + "performanceBudget": { + "required": true, + "evidence": "Must keep hidden restore bounded and avoid unbounded renderer parsing or wake loops." + }, + "promotionCriteria": [ + "Start every restore case from adversarially dirty pane state, never only fresh terminals.", + "Assert frame exactness or stale-content absence, never marker presence.", + "Build deterministic lower-layer clear and interleaving tests before broad golden E2E.", + "Use long-table/golden visual tests only as supporting evidence.", + "Record ordered buffer diagnostics on failure." + ], + "knownGaps": [ + "No manifest command yet.", + "The existing replay FIFO command is narrower than this broader scrollback restore invariant.", + "#7133's merged alt-only-clear restore tests exist on main but are not yet registered as this gate's command.", + "#7173's ordered-seq interleaving and session-revival tests in pty-connection.test.ts on main are the seed for the hidden/live interleaving slice and are unregistered until the rebase." + ], + "demotionRule": "Cannot promote without clear-semantics and hidden/live interleaving assertions." + }, + { + "id": "terminal-capability.startup-color-query", + "title": "Startup color capability queries do not leak into shell or terminal streams", + "maturity": "experimental", + "protection": "none", + "owner": "terminal-rendering", + "layer": "renderer-provider-contract", + "surfaces": [ + "OSC 10/11", + "startup capability query", + "shell stream", + "renderer stream", + "terminal colors" + ], + "platforms": [ + "macos", + "linux", + "windows" + ], + "providers": [ + "local", + "daemon", + "ssh", + "remote-runtime" + ], + "coveredPlatforms": [], + "coveredProviders": [], + "coverageNotes": "Registered gap only; no executable coverage is wired yet.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/pull/6949" + ], + "invariant": "Startup OSC 10/11 color queries are answered out of band at startup only, never leak into shell/provider output streams, and ordinary runtime OSC color queries remain renderer-handled.", + "oracle": "A provider-contract fixture records startup query replies, shell-visible bytes, renderer-visible bytes, and later runtime OSC behavior to prove no query leakage or color deadlock.", + "commands": [], + "testFiles": [], + "assertionRefs": [], + "evidenceRuns": [], + "runtimeBudget": { + "p95Seconds": 30, + "scope": "renderer provider-contract gate" + }, + "flakeHistory": { + "status": "not-started", + "evidence": "Gate is registered as a capability/startup gap; no command is wired." + }, + "redGreenEvidence": { + "status": "missing", + "evidence": "Needs intentional-break proof for leaked startup OSC replies or disabled runtime OSC handling." + }, + "performanceBudget": { + "required": false, + "evidence": "Required if capability probing adds retry loops or startup polling." + }, + "promotionCriteria": [ + "Assert shell/provider byte streams directly.", + "Cover startup-only and runtime OSC paths separately.", + "Keep screenshot evidence diagnostic only." + ], + "knownGaps": [ + "No manifest command yet.", + "No startup color-query contract is wired." + ], + "demotionRule": "Cannot promote if success is based only on absence of visible artifacts." + }, + { + "id": "terminal-runtime.mobile-stream-budget", + "title": "Runtime and mobile terminal streams keep snapshot and live-output bytes bounded", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-runtime", + "layer": "runtime-rpc-contract", + "surfaces": [ + "mobile terminal", + "runtime RPC", + "binary multiplex", + "JSON subscribe fallback", + "snapshot buffering" + ], + "platforms": [ + "macos", + "linux", + "windows", + "mobile" + ], + "providers": [ + "remote-runtime", + "ssh", + "local", + "daemon" + ], + "coveredPlatforms": [ + "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.", + "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.", + "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" + ], + "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" + ], + "assertionRefs": [ + { + "file": "src/main/runtime/rpc/terminal-subscribe-buffer.test.ts", + "assertions": [ + "legacy binary output queued during initial snapshot serialization stays bounded", + "stale mobile resize re-stream completions are dropped for legacy binary streams", + "aborted stream signals do not register stale listeners" + ] + }, + { + "file": "src/main/runtime/rpc/terminal-output-batching.test.ts", + "assertions": [ + "desktop output bursts coalesce before stream emission", + "large binary output is encoded lazily before the first output frame" + ] + }, + { + "file": "src/main/runtime/rpc/terminal-multiplex.test.ts", + "assertions": [ + "requested snapshots fall back smaller when serialized data exceeds the send budget", + "oversized live output frames are bounded for subscribed binary streams", + "multibyte live output flushes when encoded bytes reach the batch budget" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-07-03", + "runner": "local", + "platform": "macos", + "command": "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", + "result": "passed", + "durationSeconds": 3.8, + "summary": "3 test file(s) passed, 28 tests passed on main@1282f5c2d in a clean checkout." + } + ], + "runtimeBudget": { + "p95Seconds": 20, + "scope": "runtime RPC contract test" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "Focused runtime RPC contract tests passed locally on 2026-07-02; needs soak history before promotion." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "Tests cover mobile initial snapshot byte downgrade, requested binary snapshot byte downgrade, pending live-output cap while snapshot loads, output chunk size, output coalescing, abort cleanup, and stale resize re-stream suppression. Needs intentional-break proof plus JSON fallback coverage before promotion." + }, + "performanceBudget": { + "required": true, + "evidence": "This gate is the byte and batching budget for runtime/mobile terminal streaming." + }, + "promotionCriteria": [ + "Gate binary multiplex first.", + "Decide whether JSON fallback is supported, gated, or deprecated.", + "Record byte counts and order/cursor artifacts on failure." + ], + "knownGaps": [ + "Current command covers binary runtime/mobile stream contracts only.", + "Legacy JSON subscribe parity is undecided." + ], + "demotionRule": "Cannot promote while a supported stream path has uncapped snapshot or live-output buffering." + }, + { + "id": "terminal-mirror.parser-parity", + "title": "Renderer xterm and main headless mirror parse identical bytes into identical cells", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-rendering", + "layer": "renderer-main-parity-contract", + "surfaces": [ + "main mirror buffer", + "snapshot restore", + "terminal reads", + "mobile streams", + "unicode width", + "parser configuration" + ], + "platforms": [ + "macos", + "linux", + "windows" + ], + "providers": [ + "local", + "daemon", + "ssh", + "remote-runtime" + ], + "coveredPlatforms": [ + "macos" + ], + "coveredProviders": [], + "coverageNotes": "Local macOS evidence over #7148's width-parity oracle on main@1282f5c2d. A broader byte corpus, a shared parser-construction assertion over terminal-unicode-provider.ts/pane-terminal-options.ts, and recorded agent-session corpora remain gaps.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/pull/7148", + "https://github.com/stablyai/orca/pull/7133" + ], + "invariant": "The main-process headless terminal mirror and the renderer xterm must parse identical byte streams into cell-identical buffers (unicode width tables, joining behavior, modes, and addon-visible semantics), so snapshots, restores, reads, and mobile streams never encode a frame the renderer would not have produced.", + "oracle": "Feed identical byte corpora (emoji/ZWJ/wide glyphs, positioned overwrites after wide characters, alternate-screen transitions, wrapped lines) to both parser configurations and assert cell-identical visible buffers; both parsers must be constructed from one shared configuration module so one-sided drift fails the gate.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/headless-emulator-unicode-width.test.ts" + ], + "testFiles": [ + "src/main/daemon/headless-emulator-unicode-width.test.ts" + ], + "assertionRefs": [ + { + "file": "src/main/daemon/headless-emulator-unicode-width.test.ts", + "assertions": [ + "emoji advance two cells in the headless mirror so positioned writes land like the renderer (#7148)", + "ZWJ emoji join into one wide pair like the renderer provider (#7148)" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-07-03", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/headless-emulator-unicode-width.test.ts", + "result": "passed", + "durationSeconds": 0.7, + "summary": "1 test file(s) passed, 2 tests passed on main@1282f5c2d in a clean checkout." + } + ], + "runtimeBudget": { + "p95Seconds": 30, + "scope": "shared parser-parity unit gate" + }, + "flakeHistory": { + "status": "not-started", + "evidence": "Deterministic unit-layer corpus; no timing dependence expected." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "#7148 proved this red before its fix (positioned overwrite after emoji landed shifted in the mirror). Intentional-drift proof for the shared provider is still missing." + }, + "performanceBudget": { + "required": true, + "evidence": "Shared parser construction must not add renderer or emulator startup cost; the corpus stays a bounded unit fixture." + }, + "promotionCriteria": [ + "Register #7148's merged width test (src/main/daemon/headless-emulator-unicode-width.test.ts) plus a shared-construction assertion over src/shared/terminal-unicode-provider.ts and pane-terminal-options.ts as the first command during the stack rebase.", + "Extend the corpus with recorded real agent-session byte streams.", + "Promote only on aggregated reliability-gates CI history." + ], + "knownGaps": [ + "No manifest command yet; the seed test exists on fresh main but not at this branch's merge-base.", + "Width parity is protected only by #7148's single test; no broader corpus or shared-construction assertion covers the other parser-config axes.", + "Corpus does not yet include recorded live agent sessions.", + "Parity at identical configuration cannot catch state-synchronization divergence such as #7192's stale-width mirror; geometry fan-out and reflow ordering are owned by terminal-geometry.visible-convergence." + ], + "demotionRule": "Cannot promote without cell-exact parity assertions over a wide-glyph corpus and shared-construction proof." + }, + { + "id": "terminal-observability.restore-convergence-selfcheck", + "title": "Post-restore convergence self-check flags renderer/mirror divergence as an anomaly", + "maturity": "experimental", + "protection": "none", + "owner": "terminal-rendering", + "layer": "renderer-observability", + "surfaces": [ + "hidden-output restore", + "snapshot replay", + "anomaly breadcrumbs", + "telemetry" + ], + "platforms": [ + "macos", + "linux", + "windows" + ], + "providers": [ + "local", + "daemon", + "ssh", + "remote-runtime" + ], + "coveredPlatforms": [], + "coveredProviders": [], + "coverageNotes": "Registered gap only; the anomaly-breadcrumb machinery exists in this branch but no convergence probe is implemented.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/pull/7133", + "https://github.com/stablyai/orca/pull/7054" + ], + "invariant": "After every hidden-to-visible reveal settles — whether or not a restore was triggered — the renderer buffer must converge with the current main-mirror state; divergence is recorded as a compact, content-free terminal_lifecycle_anomaly breadcrumb and counted in telemetry instead of failing silently. Restore-scoped checking is insufficient: #7173's frozen-output variant restores faithfully and then silently drops later hidden output, so it never triggers another restore.", + "oracle": "Deterministic tests inject forced divergence in both shapes — stale bytes written after a faithful restore, and a frozen pane whose reveal triggers no restore — and assert exactly one deduped breadcrumb with row-hash evidence and no terminal content; the production probe is a bounded row-hash sample comparison against current mirror rows that runs once per reveal with no polling and a strict time budget.", + "commands": [], + "testFiles": [], + "assertionRefs": [], + "evidenceRuns": [], + "runtimeBudget": { + "p95Seconds": 30, + "scope": "renderer observability unit gate" + }, + "flakeHistory": { + "status": "not-started", + "evidence": "Probe and tests not yet implemented." + }, + "redGreenEvidence": { + "status": "missing", + "evidence": "Needs an intentionally divergent restore fixture proving the probe fires, dedupes, and stays silent on faithful restores." + }, + "performanceBudget": { + "required": true, + "evidence": "One bounded comparison per reveal; no polling, no hot-path work, no content serialization beyond hashed row samples." + }, + "promotionCriteria": [ + "Prove the probe is content-free and privacy-safe (hashes and indices only).", + "Prove zero false positives across the existing restore test corpus before enabling telemetry counting.", + "Watch anomaly telemetry after each terminal release as the in-production regression signal." + ], + "knownGaps": [ + "No manifest command yet.", + "Probe not implemented; #7133-class and #7173-class corruption is currently silent in production.", + "Pixel-level divergence is out of scope for this probe (see terminal-render.pixel-refresh-repair).", + "Renderer-vs-mirror comparison cannot see corruption upstream of the mirror itself (#7192's stale-width mirror); mirror-vs-PTY truth is owned by terminal-geometry.visible-convergence." + ], + "demotionRule": "Disable the probe if it exceeds its per-reveal budget or produces false-positive anomaly noise." + }, + { + "id": "terminal-render.pixel-refresh-repair", + "title": "Forced repaint does not change revealed pane pixels", + "maturity": "experimental", + "protection": "none", + "owner": "terminal-rendering", + "layer": "electron-visual-golden", + "surfaces": [ + "WebGL rendering", + "tab reveal", + "worktree resume", + "window wake", + "render model" + ], + "platforms": [ + "macos", + "linux", + "windows" + ], + "providers": [ + "local", + "daemon" + ], + "coveredPlatforms": [], + "coveredProviders": [], + "coverageNotes": "Registered gap only; the live repro harness with a refresh-repair oracle exists but is not productized into the release-blocking terminal-rendering-golden suite.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/pull/7133", + "https://github.com/stablyai/orca/pull/7054" + ], + "invariant": "After any reveal, restore, or wake, the pixels a pane displays must equal what a forced full model-invalidating repaint would draw from the buffer; a material refresh-repair diff means stale rendering escaped, whatever the mechanism.", + "oracle": "Screenshot the revealed pane, force a model-invalidating full repaint, screenshot again, and assert the bounded pixel diff is immaterial; pair with a JS-level render-model-vs-buffer comparison. Compositor screenshots only, because WebGL canvas readback is blank with preserveDrawingBuffer disabled.", + "commands": [], + "testFiles": [], + "assertionRefs": [], + "evidenceRuns": [], + "runtimeBudget": { + "p95Seconds": 300, + "scope": "release-blocking golden Electron visual test" + }, + "flakeHistory": { + "status": "not-started", + "evidence": "Runs nightly only while the per-platform diff threshold stabilizes; the destination is the release-blocking terminal-rendering-golden suite in release-cut.yml." + }, + "redGreenEvidence": { + "status": "missing", + "evidence": "Needs an intentionally fossilized render model (skip the model invalidation on reveal) proving the refresh-repair diff fires." + }, + "performanceBudget": { + "required": true, + "evidence": "Soak-only; must not add product-path repaints beyond the existing reveal hardening." + }, + "promotionCriteria": [ + "Productize the existing stale-render repro harness with the refresh-repair oracle and failure artifacts.", + "Establish a stable material-diff threshold across platforms via nightly runs.", + "Promotion means membership in the release-blocking terminal-rendering-golden suite that release-cut.yml runs against every release tag; never PR-blocking.", + "Register the gate command as a direct spec-file invocation; the checker rejects --grep tag selectors, so do not reuse the existing --grep-based golden script as the command." + ], + "knownGaps": [ + "No manifest command yet.", + "Buffer-clean, pixels-stale variants are otherwise invisible to every buffer oracle in the manifest." + ], + "demotionRule": "Drop from the golden suite back to nightly diagnostic if diff thresholds cannot be kept stable across CI runners." + } + ] +} diff --git a/config/scripts/check-reliability-gates.mjs b/config/scripts/check-reliability-gates.mjs new file mode 100644 index 00000000000..8cf61c5cb2b --- /dev/null +++ b/config/scripts/check-reliability-gates.mjs @@ -0,0 +1,431 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import { pathToFileURL } from 'node:url' +import process from 'node:process' + +import { parse, printParseErrorCode } from 'jsonc-parser' + +const MANIFEST_PATH = path.join('config', 'reliability-gates.jsonc') +const RED_GREEN_STATUSES = new Set(['missing', 'partial', 'complete', 'not-required']) +const FLAKE_STATUSES = new Set(['not-started', 'unknown', 'soaking', 'stable', 'flaky']) +const PROTECTION_LEVELS = new Set(['none', 'partial', 'active']) +const EVIDENCE_RUN_RESULTS = new Set(['passed', 'failed', 'skipped']) +const EVIDENCE_RUNNERS = new Set(['local', 'ci', 'soak', 'manual']) + +function isRecord(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +function isNonEmptyString(value) { + return typeof value === 'string' && value.trim().length > 0 +} + +function hasNonEmptyStringArray(value) { + return Array.isArray(value) && value.length > 0 && value.every(isNonEmptyString) +} + +function requireNonEmptyString(gate, field, failures) { + if (!isNonEmptyString(gate[field])) { + failures.push(`${gate.id ?? ''}: ${field} must be a non-empty string`) + } +} + +function requireStringArray(gate, field, failures) { + if (!hasNonEmptyStringArray(gate[field])) { + failures.push(`${gate.id ?? ''}: ${field} must be a non-empty string array`) + } +} + +function requireStringArrayAllowEmpty(gate, field, failures) { + if (!Array.isArray(gate[field]) || !gate[field].every(isNonEmptyString)) { + failures.push(`${gate.id ?? ''}: ${field} must be an array of strings`) + } +} + +function requireNonNegativeNumber(record, field, owner, failures) { + if (!Number.isFinite(record[field]) || record[field] < 0) { + failures.push(`${owner}.${field} must be a non-negative number`) + } +} + +function validatePolicy(manifest, failures) { + if (!isRecord(manifest.policy)) { + failures.push('policy must be an object') + return new Set() + } + if (!hasNonEmptyStringArray(manifest.policy.maturityLevels)) { + failures.push('policy.maturityLevels must be a non-empty string array') + } + if (!isRecord(manifest.policy.blockingPromotion)) { + failures.push('policy.blockingPromotion must be an object') + } else { + for (const field of ['minimumSoakRuns', 'minimumSoakDays', 'maximumUnexplainedFlakes']) { + requireNonNegativeNumber( + manifest.policy.blockingPromotion, + field, + 'policy.blockingPromotion', + failures + ) + } + } + return new Set( + Array.isArray(manifest.policy.maturityLevels) + ? manifest.policy.maturityLevels.filter(isNonEmptyString) + : [] + ) +} + +function validateRuntimeBudget(gate, failures) { + if (!isRecord(gate.runtimeBudget)) { + failures.push(`${gate.id}: runtimeBudget must be an object`) + return + } + if (!Number.isFinite(gate.runtimeBudget.p95Seconds) || gate.runtimeBudget.p95Seconds <= 0) { + failures.push(`${gate.id}: runtimeBudget.p95Seconds must be a positive number`) + } + if (!isNonEmptyString(gate.runtimeBudget.scope)) { + failures.push(`${gate.id}: runtimeBudget.scope must be a non-empty string`) + } +} + +function validateEvidence(gate, field, allowedStatuses, failures) { + const evidence = gate[field] + if (!isRecord(evidence)) { + failures.push(`${gate.id}: ${field} must be an object`) + return + } + if (!allowedStatuses.has(evidence.status)) { + failures.push(`${gate.id}: ${field}.status is invalid`) + } + if (!isNonEmptyString(evidence.evidence)) { + failures.push(`${gate.id}: ${field}.evidence must be a non-empty string`) + } +} + +function validatePerformanceBudget(gate, failures) { + if (!isRecord(gate.performanceBudget)) { + failures.push(`${gate.id}: performanceBudget must be an object`) + return + } + if (typeof gate.performanceBudget.required !== 'boolean') { + failures.push(`${gate.id}: performanceBudget.required must be boolean`) + } + if (!isNonEmptyString(gate.performanceBudget.evidence)) { + failures.push(`${gate.id}: performanceBudget.evidence must be a non-empty string`) + } +} + +function commandUsesBrittleTestSelector(command) { + return /(?:^|\s)(?:-t|--testNamePattern|--grep)(?:=|\s)/.test(command) +} + +function validateCommandCoverage(gate, failures) { + if (!hasNonEmptyStringArray(gate.commands) || !hasNonEmptyStringArray(gate.testFiles)) { + return + } + for (const testFile of gate.testFiles) { + if (!gate.commands.some((command) => command.includes(testFile))) { + failures.push(`${gate.id}: test file is not referenced by any gate command: ${testFile}`) + } + } +} + +function validateProtection(gate, failures) { + if (!PROTECTION_LEVELS.has(gate.protection)) { + failures.push(`${gate.id}: protection must be one of none, partial, or active`) + return + } + const hasCommands = hasNonEmptyStringArray(gate.commands) + const hasTestFiles = hasNonEmptyStringArray(gate.testFiles) + if (gate.protection === 'none' && (hasCommands || hasTestFiles)) { + failures.push(`${gate.id}: protection none gates must not declare commands or testFiles`) + } + if (gate.protection === 'partial' && (!hasCommands || !hasTestFiles)) { + failures.push(`${gate.id}: protection partial gates must declare commands and testFiles`) + } + if (gate.protection === 'active') { + if (gate.maturity !== 'blocking') { + failures.push(`${gate.id}: protection active is reserved for blocking gates`) + } + if (!hasCommands || !hasTestFiles) { + failures.push(`${gate.id}: protection active gates must declare commands and testFiles`) + } + if (gate.flakeHistory?.status !== 'stable') { + failures.push(`${gate.id}: protection active gates must have stable flakeHistory`) + } + if (!hasCompleteRedGreenEvidence(gate)) { + failures.push(`${gate.id}: protection active gates must have complete red/green evidence`) + } + } + if (!hasCommands && gate.protection !== 'none') { + failures.push(`${gate.id}: commandless gates must declare protection none`) + } +} + +function isIsoDate(value) { + return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value) +} + +// Why: cross-field validators run even after an earlier type check flagged a +// malformed field, so coerce to an array before `.includes` to report the error +// instead of throwing on hand-edited manifests. +function asArray(value) { + return Array.isArray(value) ? value : [] +} + +function hasCompleteRedGreenEvidence(gate) { + return ['complete', 'not-required'].includes(gate.redGreenEvidence?.status) +} + +function validateEvidenceRun(gate, run, index, failures) { + const owner = `${gate.id}: evidenceRuns[${index}]` + if (!isRecord(run)) { + failures.push(`${owner} must be an object`) + return + } + if (!isIsoDate(run.date)) { + failures.push(`${owner}.date must be YYYY-MM-DD`) + } + if (!EVIDENCE_RUNNERS.has(run.runner)) { + failures.push(`${owner}.runner must be one of local, ci, soak, or manual`) + } + if (!isNonEmptyString(run.platform)) { + failures.push(`${owner}.platform must be a non-empty string`) + } + if (!EVIDENCE_RUN_RESULTS.has(run.result)) { + failures.push(`${owner}.result must be one of passed, failed, or skipped`) + } + if (!isNonEmptyString(run.command)) { + failures.push(`${owner}.command must be a non-empty string`) + } else if (!asArray(gate.commands).includes(run.command)) { + failures.push(`${owner}.command must match one of the gate commands`) + } + if (!Number.isFinite(run.durationSeconds) || run.durationSeconds < 0) { + failures.push(`${owner}.durationSeconds must be a non-negative number`) + } + if (!isNonEmptyString(run.summary)) { + failures.push(`${owner}.summary must be a non-empty string`) + } +} + +function validateEvidenceRuns(gate, failures) { + if (!Array.isArray(gate.evidenceRuns)) { + failures.push(`${gate.id}: evidenceRuns must be an array`) + return + } + gate.evidenceRuns.forEach((run, index) => validateEvidenceRun(gate, run, index, failures)) + if ( + ['partial', 'active'].includes(gate.protection) && + !gate.evidenceRuns.some((run) => isRecord(run) && run.result === 'passed') + ) { + failures.push(`${gate.id}: protection ${gate.protection} gates need a passed evidence run`) + } + if (gate.protection === 'none' && gate.evidenceRuns.length > 0) { + failures.push(`${gate.id}: protection none gates must not declare evidenceRuns`) + } +} + +function validateAssertionRef(gate, ref, index, failures) { + const owner = `${gate.id}: assertionRefs[${index}]` + if (!isRecord(ref)) { + failures.push(`${owner} must be an object`) + return + } + if (!isNonEmptyString(ref.file)) { + failures.push(`${owner}.file must be a non-empty string`) + } else if (!asArray(gate.testFiles).includes(ref.file)) { + failures.push(`${owner}.file must be one of the gate testFiles`) + } + if (!hasNonEmptyStringArray(ref.assertions)) { + failures.push(`${owner}.assertions must be a non-empty string array`) + } +} + +function validateAssertionRefs(gate, failures) { + if (!Array.isArray(gate.assertionRefs)) { + failures.push(`${gate.id}: assertionRefs must be an array`) + return + } + gate.assertionRefs.forEach((ref, index) => validateAssertionRef(gate, ref, index, failures)) + if (['partial', 'active'].includes(gate.protection) && gate.assertionRefs.length === 0) { + failures.push(`${gate.id}: protection ${gate.protection} gates need assertionRefs`) + } + if (gate.protection === 'none' && gate.assertionRefs.length > 0) { + failures.push(`${gate.id}: protection none gates must not declare assertionRefs`) + } +} + +function validateCoveredScope(gate, failures) { + requireStringArrayAllowEmpty(gate, 'coveredPlatforms', failures) + requireStringArrayAllowEmpty(gate, 'coveredProviders', failures) + requireNonEmptyString(gate, 'coverageNotes', failures) + + if (!Array.isArray(gate.coveredPlatforms) || !Array.isArray(gate.coveredProviders)) { + return + } + for (const platform of gate.coveredPlatforms) { + if (!asArray(gate.platforms).includes(platform)) { + failures.push(`${gate.id}: covered platform is outside risk scope: ${platform}`) + } + } + for (const provider of gate.coveredProviders) { + if (!asArray(gate.providers).includes(provider)) { + failures.push(`${gate.id}: covered provider is outside risk scope: ${provider}`) + } + } + if ( + ['partial', 'active'].includes(gate.protection) && + gate.evidenceRuns.some((run) => isRecord(run) && run.result === 'passed') + ) { + const evidencePlatforms = new Set( + gate.evidenceRuns + .filter((run) => isRecord(run) && run.result === 'passed') + .map((run) => run.platform) + ) + for (const platform of evidencePlatforms) { + if (!gate.coveredPlatforms.includes(platform)) { + failures.push( + `${gate.id}: coveredPlatforms must include passed evidence platform ${platform}` + ) + } + } + } + if ( + gate.protection === 'none' && + (gate.coveredPlatforms.length > 0 || gate.coveredProviders.length > 0) + ) { + failures.push(`${gate.id}: protection none gates must not declare covered scope`) + } +} + +async function fileExists(root, filePath) { + try { + const stat = await fs.stat(path.join(root, filePath)) + return stat.isFile() + } catch { + return false + } +} + +async function validateGate(gate, maturities, root) { + const failures = [] + if (!isRecord(gate)) { + return ['gate entry must be an object'] + } + for (const field of ['id', 'title', 'owner', 'layer', 'invariant', 'oracle', 'demotionRule']) { + requireNonEmptyString(gate, field, failures) + } + if (!maturities.has(gate.maturity)) { + failures.push(`${gate.id ?? ''}: maturity is invalid`) + } + for (const field of [ + 'surfaces', + 'platforms', + 'providers', + 'motivatingLinks', + 'promotionCriteria' + ]) { + requireStringArray(gate, field, failures) + } + if (!Array.isArray(gate.commands) || !gate.commands.every(isNonEmptyString)) { + failures.push(`${gate.id}: commands must be an array of strings`) + } else if (gate.commands.some(commandUsesBrittleTestSelector)) { + failures.push( + `${gate.id}: commands must not rely on title selectors (-t, --grep, or --testNamePattern)` + ) + } + if (!Array.isArray(gate.testFiles) || !gate.testFiles.every(isNonEmptyString)) { + failures.push(`${gate.id}: testFiles must be an array of strings`) + } else { + for (const testFile of gate.testFiles) { + if (!(await fileExists(root, testFile))) { + failures.push(`${gate.id}: test file does not exist: ${testFile}`) + } + } + } + validateCommandCoverage(gate, failures) + if (['soak', 'blocking'].includes(gate.maturity)) { + if (!hasNonEmptyStringArray(gate.commands)) { + failures.push(`${gate.id}: ${gate.maturity} gates must declare at least one command`) + } + if (!hasNonEmptyStringArray(gate.testFiles)) { + failures.push(`${gate.id}: ${gate.maturity} gates must declare at least one test file`) + } + if (!['soaking', 'stable'].includes(gate.flakeHistory?.status)) { + failures.push(`${gate.id}: ${gate.maturity} gates must have soaking or stable flakeHistory`) + } + if (!hasCompleteRedGreenEvidence(gate)) { + failures.push(`${gate.id}: ${gate.maturity} gates must have complete red/green evidence`) + } + } + validateRuntimeBudget(gate, failures) + validateEvidence(gate, 'flakeHistory', FLAKE_STATUSES, failures) + validateEvidence(gate, 'redGreenEvidence', RED_GREEN_STATUSES, failures) + validateProtection(gate, failures) + validateEvidenceRuns(gate, failures) + validateAssertionRefs(gate, failures) + validateCoveredScope(gate, failures) + validatePerformanceBudget(gate, failures) + if (!Array.isArray(gate.knownGaps) || !gate.knownGaps.every(isNonEmptyString)) { + failures.push(`${gate.id}: knownGaps must be an array of strings`) + } + return failures +} + +export async function main(root = process.cwd()) { + const manifestPath = path.join(root, MANIFEST_PATH) + let raw + try { + raw = await fs.readFile(manifestPath, 'utf8') + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.error(`${MANIFEST_PATH}: unable to read manifest (${message})`) + return 1 + } + const parseErrors = [] + const manifest = parse(raw, parseErrors, { allowTrailingComma: true }) + if (parseErrors.length > 0) { + for (const error of parseErrors) { + console.error( + `${MANIFEST_PATH}: JSONC parse error ${printParseErrorCode(error.error)} at offset ${error.offset}` + ) + } + return 1 + } + const failures = [] + if (!isRecord(manifest)) { + failures.push('manifest must be an object') + } else { + if (manifest.schemaVersion !== 1) { + failures.push('schemaVersion must be 1') + } + const maturities = validatePolicy(manifest, failures) + if (!Array.isArray(manifest.gates) || manifest.gates.length === 0) { + failures.push('gates must be a non-empty array') + } else { + const seenIds = new Set() + for (const gate of manifest.gates) { + if (isRecord(gate) && isNonEmptyString(gate.id)) { + if (seenIds.has(gate.id)) { + failures.push(`${gate.id}: duplicate gate id`) + } + seenIds.add(gate.id) + } + failures.push(...(await validateGate(gate, maturities, root))) + } + } + } + if (failures.length > 0) { + console.error(`Reliability gate manifest check failed with ${failures.length} issue(s):`) + for (const failure of failures) { + console.error(`- ${failure}`) + } + return 1 + } + console.log(`Reliability gate manifest check passed for ${manifest.gates.length} gate(s).`) + return 0 +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + process.exit(await main()) +} diff --git a/config/scripts/check-reliability-gates.test.mjs b/config/scripts/check-reliability-gates.test.mjs new file mode 100644 index 00000000000..b9f2293a15a --- /dev/null +++ b/config/scripts/check-reliability-gates.test.mjs @@ -0,0 +1,460 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { main } from './check-reliability-gates.mjs' + +const tempDirs = [] + +function makeTempRoot(manifest) { + const root = mkdtempSync(path.join(tmpdir(), 'orca-reliability-gates-')) + tempDirs.push(root) + const configDir = path.join(root, 'config') + mkdirSync(configDir, { recursive: true }) + writeFileSync(path.join(configDir, 'reliability-gates.jsonc'), manifest, 'utf8') + writeFileSync(path.join(root, 'some.test.ts'), '', 'utf8') + return root +} + +function validManifest(overrides = {}) { + return JSON.stringify( + { + schemaVersion: 1, + policy: { + maturityLevels: ['experimental', 'soak', 'blocking', 'accepted-gap', 'deprecated'], + blockingPromotion: { + minimumSoakRuns: 100, + minimumSoakDays: 14, + maximumUnexplainedFlakes: 0 + } + }, + gates: [ + { + id: 'terminal-session.snapshot-freshness', + title: 'Stale liveness snapshots cannot close newer PTY bindings', + maturity: 'soak', + protection: 'partial', + owner: 'terminal-runtime', + layer: 'renderer-unit', + surfaces: ['terminal lifecycle'], + platforms: ['macos', 'linux', 'windows'], + providers: ['local', 'daemon'], + coveredPlatforms: ['macos'], + coveredProviders: ['local'], + coverageNotes: 'One local macOS unit run covers the local decision-layer fixture only.', + motivatingLinks: ['https://github.com/stablyai/orca/issues/6773'], + invariant: 'A stale snapshot cannot close a newer binding.', + oracle: 'The test rejects reconciliation when the binding is newer than the snapshot.', + commands: ['pnpm exec vitest run some.test.ts'], + testFiles: ['some.test.ts'], + assertionRefs: [ + { + file: 'some.test.ts', + assertions: ['stale liveness snapshots do not close newer PTY bindings'] + } + ], + evidenceRuns: [ + { + date: '2026-07-02', + runner: 'local', + platform: 'macos', + command: 'pnpm exec vitest run some.test.ts', + result: 'passed', + durationSeconds: 1.2, + summary: '1 file passed.' + } + ], + runtimeBudget: { + p95Seconds: 10, + scope: 'local unit test' + }, + flakeHistory: { + status: 'soaking', + evidence: 'Soak history exists.' + }, + redGreenEvidence: { + status: 'complete', + evidence: 'Fails when the guard is removed and passes with the fix.' + }, + performanceBudget: { + required: true, + evidence: 'Perf measurement is required before blocking promotion.' + }, + promotionCriteria: ['Collect soak history.'], + knownGaps: ['Needs an Electron survival test.'], + demotionRule: 'Demote on unexplained flakes.', + ...overrides + } + ] + }, + null, + 2 + ) +} + +afterEach(() => { + vi.restoreAllMocks() + while (tempDirs.length > 0) { + rmSync(tempDirs.pop(), { force: true, recursive: true }) + } +}) + +describe('check-reliability-gates', () => { + it('accepts the checked-in manifest', async () => { + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await expect(main(process.cwd())).resolves.toBe(0) + }) + + it('rejects soak gates without executable commands', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const root = makeTempRoot(validManifest({ commands: [] })) + + await expect(main(root)).resolves.toBe(1) + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('Reliability gate manifest check failed') + ) + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('soak gates must declare at least one command') + ) + }) + + it('rejects soak gates without test files', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const root = makeTempRoot(validManifest({ testFiles: [] })) + + await expect(main(root)).resolves.toBe(1) + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('soak gates must declare at least one test file') + ) + }) + + it('rejects command-backed gates that select tests by title', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const root = makeTempRoot( + validManifest({ maturity: 'experimental', commands: ['pnpm vitest -t flaky-title'] }) + ) + + await expect(main(root)).resolves.toBe(1) + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('commands must not rely on title selectors') + ) + }) + + it('rejects gates without a protection level', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const manifest = JSON.parse(validManifest()) + delete manifest.gates[0].protection + const root = makeTempRoot(JSON.stringify(manifest, null, 2)) + + await expect(main(root)).resolves.toBe(1) + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('protection must be one of none, partial, or active') + ) + }) + + it('rejects commandless gates unless they declare protection none', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const root = makeTempRoot( + validManifest({ + maturity: 'experimental', + protection: 'partial', + commands: [], + testFiles: [], + flakeHistory: { status: 'not-started', evidence: 'Planning entry only.' }, + redGreenEvidence: { status: 'missing', evidence: 'No executable proof yet.' } + }) + ) + + await expect(main(root)).resolves.toBe(1) + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('commandless gates must declare protection none') + ) + }) + + it('rejects command-backed gates marked as no protection', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const root = makeTempRoot(validManifest({ maturity: 'experimental', protection: 'none' })) + + await expect(main(root)).resolves.toBe(1) + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('protection none gates must not declare commands or testFiles') + ) + }) + + it('reserves active protection for stable blocking gates', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const root = makeTempRoot(validManifest({ maturity: 'experimental', protection: 'active' })) + + await expect(main(root)).resolves.toBe(1) + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('protection active is reserved for blocking gates') + ) + }) + + it('rejects partial gates without a passed evidence run', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const root = makeTempRoot(validManifest({ maturity: 'experimental', evidenceRuns: [] })) + + await expect(main(root)).resolves.toBe(1) + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('protection partial gates need a passed evidence run') + ) + }) + + it('rejects evidence runs whose command is not declared by the gate', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const root = makeTempRoot( + validManifest({ + maturity: 'experimental', + evidenceRuns: [ + { + date: '2026-07-02', + runner: 'local', + platform: 'macos', + command: 'pnpm exec vitest run other.test.ts', + result: 'passed', + durationSeconds: 1, + summary: 'Wrong command.' + } + ] + }) + ) + + await expect(main(root)).resolves.toBe(1) + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('evidenceRuns[0].command must match one of the gate commands') + ) + }) + + it('rejects protection none gates with evidence runs', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const root = makeTempRoot( + validManifest({ + maturity: 'experimental', + protection: 'none', + commands: [], + testFiles: [], + flakeHistory: { status: 'not-started', evidence: 'Planning entry only.' }, + redGreenEvidence: { status: 'missing', evidence: 'No executable proof yet.' } + }) + ) + + await expect(main(root)).resolves.toBe(1) + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('protection none gates must not declare evidenceRuns') + ) + }) + + it('rejects partial gates without assertion refs', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const root = makeTempRoot(validManifest({ maturity: 'experimental', assertionRefs: [] })) + + await expect(main(root)).resolves.toBe(1) + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('protection partial gates need assertionRefs') + ) + }) + + it('rejects assertion refs outside declared test files', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const root = makeTempRoot( + validManifest({ + maturity: 'experimental', + assertionRefs: [ + { + file: 'other.test.ts', + assertions: ['important invariant'] + } + ] + }) + ) + + await expect(main(root)).resolves.toBe(1) + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('assertionRefs[0].file must be one of the gate testFiles') + ) + }) + + it('rejects protection none gates with assertion refs', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const root = makeTempRoot( + validManifest({ + maturity: 'experimental', + protection: 'none', + commands: [], + testFiles: [], + evidenceRuns: [], + assertionRefs: [ + { + file: 'some.test.ts', + assertions: ['planning entry must not look tested'] + } + ], + coveredPlatforms: [], + coveredProviders: [], + flakeHistory: { status: 'not-started', evidence: 'Planning entry only.' }, + redGreenEvidence: { status: 'missing', evidence: 'No executable proof yet.' } + }) + ) + + await expect(main(root)).resolves.toBe(1) + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('protection none gates must not declare assertionRefs') + ) + }) + + it('rejects covered scope outside the risk scope', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const root = makeTempRoot( + validManifest({ + maturity: 'experimental', + coveredPlatforms: ['ios'], + coveredProviders: ['ssh'] + }) + ) + + await expect(main(root)).resolves.toBe(1) + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('covered platform is outside risk scope: ios') + ) + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('covered provider is outside risk scope: ssh') + ) + }) + + it('requires coveredPlatforms to include passed evidence platforms', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const root = makeTempRoot( + validManifest({ + maturity: 'experimental', + coveredPlatforms: ['linux'] + }) + ) + + await expect(main(root)).resolves.toBe(1) + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('coveredPlatforms must include passed evidence platform macos') + ) + }) + + it('rejects covered scope on protection none gates', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const root = makeTempRoot( + validManifest({ + maturity: 'experimental', + protection: 'none', + commands: [], + testFiles: [], + evidenceRuns: [], + coveredPlatforms: ['macos'], + coveredProviders: [], + flakeHistory: { status: 'not-started', evidence: 'Planning entry only.' }, + redGreenEvidence: { status: 'missing', evidence: 'No executable proof yet.' } + }) + ) + + await expect(main(root)).resolves.toBe(1) + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('protection none gates must not declare covered scope') + ) + }) + + it('rejects declared test files that do not exist', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const root = makeTempRoot( + validManifest({ maturity: 'experimental', testFiles: ['missing.test.ts'] }) + ) + + await expect(main(root)).resolves.toBe(1) + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('test file does not exist: missing.test.ts') + ) + }) + + it('rejects executable gates whose commands do not reference declared test files', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const command = 'pnpm exec vitest run' + const root = makeTempRoot( + validManifest({ + maturity: 'experimental', + commands: [command], + evidenceRuns: [ + { + date: '2026-07-02', + runner: 'local', + platform: 'macos', + command, + result: 'passed', + durationSeconds: 1, + summary: 'Command did not name the declared file.' + } + ] + }) + ) + + await expect(main(root)).resolves.toBe(1) + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('test file is not referenced by any gate command: some.test.ts') + ) + }) + + it('rejects soak gates without mature flake and red/green evidence', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const root = makeTempRoot( + validManifest({ + flakeHistory: { status: 'unknown', evidence: 'No soak yet.' }, + redGreenEvidence: { status: 'partial', evidence: 'No saved artifact yet.' } + }) + ) + + await expect(main(root)).resolves.toBe(1) + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('soak gates must have soaking or stable flakeHistory') + ) + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('soak gates must have complete red/green evidence') + ) + }) + + it('rejects malformed JSONC', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const root = makeTempRoot('{ "schemaVersion": 1, } trailing') + + await expect(main(root)).resolves.toBe(1) + expect(console.error).toHaveBeenCalledWith(expect.stringContaining('JSONC parse error')) + }) + + it('rejects missing manifests with a structured error', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const root = mkdtempSync(path.join(tmpdir(), 'orca-reliability-gates-')) + tempDirs.push(root) + + await expect(main(root)).resolves.toBe(1) + expect(console.error).toHaveBeenCalledWith(expect.stringContaining('unable to read manifest')) + }) + + it('uses policy maturity levels as the source of truth', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const manifest = JSON.parse(validManifest()) + manifest.policy.maturityLevels = ['experimental'] + const root = makeTempRoot(JSON.stringify(manifest, null, 2)) + + await expect(main(root)).resolves.toBe(1) + expect(console.error).toHaveBeenCalledWith(expect.stringContaining('maturity is invalid')) + }) + + it('rejects malformed blocking promotion policy', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const manifest = JSON.parse(validManifest()) + manifest.policy.blockingPromotion.maximumUnexplainedFlakes = -1 + const root = makeTempRoot(JSON.stringify(manifest, null, 2)) + + await expect(main(root)).resolves.toBe(1) + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining( + 'policy.blockingPromotion.maximumUnexplainedFlakes must be a non-negative number' + ) + ) + }) +}) diff --git a/config/scripts/create-draft-release.mjs b/config/scripts/create-draft-release.mjs index ffd8af6d86e..3412a118491 100644 --- a/config/scripts/create-draft-release.mjs +++ b/config/scripts/create-draft-release.mjs @@ -6,6 +6,55 @@ const API_VERSION = '2022-11-28' const MAX_RELEASE_BODY_LENGTH = 120_000 const TRUNCATION_NOTICE = '\n\n---\nRelease notes were truncated because GitHub release bodies are limited to 125,000 characters.' +const DESKTOP_RELEASE_TAG_PATTERN = /^v(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?$/ + +export function parseDesktopReleaseTag(tag) { + const match = DESKTOP_RELEASE_TAG_PATTERN.exec(tag) + if (!match) { + return null + } + return { + tag, + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + rc: match[4] === undefined ? null : Number(match[4]) + } +} + +function compareDesktopReleaseTags(a, b) { + const versionDiff = a.major - b.major || a.minor - b.minor || a.patch - b.patch + if (versionDiff !== 0) { + return versionDiff + } + if (a.rc === b.rc) { + return 0 + } + if (a.rc === null) { + return 1 + } + if (b.rc === null) { + return -1 + } + return a.rc - b.rc +} + +export function latestPreviousPublishedDesktopReleaseTag(releases, tag) { + const current = parseDesktopReleaseTag(tag) + if (!current) { + return '' + } + const previousReleases = releases + .filter((release) => release?.draft === false && typeof release.tag_name === 'string') + .map((release) => parseDesktopReleaseTag(release.tag_name)) + .filter((candidate) => candidate && candidate.tag !== current.tag) + .filter((candidate) => compareDesktopReleaseTags(candidate, current) < 0) + // Why: public changelogs should be bounded by releases users could see; + // stable releases summarize since the prior stable, not the latest RC. + .filter((candidate) => current.rc !== null || candidate.rc === null) + .sort(compareDesktopReleaseTags) + return previousReleases.at(-1)?.tag ?? '' +} function githubHeaders(token) { return { @@ -30,6 +79,25 @@ async function githubJson(fetchImpl, url, token, options = {}) { return res.json() } +async function fetchRepoReleases(repo, token, fetchImpl) { + const releases = [] + for (let page = 1; ; page += 1) { + const pageReleases = await githubJson( + fetchImpl, + `https://api.github.com/repos/${repo}/releases?per_page=100&page=${page}`, + token + ) + if (!Array.isArray(pageReleases)) { + throw new Error(`GitHub releases response page ${page} for ${repo} was not an array`) + } + releases.push(...pageReleases) + if (pageReleases.length < 100) { + break + } + } + return releases +} + export function truncateReleaseBody(body, maxLength = MAX_RELEASE_BODY_LENGTH) { if (body.length <= maxLength) { return body @@ -60,16 +128,25 @@ export async function createDraftRelease({ throw new Error('token is required') } + const previousTag = latestPreviousPublishedDesktopReleaseTag( + await fetchRepoReleases(repo, token, fetchImpl), + tag + ) + const generateNotesBody = { + tag_name: tag, + target_commitish: tag, + ...(previousTag ? { previous_tag_name: previousTag } : {}) + } + + // Why: GitHub's generate-notes baseline ignores draft releases, so pass the + // previous public changelog boundary explicitly. const releaseNotes = await githubJson( fetchImpl, `https://api.github.com/repos/${repo}/releases/generate-notes`, token, { method: 'POST', - body: JSON.stringify({ - tag_name: tag, - target_commitish: tag - }) + body: JSON.stringify(generateNotesBody) } ) diff --git a/config/scripts/create-draft-release.test.mjs b/config/scripts/create-draft-release.test.mjs index 66530004cee..b330ccb9423 100644 --- a/config/scripts/create-draft-release.test.mjs +++ b/config/scripts/create-draft-release.test.mjs @@ -1,5 +1,18 @@ import { describe, expect, it, vi } from 'vitest' -import { createDraftRelease, truncateReleaseBody } from './create-draft-release.mjs' +import { + createDraftRelease, + latestPreviousPublishedDesktopReleaseTag, + parseDesktopReleaseTag, + truncateReleaseBody +} from './create-draft-release.mjs' + +function release(tag, options = {}) { + return { + draft: false, + tag_name: tag, + ...options + } +} function jsonResponse(body, init = {}) { return { @@ -24,10 +37,102 @@ describe('truncateReleaseBody', () => { }) }) +describe('parseDesktopReleaseTag', () => { + it('parses stable and rc desktop release tags only', () => { + expect(parseDesktopReleaseTag('v1.4.36')).toMatchObject({ + tag: 'v1.4.36', + major: 1, + minor: 4, + patch: 36, + rc: null + }) + expect(parseDesktopReleaseTag('v1.4.36-rc.2')).toMatchObject({ + tag: 'v1.4.36-rc.2', + major: 1, + minor: 4, + patch: 36, + rc: 2 + }) + expect(parseDesktopReleaseTag('mobile-v0.0.12')).toBeNull() + }) +}) + +describe('latestPreviousPublishedDesktopReleaseTag', () => { + it('bounds stable notes to the previous stable release when rcs exist', () => { + expect( + latestPreviousPublishedDesktopReleaseTag( + [release('v1.4.35'), release('v1.4.36-rc.0'), release('v1.4.36')], + 'v1.4.36' + ) + ).toBe('v1.4.35') + }) + + it('does not collapse a stable changelog to its rc-to-stable version bump', () => { + expect( + latestPreviousPublishedDesktopReleaseTag( + [ + release('v1.4.120'), + release('v1.4.121-rc.0'), + release('v1.4.121-rc.6'), + release('v1.4.121') + ], + 'v1.4.121' + ) + ).toBe('v1.4.120') + }) + + it('bounds the first rc notes to the previous stable release', () => { + expect( + latestPreviousPublishedDesktopReleaseTag( + [release('v1.4.35'), release('v1.4.36-rc.0'), release('mobile-v0.0.12')], + 'v1.4.36-rc.0' + ) + ).toBe('v1.4.35') + }) + + it('bounds later rc notes to the prior rc', () => { + expect( + latestPreviousPublishedDesktopReleaseTag( + [release('v1.4.36-rc.0'), release('v1.4.36-rc.1')], + 'v1.4.36-rc.1' + ) + ).toBe('v1.4.36-rc.0') + }) + + it('ignores draft releases as public changelog boundaries', () => { + expect( + latestPreviousPublishedDesktopReleaseTag( + [release('v1.4.35'), release('v1.4.36-rc.0', { draft: true }), release('v1.4.36-rc.1')], + 'v1.4.36-rc.1' + ) + ).toBe('v1.4.35') + }) + + it('returns empty string for the first desktop release when no earlier tag exists', () => { + expect( + latestPreviousPublishedDesktopReleaseTag( + [release('v1.4.36'), release('mobile-v0.0.12')], + 'v1.4.36' + ) + ).toBe('') + expect(latestPreviousPublishedDesktopReleaseTag([], 'v1.4.36')).toBe('') + }) + + it('returns empty string when the current tag is not a desktop release tag', () => { + expect( + latestPreviousPublishedDesktopReleaseTag( + [release('v1.4.35'), release('v1.4.36')], + 'mobile-v0.0.12' + ) + ).toBe('') + }) +}) + describe('createDraftRelease', () => { it('creates a draft release with bounded generated notes', async () => { const fetchImpl = vi .fn() + .mockResolvedValueOnce(jsonResponse([release('v1.4.35'), release('v1.4.36')])) .mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36', body: 'a'.repeat(130_000) })) .mockResolvedValueOnce(jsonResponse({ tag_name: 'v1.4.36', draft: true })) @@ -41,17 +146,23 @@ describe('createDraftRelease', () => { expect(fetchImpl).toHaveBeenNthCalledWith( 1, + 'https://api.github.com/repos/stablyai/orca/releases?per_page=100&page=1', + expect.any(Object) + ) + expect(fetchImpl).toHaveBeenNthCalledWith( + 2, 'https://api.github.com/repos/stablyai/orca/releases/generate-notes', expect.objectContaining({ method: 'POST', body: JSON.stringify({ tag_name: 'v1.4.36', - target_commitish: 'v1.4.36' + target_commitish: 'v1.4.36', + previous_tag_name: 'v1.4.35' }) }) ) expect(fetchImpl).toHaveBeenNthCalledWith( - 2, + 3, 'https://api.github.com/repos/stablyai/orca/releases', expect.objectContaining({ method: 'POST', @@ -59,7 +170,7 @@ describe('createDraftRelease', () => { }) ) - const createBody = JSON.parse(fetchImpl.mock.calls[1][1].body) + const createBody = JSON.parse(fetchImpl.mock.calls[2][1].body) expect(createBody).toMatchObject({ tag_name: 'v1.4.36', name: 'v1.4.36', @@ -73,6 +184,7 @@ describe('createDraftRelease', () => { it('marks rc tags as prereleases', async () => { const fetchImpl = vi .fn() + .mockResolvedValueOnce(jsonResponse([release('v1.4.36'), release('v1.4.36-rc.1')])) .mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36-rc.1', body: 'notes' })) .mockResolvedValueOnce(jsonResponse({ tag_name: 'v1.4.36-rc.1', draft: true })) @@ -84,7 +196,58 @@ describe('createDraftRelease', () => { log: vi.fn() }) - const createBody = JSON.parse(fetchImpl.mock.calls[1][1].body) + const createBody = JSON.parse(fetchImpl.mock.calls[2][1].body) expect(createBody.prerelease).toBe(true) }) + + it('omits previous_tag_name for the first desktop release so notes fall back to the GitHub default', async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(jsonResponse([release('v1.4.36'), release('mobile-v0.0.12')])) + .mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36', body: 'notes' })) + .mockResolvedValueOnce(jsonResponse({ tag_name: 'v1.4.36', draft: true })) + + await createDraftRelease({ + repo: 'stablyai/orca', + tag: 'v1.4.36', + token: 'token', + fetchImpl, + log: vi.fn() + }) + + const generateNotesBody = JSON.parse(fetchImpl.mock.calls[1][1].body) + expect(generateNotesBody).toEqual({ tag_name: 'v1.4.36', target_commitish: 'v1.4.36' }) + expect(generateNotesBody).not.toHaveProperty('previous_tag_name') + }) + + it('paginates through every release page before choosing the previous release', async () => { + const firstPage = Array.from({ length: 100 }, (_, index) => release(`mobile-v0.0.${index}`)) + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(jsonResponse(firstPage)) + .mockResolvedValueOnce(jsonResponse([release('v1.4.35')])) + .mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36', body: 'notes' })) + .mockResolvedValueOnce(jsonResponse({ tag_name: 'v1.4.36', draft: true })) + + await createDraftRelease({ + repo: 'stablyai/orca', + tag: 'v1.4.36', + token: 'token', + fetchImpl, + log: vi.fn() + }) + + expect(fetchImpl).toHaveBeenNthCalledWith( + 1, + 'https://api.github.com/repos/stablyai/orca/releases?per_page=100&page=1', + expect.any(Object) + ) + expect(fetchImpl).toHaveBeenNthCalledWith( + 2, + 'https://api.github.com/repos/stablyai/orca/releases?per_page=100&page=2', + expect.any(Object) + ) + const generateNotesBody = JSON.parse(fetchImpl.mock.calls[2][1].body) + expect(generateNotesBody.previous_tag_name).toBe('v1.4.35') + }) }) diff --git a/config/scripts/locale-ko-key-overrides.json b/config/scripts/locale-ko-key-overrides.json index 071a249e01c..953eb8a21fc 100644 --- a/config/scripts/locale-ko-key-overrides.json +++ b/config/scripts/locale-ko-key-overrides.json @@ -1559,12 +1559,6 @@ "auto.components.onboarding.ThemeStep.94b9dc561d": { "ko": "설정 -> Terminal" }, - "auto.components.pet.PetOverlay.4712d196c6": { - "ko": "@keyframes pet-{{value0}} { from { background-position: {{value1}}px {{value2}}px; } to { background-position: {{value3}}px {{value4}}px; } }" - }, - "auto.components.pet.PetOverlay.de932b0e8f": { - "ko": "@keyframes pet-bob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-4px); } }" - }, "auto.components.repo.NestedRepoChecklist.ea54c7bf8f": { "ko": "/" }, diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index 5ee9f1a3017..c106b166feb 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -194,6 +194,10 @@ describe('Electron runtime package contract', () => { expect(installStep.if).toBe("matrix.platform == 'win'") expect(installStep.shell).toBe('pwsh') + expect(installRun).toContain( + 'if ($null -eq (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue))' + ) + expect(installRun).toContain('Register-PSRepository -Default -InstallationPolicy Trusted') expect(installRun).toContain('Set-PSRepository -Name PSGallery -InstallationPolicy Trusted') expect(installRun).toMatch(/\$env:PSModulePath -split \[System\.IO\.Path\]::PathSeparator/) expect(installRun).toContain( @@ -217,6 +221,23 @@ describe('Electron runtime package contract', () => { expect(installRun).not.toMatch(/throw\s+\$_/) }) + it('temporarily allows publishing Windows after verifying the signed installer only', () => { + const releaseWorkflow = readFileSync( + join(projectDir, '.github/workflows/release-cut.yml'), + 'utf8' + ) + const parsedWorkflow = parse(releaseWorkflow) + const steps = parsedWorkflow.jobs.build.steps + const stepNames = steps.map((step) => step.name) + const outerVerifyIndex = stepNames.indexOf('Verify signed Windows installer') + const innerVerifyIndex = stepNames.indexOf('Verify signed Windows inner executable') + const publishIndex = stepNames.indexOf('Publish signed Windows release artifacts') + + expect(outerVerifyIndex).toBeGreaterThan(-1) + expect(innerVerifyIndex).toBe(-1) + expect(publishIndex).toBe(outerVerifyIndex + 1) + }) + it('publishes both Linux release matrix entries', () => { const releaseWorkflow = readFileSync( join(projectDir, '.github/workflows/release-cut.yml'), diff --git a/config/scripts/repro-windows-apphang-terminal-activation.mjs b/config/scripts/repro-windows-apphang-terminal-activation.mjs new file mode 100644 index 00000000000..73e7bd4a13f --- /dev/null +++ b/config/scripts/repro-windows-apphang-terminal-activation.mjs @@ -0,0 +1,196 @@ +#!/usr/bin/env node + +import { mkdirSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import process from 'node:process' +import { + createWslFixture, + listWslDistros, + removeWslFixture +} from './windows-apphang-repro/wsl-workspace-fixture.mjs' +import { runGpuMode } from './windows-apphang-repro/terminal-activation-scenario.mjs' +import { summarizeResult } from './windows-apphang-repro/apphang-report-summary.mjs' + +const defaultCycles = 14 +const defaultOutputLines = 1_600 + +function parseArgs() { + const args = { + cycles: defaultCycles, + distro: null, + expect: 'none', + gpuModes: ['on'], + keep: false, + outputLines: defaultOutputLines, + reportPath: null, + sourceControl: true, + deadPtyReactivate: true + } + + for (const arg of process.argv.slice(2)) { + if (arg === '--help' || arg === '-h') { + printHelp() + process.exit(0) + } + if (arg === '--keep') { + args.keep = true + continue + } + if (arg === '--no-source-control') { + args.sourceControl = false + continue + } + if (arg === '--no-dead-pty-reactivate') { + args.deadPtyReactivate = false + continue + } + const [name, value] = arg.split('=', 2) + if (name === '--cycles') { + args.cycles = parsePositiveInt(name, value) + continue + } + if (name === '--distro') { + args.distro = value?.trim() || null + continue + } + if (name === '--expect') { + if (!['none', 'repro', 'pass'].includes(value)) { + throw new Error(`Unsupported --expect=${value}. Use none, repro, or pass.`) + } + args.expect = value + continue + } + if (name === '--gpu') { + const modes = (value ?? '') + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean) + if (modes.length === 0 || modes.some((mode) => !['on', 'off', 'auto'].includes(mode))) { + throw new Error(`Unsupported --gpu=${value}. Use on, off, auto, or a comma-list.`) + } + args.gpuModes = modes + continue + } + if (name === '--output-lines') { + args.outputLines = parsePositiveInt(name, value) + continue + } + if (name === '--report') { + args.reportPath = value?.trim() || null + if (!args.reportPath) { + throw new Error('--report requires a file path.') + } + continue + } + throw new Error(`Unknown argument: ${arg}`) + } + + return args +} + +function printHelp() { + console.log(`Usage: + node config/scripts/repro-windows-apphang-terminal-activation.mjs [options] + +Options: + --expect=none|repro|pass none prints measurements, repro exits 0 only when hang evidence is observed, + pass exits 1 if hang evidence is observed. Default: none. + --gpu=on|off|auto[,mode...] Terminal GPU setting(s) to run. Default: on. + --cycles=N Activation/output cycles per GPU mode. Default: ${defaultCycles}. + --output-lines=N Lines emitted by each terminal stress command. Default: ${defaultOutputLines}. + --report=PATH Write full JSON evidence to PATH and print a compact summary to stdout. + --distro=NAME WSL distro to use. Default: first non docker-desktop distro. + --no-source-control Do not open Source Control during the stress loop. + --no-dead-pty-reactivate Do not kill PTYs and revisit workspaces after initial activation. + --keep Keep disposable WSL/userData fixtures after the run.`) +} + +function parsePositiveInt(name, value) { + const parsed = Number.parseInt(value ?? '', 10) + if (!Number.isInteger(parsed) || parsed <= 0 || String(parsed) !== value) { + throw new Error(`${name} requires a positive integer.`) + } + return parsed +} + +async function main() { + if (process.platform !== 'win32') { + throw new Error('This repro harness is intentionally Windows-only.') + } + const args = parseArgs() + const distros = listWslDistros() + const distro = args.distro ?? distros[0] + if (!distro) { + throw new Error('No user WSL distro found. Install/enable WSL or pass --distro=NAME.') + } + console.log( + `[apphang-repro] issue=https://github.com/stablyai/orca/issues/6874 distro=${distro} gpuModes=${args.gpuModes.join(',')} cycles=${args.cycles}` + ) + const fixture = createWslFixture(distro) + console.log( + `[apphang-repro] fixture repo=${fixture.repoUncPath} plain=${fixture.plainUncPath} base=${fixture.baseLinuxPath}` + ) + + const results = [] + try { + for (const gpuMode of args.gpuModes) { + results.push(await runGpuMode(gpuMode, args, fixture)) + if (args.expect === 'repro' && results.at(-1)?.reproduced) { + break + } + } + } finally { + if (!args.keep) { + removeWslFixture(fixture) + } + } + + const payload = { + issue: 'https://github.com/stablyai/orca/issues/6874', + expect: args.expect, + distro, + fixture: args.keep ? fixture : { removed: true, baseLinuxPath: fixture.baseLinuxPath }, + summary: results.map(summarizeResult), + results + } + if (args.reportPath) { + const reportPath = path.resolve(args.reportPath) + mkdirSync(path.dirname(reportPath), { recursive: true }) + writeFileSync(reportPath, `${JSON.stringify(payload, null, 2)}\n`) + console.log(JSON.stringify({ reportPath, summary: payload.summary }, null, 2)) + } else { + console.log(JSON.stringify(payload, null, 2)) + } + + // Why: a broken harness run (CDP/setup/selector failure) proves nothing — + // it must fail both --expect=repro and --expect=pass rather than being + // miscounted as hang evidence or as a clean pass. + const harnessErrors = results.filter((result) => result.harnessError) + if (harnessErrors.length > 0) { + const harnessErrorLines = harnessErrors + .map((result) => ` gpu=${result.gpuMode}: ${result.harnessError}`) + .join('\n') + console.error( + `[apphang-repro] Harness failed in ${harnessErrors.length} run(s); result inconclusive:\n${harnessErrorLines}` + ) + if (args.expect !== 'none') { + process.exit(1) + } + } + const reproduced = results.some((result) => result.reproduced) + if (args.expect === 'repro' && !reproduced) { + console.error( + '[apphang-repro] Expected to reproduce the hang, but no hang evidence was observed.' + ) + process.exit(1) + } + if (args.expect === 'pass' && reproduced) { + console.error('[apphang-repro] Expected a clean pass, but hang evidence was observed.') + process.exit(1) + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.stack || error.message : String(error)) + process.exit(1) +}) diff --git a/config/scripts/verify-windows-inner-signature.mjs b/config/scripts/verify-windows-inner-signature.mjs new file mode 100644 index 00000000000..e0c5e303dbc --- /dev/null +++ b/config/scripts/verify-windows-inner-signature.mjs @@ -0,0 +1,206 @@ +import { spawnSync } from 'node:child_process' +import { existsSync, statSync } from 'node:fs' +import { pathToFileURL } from 'node:url' + +export const DEFAULT_EXPECTED_SIGNER = + 'CN=SignPath Foundation, O=SignPath Foundation, L=Lewes, S=Delaware, C=US' + +const POWERSHELL_SIGNATURE_SCRIPT = String.raw` +$ErrorActionPreference = 'Stop' +$signature = Get-AuthenticodeSignature -FilePath $env:ORCA_WINDOWS_INNER_EXECUTABLE +$certificate = $signature.SignerCertificate +[pscustomobject]@{ + status = $signature.Status.ToString() + statusMessage = $signature.StatusMessage + signerSubject = if ($null -eq $certificate) { $null } else { $certificate.Subject } + signerIssuer = if ($null -eq $certificate) { $null } else { $certificate.Issuer } + signerThumbprint = if ($null -eq $certificate) { $null } else { $certificate.Thumbprint } + notBefore = if ($null -eq $certificate) { $null } else { $certificate.NotBefore.ToString('o') } + notAfter = if ($null -eq $certificate) { $null } else { $certificate.NotAfter.ToString('o') } +} | ConvertTo-Json -Compress +` + +export function normalizeSignerSubject(subject) { + if (typeof subject !== 'string') { + return '' + } + + return subject + .split(',') + .map((part) => part.trim().replace(/\s*=\s*/u, '=')) + .filter(Boolean) + .join(', ') +} + +export function normalizeThumbprint(thumbprint) { + if (typeof thumbprint !== 'string') { + return '' + } + + return thumbprint.replace(/[^0-9a-f]/giu, '').toUpperCase() +} + +export function parseExpectedSigners(value = process.env.ORCA_WINDOWS_EXPECTED_SIGNERS) { + const source = typeof value === 'string' && value.trim() !== '' ? value : DEFAULT_EXPECTED_SIGNER + + return source + .split(/[\r\n;]+/u) + .map(normalizeSignerSubject) + .filter(Boolean) +} + +export function parseExpectedThumbprints(value = process.env.ORCA_WINDOWS_EXPECTED_THUMBPRINTS) { + if (typeof value !== 'string' || value.trim() === '') { + return [] + } + + return value + .split(/[\r\n,;]+/u) + .map(normalizeThumbprint) + .filter(Boolean) +} + +export function parseSignatureJson(stdout) { + const trimmed = typeof stdout === 'string' ? stdout.trim() : '' + if (trimmed === '') { + throw new Error('PowerShell did not return signature JSON.') + } + + try { + return JSON.parse(trimmed) + } catch (error) { + throw new Error(`PowerShell returned malformed signature JSON: ${error.message}`) + } +} + +export function classifySignature(signature, options = {}) { + const expectedSigners = options.expectedSigners ?? parseExpectedSigners() + const expectedThumbprints = options.expectedThumbprints ?? parseExpectedThumbprints() + const status = typeof signature?.status === 'string' ? signature.status : '' + const signerSubject = normalizeSignerSubject(signature?.signerSubject) + const signerThumbprint = normalizeThumbprint(signature?.signerThumbprint) + const subjectAllowed = expectedSigners.includes(signerSubject) + const thumbprintAllowed = + expectedThumbprints.length > 0 && + signerThumbprint !== '' && + expectedThumbprints.includes(signerThumbprint) + + if (status !== 'Valid') { + return { + ok: false, + message: `Windows inner executable signature status is ${status || ''}.`, + signature + } + } + + if (!subjectAllowed && !thumbprintAllowed) { + return { + ok: false, + message: `Unexpected Windows inner executable signer: ${signerSubject || ''}.`, + signature + } + } + + return { ok: true, signature } +} + +export function formatSignatureSummary(signature) { + return [ + `Status: ${signature.status ?? ''}`, + `Subject: ${normalizeSignerSubject(signature.signerSubject) || ''}`, + `Issuer: ${signature.signerIssuer ?? ''}`, + `Thumbprint: ${normalizeThumbprint(signature.signerThumbprint) || ''}`, + `NotBefore: ${signature.notBefore ?? ''}`, + `NotAfter: ${signature.notAfter ?? ''}` + ].join('\n') +} + +export function validateExecutablePath(executablePath) { + if (typeof executablePath !== 'string' || executablePath.trim() === '') { + throw new Error('Usage: node config/scripts/verify-windows-inner-signature.mjs ') + } + + if (!existsSync(executablePath)) { + throw new Error(`Windows inner executable does not exist: ${executablePath}`) + } + + if (!statSync(executablePath).isFile()) { + throw new Error(`Windows inner executable path is not a file: ${executablePath}`) + } +} + +export function getPowerShellSignatureJson(executablePath, spawnSyncImpl = spawnSync) { + // Why: pwsh -Command does not reliably expose trailing process args to string commands. + const result = spawnSyncImpl( + 'pwsh', + [ + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Bypass', + '-Command', + POWERSHELL_SIGNATURE_SCRIPT + ], + { + encoding: 'utf8', + env: { + ...process.env, + ORCA_WINDOWS_INNER_EXECUTABLE: executablePath + } + } + ) + + if (result.error) { + throw result.error + } + + if (result.stderr?.trim()) { + throw new Error(`PowerShell wrote to stderr while checking signature:\n${result.stderr.trim()}`) + } + + if (result.status !== 0) { + throw new Error( + `PowerShell signature check failed with exit code ${result.status ?? ''}.` + ) + } + + return result.stdout +} + +export function verifyWindowsInnerSignature({ + executablePath, + platform = process.platform, + spawnSyncImpl = spawnSync, + expectedSigners = parseExpectedSigners(), + expectedThumbprints = parseExpectedThumbprints() +}) { + validateExecutablePath(executablePath) + + if (platform !== 'win32') { + throw new Error('Windows inner executable signature verification requires Windows.') + } + + const signature = parseSignatureJson(getPowerShellSignatureJson(executablePath, spawnSyncImpl)) + const classification = classifySignature(signature, { expectedSigners, expectedThumbprints }) + if (!classification.ok) { + throw new Error(`${classification.message}\n${formatSignatureSummary(signature)}`) + } + + return signature +} + +export function main(argv = process.argv.slice(2)) { + try { + const signature = verifyWindowsInnerSignature({ executablePath: argv[0] }) + console.log('Verified Windows inner executable signature.') + console.log(formatSignatureSummary(signature)) + } catch (error) { + console.error(error.message) + process.exitCode = 1 + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main() +} diff --git a/config/scripts/verify-windows-inner-signature.test.mjs b/config/scripts/verify-windows-inner-signature.test.mjs new file mode 100644 index 00000000000..d833fb64774 --- /dev/null +++ b/config/scripts/verify-windows-inner-signature.test.mjs @@ -0,0 +1,218 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { + DEFAULT_EXPECTED_SIGNER, + classifySignature, + getPowerShellSignatureJson, + normalizeSignerSubject, + normalizeThumbprint, + parseExpectedSigners, + parseExpectedThumbprints, + parseSignatureJson, + validateExecutablePath, + verifyWindowsInnerSignature +} from './verify-windows-inner-signature.mjs' + +const validSignature = { + status: 'Valid', + statusMessage: 'Signature verified.', + signerSubject: DEFAULT_EXPECTED_SIGNER, + signerIssuer: 'CN=SignPath Foundation Root', + signerThumbprint: 'aa bb cc dd', + notBefore: '2026-01-01T00:00:00.0000000Z', + notAfter: '2027-01-01T00:00:00.0000000Z' +} + +function withTempFile(callback) { + const dir = mkdtempSync(join(tmpdir(), 'orca-inner-signature-')) + const filePath = join(dir, 'Orca.exe') + writeFileSync(filePath, 'placeholder executable') + + try { + return callback(filePath, dir) + } finally { + rmSync(dir, { force: true, recursive: true }) + } +} + +describe('verify-windows-inner-signature', () => { + const originalExpectedSigners = process.env.ORCA_WINDOWS_EXPECTED_SIGNERS + const originalExpectedThumbprints = process.env.ORCA_WINDOWS_EXPECTED_THUMBPRINTS + + beforeEach(() => { + delete process.env.ORCA_WINDOWS_EXPECTED_SIGNERS + delete process.env.ORCA_WINDOWS_EXPECTED_THUMBPRINTS + }) + + afterEach(() => { + if (originalExpectedSigners === undefined) { + delete process.env.ORCA_WINDOWS_EXPECTED_SIGNERS + } else { + process.env.ORCA_WINDOWS_EXPECTED_SIGNERS = originalExpectedSigners + } + + if (originalExpectedThumbprints === undefined) { + delete process.env.ORCA_WINDOWS_EXPECTED_THUMBPRINTS + } else { + process.env.ORCA_WINDOWS_EXPECTED_THUMBPRINTS = originalExpectedThumbprints + } + }) + + it('normalizes signer subjects without widening exact matching', () => { + expect( + normalizeSignerSubject( + ' CN = SignPath Foundation , O=SignPath Foundation,L=Lewes,S=Delaware,C=US ' + ) + ).toBe(DEFAULT_EXPECTED_SIGNER) + expect( + normalizeSignerSubject( + 'CN=Different Signer, O=SignPath Foundation, L=Lewes, S=Delaware, C=US' + ) + ).not.toBe(DEFAULT_EXPECTED_SIGNER) + }) + + it('defaults the expected signer allowlist to SignPath Foundation', () => { + expect(parseExpectedSigners('')).toEqual([DEFAULT_EXPECTED_SIGNER]) + expect(parseExpectedSigners(' ')).toEqual([DEFAULT_EXPECTED_SIGNER]) + }) + + it('parses semicolon and newline separated signer allowlists', () => { + expect(parseExpectedSigners(`${DEFAULT_EXPECTED_SIGNER};\nCN=Backup, O=Backup`)).toEqual([ + DEFAULT_EXPECTED_SIGNER, + 'CN=Backup, O=Backup' + ]) + }) + + it('normalizes optional thumbprint allowlists', () => { + expect(normalizeThumbprint('aa bb:cc')).toBe('AABBCC') + expect(parseExpectedThumbprints('aa bb cc,11:22:33')).toEqual(['AABBCC', '112233']) + }) + + it('rejects missing, nonexistent, and directory executable paths before PowerShell', () => { + expect(() => validateExecutablePath('')).toThrow(/Usage:/) + expect(() => validateExecutablePath(join(tmpdir(), 'missing-Orca.exe'))).toThrow( + /does not exist/ + ) + + withTempFile((filePath, dir) => { + expect(() => validateExecutablePath(dir)).toThrow(/not a file/) + expect(() => validateExecutablePath(filePath)).not.toThrow() + }) + }) + + it('parses the exact JSON emitted by PowerShell', () => { + expect(parseSignatureJson(JSON.stringify(validSignature))).toEqual(validSignature) + expect(() => parseSignatureJson('')).toThrow(/did not return/) + expect(() => parseSignatureJson(`${JSON.stringify(validSignature)}\nextra`)).toThrow( + /malformed/ + ) + }) + + it('accepts a valid signature with an exact normalized signer subject', () => { + const result = classifySignature({ + ...validSignature, + signerSubject: ' CN=SignPath Foundation, O=SignPath Foundation, L=Lewes, S=Delaware, C=US ' + }) + + expect(result.ok).toBe(true) + }) + + it('rejects invalid status and unexpected signer subjects', () => { + expect(classifySignature({ ...validSignature, status: 'NotSigned' }).message).toMatch( + /status is NotSigned/ + ) + expect( + classifySignature({ + ...validSignature, + signerSubject: 'CN=SignPath Foundation Test, O=SignPath Foundation, L=Vienna, C=AT' + }).message + ).toMatch(/Unexpected Windows inner executable signer/) + }) + + it('accepts an expected thumbprint as an alternate explicit allowlist', () => { + expect(classifySignature({ ...validSignature, signerThumbprint: '00' }).ok).toBe(true) + expect( + classifySignature( + { ...validSignature, signerSubject: 'CN=Rotated Signer, O=Rotated' }, + { + expectedSigners: [DEFAULT_EXPECTED_SIGNER], + expectedThumbprints: ['AABBCCDD'] + } + ).ok + ).toBe(true) + expect( + classifySignature( + { ...validSignature, signerSubject: 'CN=Rotated Signer, O=Rotated' }, + { + expectedSigners: [DEFAULT_EXPECTED_SIGNER], + expectedThumbprints: ['001122'] + } + ).message + ).toMatch(/Unexpected Windows inner executable signer/) + expect( + classifySignature(validSignature, { + expectedSigners: [DEFAULT_EXPECTED_SIGNER], + expectedThumbprints: ['001122'] + }).ok + ).toBe(true) + }) + + it('runs PowerShell with an argument array and fails on stderr or nonzero exit', () => { + const calls = [] + const spawnSyncImpl = (command, args, options) => { + calls.push({ command, args, options }) + return { status: 0, stdout: JSON.stringify(validSignature), stderr: '' } + } + + expect(getPowerShellSignatureJson('C:\\Path With Spaces\\Orca.exe', spawnSyncImpl)).toBe( + JSON.stringify(validSignature) + ) + expect(calls[0].command).toBe('pwsh') + expect(calls[0].args).toContain('-Command') + expect(calls[0].args.at(-1)).not.toBe('C:\\Path With Spaces\\Orca.exe') + expect(calls[0].options).toEqual( + expect.objectContaining({ + encoding: 'utf8', + env: expect.objectContaining({ + ORCA_WINDOWS_INNER_EXECUTABLE: 'C:\\Path With Spaces\\Orca.exe' + }) + }) + ) + + expect(() => + getPowerShellSignatureJson('Orca.exe', () => ({ status: 0, stdout: '{}', stderr: 'warning' })) + ).toThrow(/stderr/) + expect(() => + getPowerShellSignatureJson('Orca.exe', () => ({ status: 7, stdout: '', stderr: '' })) + ).toThrow(/exit code 7/) + }) + + it('verifies with injected Windows platform and spawn implementation', () => { + withTempFile((filePath) => { + const signature = verifyWindowsInnerSignature({ + executablePath: filePath, + platform: 'win32', + spawnSyncImpl: () => ({ status: 0, stdout: JSON.stringify(validSignature), stderr: '' }) + }) + + expect(signature).toEqual(validSignature) + }) + }) + + it('does not attempt real Authenticode verification outside Windows', () => { + withTempFile((filePath) => { + expect(() => + verifyWindowsInnerSignature({ + executablePath: filePath, + platform: 'linux', + spawnSyncImpl: () => { + throw new Error('should not spawn') + } + }) + ).toThrow(/requires Windows/) + }) + }) +}) diff --git a/config/scripts/windows-apphang-repro/apphang-report-summary.mjs b/config/scripts/windows-apphang-repro/apphang-report-summary.mjs new file mode 100644 index 00000000000..b6d222cb4de --- /dev/null +++ b/config/scripts/windows-apphang-repro/apphang-report-summary.mjs @@ -0,0 +1,77 @@ +import { existsSync, readFileSync } from 'node:fs' +import path from 'node:path' + +export function summarizeTrace(userDataDir) { + const tracePath = path.join(userDataDir, 'logs', 'main.trace.ndjson') + if (!existsSync(tracePath)) { + return { tracePath, exists: false } + } + const lines = readFileSync(tracePath, 'utf8').trim().split(/\r?\n/).filter(Boolean) + const parsed = [] + for (const line of lines.slice(-400)) { + try { + parsed.push(JSON.parse(line)) + } catch { + parsed.push({ raw: line }) + } + } + const interesting = parsed.filter((entry) => { + const name = String(entry.name ?? entry.event ?? entry.type ?? '') + const text = JSON.stringify(entry) + return ( + name.includes('git.exec') || + name.includes('renderer_memory') || + name.includes('sidebar_worktree_activate') || + text.includes('git.exec remote') || + text.includes('git.exec worktree') || + text.includes('sidebar_worktree_activate') + ) + }) + return { + tracePath, + exists: true, + totalLines: lines.length, + interestingTail: interesting.slice(-40) + } +} + +export function summarizeAppLogs(logs) { + const webglContextWarnings = logs.filter((entry) => + entry.line.includes('Too many active WebGL contexts') + ) + const webglContextLosses = logs.filter((entry) => + entry.line.toLowerCase().includes('webgl context lost') + ) + const gpuProcessEvents = logs.filter((entry) => /gpu|d3d|angle/i.test(entry.line)) + return { + lineCount: logs.length, + webglContextWarningCount: webglContextWarnings.length, + webglContextWarningsTail: webglContextWarnings.slice(-10), + webglContextLossCount: webglContextLosses.length, + webglContextLossesTail: webglContextLosses.slice(-10), + gpuProcessEventCount: gpuProcessEvents.length, + gpuProcessEventsTail: gpuProcessEvents.slice(-20) + } +} + +export function summarizeResult(result) { + const samples = result.samples.filter((sample) => sample && typeof sample === 'object') + const maxActivationMs = Math.max(0, ...samples.map((sample) => sample.activationMs ?? 0)) + const maxPtyWaitMs = Math.max(0, ...samples.map((sample) => sample.ptyWaitMs ?? 0)) + const maxMainIpcMs = Math.max(0, ...samples.map((sample) => sample.mainIpcMs ?? 0)) + const maxRendererDriftMs = Math.max(0, ...samples.map((sample) => sample.rendererMaxDriftMs ?? 0)) + return { + gpuMode: result.gpuMode, + reproduced: result.reproduced, + reproductionReason: result.reproductionReason, + harnessError: result.harnessError ?? null, + elapsedMs: result.elapsedMs, + sampleCount: samples.length, + maxActivationMs, + maxPtyWaitMs, + maxMainIpcMs, + maxRendererDriftMs, + finalWebglContextCounts: result.finalDiagnostics?.webglContextCounts ?? null, + appLogSummary: result.appLogSummary + } +} diff --git a/config/scripts/windows-apphang-repro/electron-dev-session.mjs b/config/scripts/windows-apphang-repro/electron-dev-session.mjs new file mode 100644 index 00000000000..89fddf4ef99 --- /dev/null +++ b/config/scripts/windows-apphang-repro/electron-dev-session.mjs @@ -0,0 +1,331 @@ +import { chromium } from '@playwright/test' +import { spawn } from 'node:child_process' +import { mkdtempSync } from 'node:fs' +import net from 'node:net' +import os from 'node:os' +import path from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' +import { + appShutdownTimeoutMs, + cdpPollTimeoutMs, + delay, + pollUntil, + rendererActionTimeoutMs, + runWithTimeout +} from './repro-timing.mjs' +import { createCompletedOnboardingProfile } from './wsl-workspace-fixture.mjs' + +const rootDir = path.resolve(fileURLToPath(new URL('../../..', import.meta.url))) + +function isPortFree(port) { + return new Promise((resolve) => { + const server = net.createServer() + server.once('error', () => { + try { + server.close() + } catch {} + resolve(false) + }) + server.once('listening', () => server.close(() => resolve(true))) + server.listen(port, '127.0.0.1') + }) +} + +export async function pickFreePort() { + for (let port = 9533; port < 9633; port += 1) { + if (await isPortFree(port)) { + return port + } + } + throw new Error('Could not find a free CDP port in 9533..9632.') +} + +export function createGpuUserDataDirectory(gpuMode) { + const userDataDir = mkdtempSync(path.join(os.tmpdir(), `orca-apphang-${gpuMode}-userdata-`)) + createCompletedOnboardingProfile(userDataDir) + return userDataDir +} + +export function launchDevApp({ cdpPort, userDataDir }) { + const env = { ...process.env } + delete env.ELECTRON_RUN_AS_NODE + Object.assign(env, { + ELECTRON_ENABLE_LOGGING: '1', + ELECTRON_ENABLE_STACK_DUMPING: '1', + NODE_ENV: 'development', + ORCA_DEV_USER_DATA_PATH: userDataDir, + ORCA_SKIP_DEV_WEB_PREPARE: '1', + ORCA_STARTUP_DIAGNOSTICS: '1', + REMOTE_DEBUGGING_PORT: String(cdpPort), + VITE_EXPOSE_STORE: 'true' + }) + const child = spawn( + process.execPath, + [path.join('config', 'scripts', 'run-electron-vite-dev.mjs')], + { + cwd: rootDir, + env, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true + } + ) + const logs = [] + const collect = (source) => (chunk) => { + const text = chunk.toString() + for (const line of text.split(/\r?\n/).filter(Boolean)) { + logs.push({ source, line, at: Date.now() }) + console.log(`[apphang-repro:${source}] ${line}`) + } + } + child.stdout?.on('data', collect('stdout')) + child.stderr?.on('data', collect('stderr')) + return { child, logs } +} + +export async function stopDevApp(child) { + if (!child?.pid || child.exitCode !== null || child.signalCode !== null) { + return + } + await new Promise((resolve) => { + const timer = setTimeout(resolve, appShutdownTimeoutMs) + child.once('exit', () => { + clearTimeout(timer) + resolve() + }) + if (process.platform === 'win32') { + const killer = spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { + stdio: 'ignore', + windowsHide: true + }) + killer.once('exit', () => undefined) + return + } + child.kill('SIGTERM') + }) +} + +async function waitForCdp(port) { + const url = `http://127.0.0.1:${port}/json` + const startedAt = Date.now() + while (Date.now() - startedAt < cdpPollTimeoutMs) { + try { + const response = await fetch(url) + if (response.ok) { + const targets = await response.json() + if (Array.isArray(targets) && targets.some((target) => target.type)) { + return targets + } + } + } catch {} + await delay(500) + } + throw new Error(`Timed out waiting for CDP targets on ${url}`) +} + +async function getMainPage(browser) { + const startedAt = Date.now() + while (Date.now() - startedAt < cdpPollTimeoutMs) { + for (const context of browser.contexts()) { + const pages = context.pages() + const page = + pages.find((candidate) => + /^https?:\/\/127\.0\.0\.1:|^https?:\/\/localhost:/.test(candidate.url()) + ) ?? pages[0] + if (page) { + return page + } + } + await delay(250) + } + throw new Error('Timed out waiting for the Electron renderer page.') +} + +export async function connectToApp(cdpPort) { + await waitForCdp(cdpPort) + const browser = await chromium.connectOverCDP(`http://127.0.0.1:${cdpPort}`) + const page = await getMainPage(browser) + await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }) + return { browser, page } +} + +export async function installRendererProbe(page) { + await page.evaluate(() => { + if (globalThis.__orcaApphangProbe) { + return + } + const probe = { + intervalMs: 50, + last: performance.now(), + maxDriftMs: 0, + samples: 0, + startedAt: performance.now(), + lastTickAt: performance.now() + } + const timer = setInterval(() => { + const now = performance.now() + const drift = Math.max(0, now - probe.last - probe.intervalMs) + probe.maxDriftMs = Math.max(probe.maxDriftMs, drift) + probe.last = now + probe.lastTickAt = now + probe.samples += 1 + }, probe.intervalMs) + globalThis.__orcaApphangProbe = { probe, timer } + }) +} + +export async function waitForStoreReady(page) { + await pollUntil( + 'renderer store exposure', + () => page.evaluate(() => Boolean(window.__store && window.api)), + Boolean, + 30_000 + ) + await pollUntil( + 'workspace session hydration', + () => + page.evaluate(() => { + const state = window.__store?.getState?.() + return Boolean(state?.workspaceSessionReady && state?.hydrationSucceeded) + }), + Boolean, + 45_000 + ) +} + +export async function collectRendererDiagnostics(page) { + if (!page) { + return null + } + try { + return await runWithTimeout( + 'renderer diagnostics', + () => + page.evaluate(async () => { + const timed = (label, promise) => + Promise.race([ + Promise.resolve(promise).then( + (value) => ({ value }), + (error) => ({ error: error instanceof Error ? error.message : String(error) }) + ), + new Promise((resolve) => + setTimeout(() => resolve({ error: `Timed out collecting ${label}` }), 1_000) + ) + ]) + const readWebglIdentity = () => { + const canvas = document.createElement('canvas') + let gl = null + try { + gl = canvas.getContext('webgl2') ?? canvas.getContext('webgl') + if (!gl) { + return { available: false, vendor: null, renderer: null } + } + const debugInfo = gl.getExtension('WEBGL_debug_renderer_info') + if (!debugInfo) { + return { available: true, vendor: null, renderer: null } + } + return { + available: true, + vendor: String(gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL) ?? '') || null, + renderer: String(gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL) ?? '') || null + } + } catch (error) { + return { + available: false, + error: error instanceof Error ? error.message : String(error) + } + } finally { + try { + gl?.getExtension('WEBGL_lose_context')?.loseContext() + } catch {} + canvas.width = 0 + canvas.height = 0 + } + } + const allPaneManagersDiagnostics = Array.from( + window.__paneManagers?.entries?.() ?? [] + ).map(([managerTabId, paneManager]) => ({ + tabId: managerTabId, + diagnostics: paneManager?.getRenderingDiagnostics?.() ?? [] + })) + const webglContextCounts = allPaneManagersDiagnostics.reduce( + (acc, entry) => { + acc.managerCount += 1 + acc.paneCount += entry.diagnostics.length + for (const diagnostic of entry.diagnostics) { + if (diagnostic.hasWebgl) { + acc.attachedWebglCount += 1 + } + if (diagnostic.webglAttachmentDeferred) { + acc.deferredWebglCount += 1 + } + } + return acc + }, + { attachedWebglCount: 0, deferredWebglCount: 0, managerCount: 0, paneCount: 0 } + ) + const state = window.__store?.getState?.() + const worktreeId = state?.activeWorktreeId ?? null + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const activePane = manager?.getActivePane?.() ?? manager?.getPanes?.()?.[0] ?? null + const buffer = activePane?.terminal?.buffer?.active ?? null + return { + hasStore: Boolean(window.__store), + workspaceSessionReady: state?.workspaceSessionReady ?? null, + hydrationSucceeded: state?.hydrationSucceeded ?? null, + activeView: state?.activeView ?? null, + activeRepoId: state?.activeRepoId ?? null, + activeWorktreeId: worktreeId, + activeTabType: state?.activeTabType ?? null, + activeTabId: tabId, + terminalGpuAcceleration: state?.settings?.terminalGpuAcceleration ?? null, + repoCount: state?.repos?.length ?? null, + worktreeCountsByRepo: Object.fromEntries( + Object.entries(state?.worktreesByRepo ?? {}).map(([repoId, worktrees]) => [ + repoId, + worktrees.length + ]) + ), + ptyIdsByTabId: tabId ? (state?.ptyIdsByTabId?.[tabId] ?? []) : [], + paneManagerCount: window.__paneManagers?.size ?? null, + activePane: activePane + ? { + id: activePane.id ?? null, + leafId: activePane.leafId ?? null, + ptyId: activePane.container?.dataset?.ptyId ?? null, + cols: activePane.terminal?.cols ?? null, + rows: activePane.terminal?.rows ?? null, + buffer: buffer + ? { + baseY: buffer.baseY, + viewportY: buffer.viewportY, + cursorY: buffer.cursorY, + length: buffer.length + } + : null + } + : null, + renderingDiagnostics: manager?.getRenderingDiagnostics?.() ?? null, + allPaneManagersDiagnostics, + webglContextCounts, + webglIdentity: readWebglIdentity(), + rendererProbe: globalThis.__orcaApphangProbe?.probe ?? null, + ptySessions: await timed('PTY sessions', window.api?.pty?.listSessions?.()), + rendererDeliveryDebug: await timed( + 'renderer delivery debug', + window.api?.pty?.getRendererDeliveryDebugSnapshot?.() + ) + } + }), + rendererActionTimeoutMs + ) + } catch (error) { + return { error: error instanceof Error ? error.message : String(error) } + } +} diff --git a/config/scripts/windows-apphang-repro/repro-timing.mjs b/config/scripts/windows-apphang-repro/repro-timing.mjs new file mode 100644 index 00000000000..9f335d61435 --- /dev/null +++ b/config/scripts/windows-apphang-repro/repro-timing.mjs @@ -0,0 +1,47 @@ +export const cdpPollTimeoutMs = 90_000 +export const rendererActionTimeoutMs = 5_000 +export const activationTimeoutMs = 15_000 +export const ptyWaitTimeoutMs = 20_000 +export const terminalMarkerTimeoutMs = 45_000 +export const setupTimeoutMs = 90_000 +export const appShutdownTimeoutMs = 8_000 +export const severeRendererDriftMs = 2_000 +export const severeActivationMs = 5_000 +export const severeMainIpcMs = 2_000 +export const severePtyWaitMs = 10_000 + +export function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +export async function runWithTimeout(label, action, timeoutMs) { + const actionPromise = Promise.resolve().then(action) + actionPromise.catch(() => undefined) + const result = await Promise.race([ + actionPromise.then( + (value) => ({ timedOut: false, value }), + (error) => ({ timedOut: false, error }) + ), + delay(timeoutMs).then(() => ({ timedOut: true })) + ]) + if (result.timedOut) { + throw new Error(`Timed out during ${label} after ${timeoutMs}ms.`) + } + if ('error' in result) { + throw result.error + } + return result.value +} + +export async function pollUntil(label, read, predicate, timeoutMs, intervalMs = 100) { + const startedAt = Date.now() + let lastValue = null + while (Date.now() - startedAt < timeoutMs) { + lastValue = await runWithTimeout(label, read, rendererActionTimeoutMs) + if (predicate(lastValue)) { + return lastValue + } + await delay(intervalMs) + } + throw new Error(`Timed out waiting for ${label}; last value: ${JSON.stringify(lastValue)}`) +} diff --git a/config/scripts/windows-apphang-repro/terminal-activation-scenario.mjs b/config/scripts/windows-apphang-repro/terminal-activation-scenario.mjs new file mode 100644 index 00000000000..db92c7cdf31 --- /dev/null +++ b/config/scripts/windows-apphang-repro/terminal-activation-scenario.mjs @@ -0,0 +1,486 @@ +import { + collectRendererDiagnostics, + connectToApp, + createGpuUserDataDirectory, + installRendererProbe, + launchDevApp, + pickFreePort, + stopDevApp, + waitForStoreReady +} from './electron-dev-session.mjs' +import { summarizeAppLogs, summarizeTrace } from './apphang-report-summary.mjs' +import { + activationTimeoutMs, + pollUntil, + ptyWaitTimeoutMs, + rendererActionTimeoutMs, + runWithTimeout, + setupTimeoutMs, + severeActivationMs, + severeMainIpcMs, + severePtyWaitMs, + severeRendererDriftMs, + terminalMarkerTimeoutMs +} from './repro-timing.mjs' +import { safeRemoveLocalDirectory } from './wsl-workspace-fixture.mjs' + +class ReproductionObservedError extends Error { + constructor(message, evidence) { + super(message) + this.name = 'ReproductionObservedError' + this.evidence = evidence + } +} + +async function setupAppFixture(page, fixture, gpuMode, sourceControl) { + return await runWithTimeout( + 'fixture registration in Orca', + () => + page.evaluate( + async ({ repoPath, plainPath, importedWorktreePaths, mode, openSourceControl }) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is unavailable.') + } + const state = store.getState() + await state.fetchSettings?.() + await store.getState().updateSettings({ terminalGpuAcceleration: mode }) + + const addResult = await window.api.repos.add({ path: repoPath, kind: 'git' }) + if ('error' in addResult) { + throw new Error(addResult.error) + } + await store.getState().fetchRepos() + let nextState = store.getState() + const repo = + nextState.repos.find((candidate) => candidate.path === repoPath) ?? addResult.repo + await nextState.updateRepo(repo.id, { + externalWorktreeVisibility: 'show', + externalWorktreeVisibilityPromptDismissedAt: Date.now(), + importedExternalWorktreePaths: importedWorktreePaths, + externalWorktreeInboxBaselinePaths: importedWorktreePaths + }) + await store.getState().fetchWorktrees(repo.id, { requireAuthoritative: true }) + + const plainRepo = await store.getState().addNonGitFolder(plainPath) + if (!plainRepo) { + throw new Error('addNonGitFolder returned null.') + } + await store.getState().fetchWorktrees(plainRepo.id, { requireAuthoritative: true }) + + nextState = store.getState() + nextState.setSidebarOpen(true) + nextState.setGroupBy('none') + nextState.setSortBy('recent') + nextState.setShowActiveOnly(false) + nextState.setActiveView('terminal') + if (openSourceControl) { + nextState.setRightSidebarOpen(true) + nextState.setRightSidebarTab('source-control') + } + + const gitWorktrees = nextState.worktreesByRepo[repo.id] ?? [] + const plainWorktrees = nextState.worktreesByRepo[plainRepo.id] ?? [] + return { + repoId: repo.id, + repoPath: repo.path, + plainRepoId: plainRepo.id, + gitWorktrees: gitWorktrees.map((worktree) => ({ + id: worktree.id, + path: worktree.path, + displayName: worktree.displayName, + branch: worktree.branch, + isMainWorktree: worktree.isMainWorktree + })), + plainWorktrees: plainWorktrees.map((worktree) => ({ + id: worktree.id, + path: worktree.path, + displayName: worktree.displayName, + branch: worktree.branch, + isMainWorktree: worktree.isMainWorktree + })) + } + }, + { + repoPath: fixture.repoUncPath, + plainPath: fixture.plainUncPath, + importedWorktreePaths: fixture.worktreeUncPaths, + mode: gpuMode, + openSourceControl: sourceControl + } + ), + setupTimeoutMs + ) +} + +async function clickWorktreeCard(page, worktreeId) { + const rect = await runWithTimeout( + `locate worktree card ${worktreeId}`, + () => + page.evaluate((id) => { + const rows = Array.from(document.querySelectorAll('[data-worktree-id]')) + const row = rows.find((candidate) => candidate.getAttribute('data-worktree-id') === id) + if (!row) { + return null + } + row.scrollIntoView({ block: 'center', inline: 'nearest' }) + const surface = row.querySelector('[data-worktree-card-surface="true"]') ?? row + const bounds = surface.getBoundingClientRect() + if (bounds.width <= 0 || bounds.height <= 0) { + return null + } + return { + x: bounds.left + bounds.width / 2, + y: bounds.top + bounds.height / 2, + width: bounds.width, + height: bounds.height + } + }, worktreeId), + rendererActionTimeoutMs + ) + if (!rect) { + throw new Error(`Could not find rendered worktree card for ${worktreeId}`) + } + await runWithTimeout( + `click worktree card ${worktreeId}`, + () => page.mouse.click(rect.x, rect.y), + rendererActionTimeoutMs + ) +} + +async function waitForActiveWorktree(page, worktreeId) { + return await pollUntil( + `active worktree ${worktreeId}`, + () => + page.evaluate((id) => { + const state = window.__store?.getState?.() + return { + activeWorktreeId: state?.activeWorktreeId ?? null, + activeTabId: state?.activeTabId ?? null, + activeTabType: state?.activeTabType ?? null, + tabs: state?.tabsByWorktree?.[id]?.map((tab) => tab.id) ?? [] + } + }, worktreeId), + (value) => value?.activeWorktreeId === worktreeId && value.activeTabType === 'terminal', + activationTimeoutMs + ) +} + +async function waitForActivePty(page, worktreeId) { + const startedAt = Date.now() + const value = await pollUntil( + `active PTY for ${worktreeId}`, + () => + page.evaluate((id) => { + const state = window.__store?.getState?.() + const tabId = + state?.activeWorktreeId === id && state.activeTabType === 'terminal' + ? state.activeTabId + : (state?.activeTabIdByWorktree?.[id] ?? null) + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()?.[0] ?? null + const ptyId = pane?.container?.dataset?.ptyId ?? null + return { + tabId, + ptyId, + hasManager: Boolean(manager), + paneCount: manager?.getPanes?.()?.length ?? 0, + ptyIdsByTab: tabId ? (state?.ptyIdsByTabId?.[tabId] ?? []) : [] + } + }, worktreeId), + (value) => Boolean(value?.ptyId), + ptyWaitTimeoutMs + ) + return { ...value, waitMs: Date.now() - startedAt } +} + +function makeOutputCommand(marker, outputLines) { + const payload = 'x'.repeat(180) + return `printf '${marker}_START\\n'; i=1; while [ "$i" -le ${outputLines} ]; do printf '${marker}_%05d ${payload}\\n' "$i"; i=$((i+1)); done; printf '${marker}_DONE\\n'\r` +} + +async function getTerminalContent(page, charLimit = 20_000) { + return await runWithTimeout( + 'terminal content', + () => + page.evaluate((limit) => { + const state = window.__store?.getState?.() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()?.[0] ?? null + return (pane?.serializeAddon?.serialize?.() ?? '').slice(-limit) + }, charLimit), + rendererActionTimeoutMs + ) +} + +async function sendOutputStress(page, ptyId, marker, outputLines) { + await runWithTimeout( + `write terminal stress ${marker}`, + () => + page.evaluate( + ({ id, command }) => { + window.api.pty.write(id, command) + }, + { id: ptyId, command: makeOutputCommand(marker, outputLines) } + ), + rendererActionTimeoutMs + ) + await pollUntil( + `terminal stress marker ${marker}`, + async () => (await getTerminalContent(page)).includes(`${marker}_DONE`), + Boolean, + terminalMarkerTimeoutMs, + 200 + ) +} + +async function pingMainIpc(page) { + const startedAt = Date.now() + await runWithTimeout( + 'main IPC ping', + () => page.evaluate(() => window.api.pty.listSessions()), + rendererActionTimeoutMs + ) + return Date.now() - startedAt +} + +async function readRendererProbe(page) { + return await runWithTimeout( + 'renderer probe read', + () => page.evaluate(() => globalThis.__orcaApphangProbe?.probe ?? null), + rendererActionTimeoutMs + ) +} + +async function killActivePty(page) { + return await runWithTimeout( + 'kill active PTY', + () => + page.evaluate(async () => { + const state = window.__store?.getState?.() + const tabId = state?.activeTabId ?? null + const ptyIds = tabId ? (state?.ptyIdsByTabId?.[tabId] ?? []) : [] + const ptyId = ptyIds[0] ?? null + if (!ptyId) { + return null + } + await window.api.pty.kill(ptyId) + return ptyId + }), + rendererActionTimeoutMs + ) +} + +async function resetRendererProbe(page) { + // Why: probe drift is cumulative since installation; without a per-cycle + // reset, one startup/setup stall gets re-reported as a hang in every + // later cycle's rendererMaxDriftMs check. + await runWithTimeout( + 'renderer probe reset', + () => + page.evaluate(() => { + const probe = globalThis.__orcaApphangProbe?.probe + if (!probe) { + return + } + const now = performance.now() + probe.last = now + probe.lastTickAt = now + probe.startedAt = now + probe.maxDriftMs = 0 + probe.samples = 0 + }), + rendererActionTimeoutMs + ) +} + +async function runActivationCycle(page, target, args) { + await resetRendererProbe(page) + const marker = `ORCA_APPHANG_${target.index}_${Date.now()}` + const cycleStartedAt = Date.now() + const activationStartedAt = Date.now() + await clickWorktreeCard(page, target.id) + const activation = await waitForActiveWorktree(page, target.id) + const activationMs = Date.now() - activationStartedAt + const activePty = await waitForActivePty(page, target.id) + const diagnosticsBeforeOutput = await collectRendererDiagnostics(page) + const outputStartedAt = Date.now() + await sendOutputStress(page, activePty.ptyId, marker, args.outputLines) + const outputMs = Date.now() - outputStartedAt + const mainIpcMs = await pingMainIpc(page) + const rendererProbe = await readRendererProbe(page) + const diagnosticsAfterOutput = await collectRendererDiagnostics(page) + const elapsedMs = Date.now() - cycleStartedAt + const sample = { + index: target.index, + kind: target.kind, + worktreeId: target.id, + displayName: target.displayName, + elapsedMs, + activationMs, + outputMs, + activation, + ptyWaitMs: activePty.waitMs, + ptyId: activePty.ptyId, + tabId: activePty.tabId, + mainIpcMs, + rendererMaxDriftMs: rendererProbe?.maxDriftMs ?? null, + renderingDiagnostics: diagnosticsAfterOutput?.renderingDiagnostics ?? null, + webglIdentity: diagnosticsAfterOutput?.webglIdentity ?? null, + diagnosticsBeforeOutput, + diagnosticsAfterOutput + } + + const reproducedReasons = [] + if (activationMs > severeActivationMs) { + reproducedReasons.push(`worktree activation took ${activationMs}ms`) + } + if (activePty.waitMs > severePtyWaitMs) { + reproducedReasons.push(`PTY binding took ${activePty.waitMs}ms`) + } + if (mainIpcMs > severeMainIpcMs) { + reproducedReasons.push(`main IPC ping took ${mainIpcMs}ms`) + } + if ((rendererProbe?.maxDriftMs ?? 0) > severeRendererDriftMs) { + reproducedReasons.push(`renderer timer drift reached ${Math.round(rendererProbe.maxDriftMs)}ms`) + } + if (reproducedReasons.length > 0) { + throw new ReproductionObservedError(reproducedReasons.join('; '), sample) + } + return sample +} + +async function runDeadPtyReactivation(page, targets, args) { + const samples = [] + for (const target of targets) { + await clickWorktreeCard(page, target.id) + await waitForActiveWorktree(page, target.id) + await waitForActivePty(page, target.id) + const killedPtyId = await killActivePty(page) + samples.push({ worktreeId: target.id, killedPtyId }) + } + for (const target of targets) { + samples.push( + await runActivationCycle(page, { ...target, kind: `${target.kind}:dead-pty` }, args) + ) + } + return samples +} + +function selectTargets(setupResult, cycles) { + const gitWorktrees = setupResult.gitWorktrees + .filter((worktree) => worktree.path) + .sort((a, b) => Number(b.isMainWorktree) - Number(a.isMainWorktree)) + .slice(0, 5) + .map((worktree) => ({ ...worktree, kind: 'git-wsl' })) + const plainWorktrees = setupResult.plainWorktrees.map((worktree) => ({ + ...worktree, + kind: 'plain-folder-wsl' + })) + const baseTargets = [...gitWorktrees, ...plainWorktrees] + if (baseTargets.length === 0) { + throw new Error('No worktrees were discovered for the repro fixture.') + } + return Array.from({ length: cycles }, (_, index) => ({ + ...baseTargets[index % baseTargets.length], + index: index + 1 + })) +} + +export async function runGpuMode(gpuMode, args, fixture) { + const cdpPort = await pickFreePort() + const userDataDir = createGpuUserDataDirectory(gpuMode) + const launched = launchDevApp({ cdpPort, userDataDir }) + let browser = null + let page = null + const startedAt = Date.now() + const result = { + gpuMode, + reproduced: false, + reproductionReason: null, + harnessError: null, + startedAt, + elapsedMs: null, + cdpPort, + userDataDir, + appPid: launched.child.pid ?? null, + setup: null, + samples: [], + finalDiagnostics: null, + trace: null, + appLogSummary: null, + appLogsTail: null, + cleanupErrors: [] + } + + try { + const connected = await connectToApp(cdpPort) + browser = connected.browser + page = connected.page + await waitForStoreReady(page) + await installRendererProbe(page) + const identity = await runWithTimeout( + 'app identity', + () => page.evaluate(() => window.api.app.getIdentity?.()), + rendererActionTimeoutMs + ).catch(() => null) + console.log( + `[apphang-repro] connected gpu=${gpuMode} pid=${result.appPid} identity=${JSON.stringify(identity)}` + ) + result.setup = await setupAppFixture(page, fixture, gpuMode, args.sourceControl) + const targets = selectTargets(result.setup, args.cycles) + console.log( + `[apphang-repro] targets gpu=${gpuMode}: ${targets + .map((target) => `${target.kind}:${target.displayName}`) + .join(', ')}` + ) + for (const target of targets) { + console.log(`[apphang-repro] cycle=${target.index} gpu=${gpuMode} kind=${target.kind}`) + result.samples.push(await runActivationCycle(page, target, args)) + } + if (args.deadPtyReactivate) { + const uniqueTargets = [] + const seen = new Set() + for (const target of targets) { + if (!seen.has(target.id)) { + seen.add(target.id) + uniqueTargets.push(target) + } + } + console.log(`[apphang-repro] dead-pty-reactivation gpu=${gpuMode}`) + result.samples.push(...(await runDeadPtyReactivation(page, uniqueTargets, args))) + } + } catch (error) { + if (error instanceof ReproductionObservedError) { + result.reproduced = true + result.reproductionReason = error.message + result.samples.push(error.evidence) + } else { + // Why: setup/CDP/selector failures are inconclusive, not hang evidence. + // Conflating them with `reproduced` lets --expect=repro pass on a broken + // harness; callers treat harnessError as a hard failure instead. + result.harnessError = error instanceof Error ? error.stack || error.message : String(error) + } + } finally { + result.elapsedMs = Date.now() - startedAt + result.finalDiagnostics = await collectRendererDiagnostics(page) + result.trace = summarizeTrace(userDataDir) + result.appLogSummary = summarizeAppLogs(launched.logs) + result.appLogsTail = launched.logs.slice(-120) + if (browser) { + await browser.close().catch(() => undefined) + } + await stopDevApp(launched.child) + if (!args.keep) { + safeRemoveLocalDirectory(userDataDir, result.cleanupErrors) + } + } + return result +} diff --git a/config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs b/config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs new file mode 100644 index 00000000000..cd38f400d47 --- /dev/null +++ b/config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs @@ -0,0 +1,135 @@ +import { execFileSync } from 'node:child_process' +import { mkdirSync, rmSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const rootDir = path.resolve(fileURLToPath(new URL('../../..', import.meta.url))) + +function runWsl(distro, script, options = {}) { + return execFileSync('wsl.exe', ['-d', distro, '--', 'bash', '-se'], { + cwd: rootDir, + encoding: 'utf8', + input: script, + timeout: options.timeoutMs ?? 120_000, + stdio: ['pipe', 'pipe', 'pipe'] + }) +} + +export function listWslDistros() { + const output = execFileSync('wsl.exe', ['--list', '--quiet'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 10_000 + }) + return output + .replaceAll(String.fromCharCode(0), '') + .split(/\r?\n/) + .map((line) => line.trim().replace(/^\*\s*/, '')) + .filter((line) => line && !line.toLowerCase().startsWith('docker-desktop')) +} + +function linuxPathToWslUnc(distro, linuxPath) { + if (!linuxPath.startsWith('/')) { + throw new Error(`Expected absolute Linux path, got ${linuxPath}`) + } + return `\\\\wsl.localhost\\${distro}${linuxPath.replace(/\//g, '\\')}` +} + +export function createWslFixture(distro) { + const script = String.raw` +set -euo pipefail +base="$(mktemp -d /tmp/orca-apphang-repro.XXXXXX)" +repo="$base/repo" +mkdir -p "$repo" +cd "$repo" +git init -q +git config user.email apphang-repro@test.local +git config user.name "AppHang Repro" +mkdir -p src docs +printf '# Orca Windows AppHang repro\n' > README.md +for n in $(seq 1 25); do + printf 'line %03d %s\n' "$n" "abcdefghijklmnopqrstuvwxyz0123456789" >> src/payload.txt +done +git add -A +git commit -q -m "Initial repro fixture" +for n in 1 2 3 4; do + git branch "repro-wt-$n" + git worktree add -q "$base/wt-$n" "repro-wt-$n" + mkdir -p "$base/wt-$n/docs" + printf 'worktree %s\n' "$n" > "$base/wt-$n/docs/wt.txt" +done +plain="$base/plain-folder" +mkdir -p "$plain/subdir" +printf 'plain folder for Orca AppHang repro\n' > "$plain/README.txt" +printf '%s\n' "$base" "$repo" "$base/wt-1" "$base/wt-2" "$base/wt-3" "$base/wt-4" "$plain" +` + const lines = runWsl(distro, script, { timeoutMs: 120_000 }).trim().split(/\r?\n/).filter(Boolean) + if (lines.length < 7) { + throw new Error(`WSL fixture creation returned unexpected output: ${JSON.stringify(lines)}`) + } + const [base, repo, ...rest] = lines + const plain = rest.at(-1) + const worktrees = rest.slice(0, -1) + return { + distro, + baseLinuxPath: base, + repoLinuxPath: repo, + plainLinuxPath: plain, + worktreeLinuxPaths: worktrees, + repoUncPath: linuxPathToWslUnc(distro, repo), + plainUncPath: linuxPathToWslUnc(distro, plain), + worktreeUncPaths: worktrees.map((entry) => linuxPathToWslUnc(distro, entry)) + } +} + +export function removeWslFixture(fixture) { + if (!fixture?.baseLinuxPath) { + return + } + const quoted = fixture.baseLinuxPath.replaceAll("'", "'\\''") + runWsl(fixture.distro, `rm -rf '${quoted}'`, { timeoutMs: 30_000 }) +} + +export function createCompletedOnboardingProfile(userDataDir) { + mkdirSync(userDataDir, { recursive: true }) + const profile = { + settings: { + telemetry: { + // Why: synthetic harness/benchmark activity must not send telemetry. + // Explicit false with existedBeforeTelemetryRelease=false also keeps + // the first-launch consent surface from blocking automation. + optedIn: false, + installId: '00000000-0000-4000-8000-000000000000', + existedBeforeTelemetryRelease: false + } + }, + onboarding: { + flowVersion: 4, + closedAt: 1, + outcome: 'completed', + lastCompletedStep: 5 + }, + ui: { + contextualToursAutoEligible: false, + contextualToursSeenIds: [ + 'workspace-board', + 'browser', + 'tasks', + 'automations', + 'workspace-creation' + ], + featureTipsSeenIds: [], + featureInteractions: {}, + projectOrderManualDefaultNoticeDismissed: true + } + } + writeFileSync(path.join(userDataDir, 'orca-data.json'), `${JSON.stringify(profile, null, 2)}\n`) +} + +export function safeRemoveLocalDirectory(dir, cleanupErrors) { + try { + rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }) + } catch (error) { + cleanupErrors.push(error instanceof Error ? error.message : String(error)) + } +} diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 955567a21fa..a7c4761a0ec 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -11,7 +11,9 @@ "../src/main/claude/hook-settings.ts", "../src/main/claude/hook-service.ts", "../src/main/codex/codex-config-mirror.ts", + "../src/main/codex/codex-config-path-reference-rewrite.ts", "../src/main/codex/codex-home-paths.ts", + "../src/main/codex/config-toml-line-scan.ts", "../src/main/codex/config-toml-trust.ts", "../src/main/codex/hook-service.ts", "../src/main/codex-accounts/fs-utils.ts", diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index 8287429c5dc..abc2d902e9a 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 3.1m + + downloads: 3.6m @@ -15,7 +15,7 @@ downloads downloads - 3.1m - 3.1m + 3.6m + 3.6m diff --git a/docs/assets/star-history.png b/docs/assets/star-history.png new file mode 100644 index 00000000000..39d06509590 Binary files /dev/null and b/docs/assets/star-history.png differ diff --git a/docs/assets/wechat-qr.png b/docs/assets/wechat-qr.png index 3c71c4952ea..bf270d857e8 100644 Binary files a/docs/assets/wechat-qr.png and b/docs/assets/wechat-qr.png differ diff --git a/docs/claude-fable-weekly-usage-meter.md b/docs/claude-fable-weekly-usage-meter.md new file mode 100644 index 00000000000..ae7bcc0f909 --- /dev/null +++ b/docs/claude-fable-weekly-usage-meter.md @@ -0,0 +1,75 @@ +# Claude Fable Weekly Usage Meter + +## Problem + +Claude Code now exposes weekly subscription usage alongside the 5-hour window, and its live `/usage` panel can show an explicit Fable-specific weekly bucket. Anthropic documents `rate_limits.five_hour` and `rate_limits.seven_day` in Claude Code statusline JSON, with weekly data present for Claude.ai subscribers after the first API response. The existing Orca Claude meter already has a weekly slot in shared state, but it needs a distinct Fable weekly slot so the status bar can show all three visible meters when Claude reports them. + +Relevant code: + +- `src/shared/rate-limit-types.ts:46` models provider usage with `session` and `weekly` windows. +- `src/main/rate-limits/claude-fetcher.ts:373` maps OAuth `five_hour` and `seven_day` into Orca's Claude provider state. +- `src/main/rate-limits/claude-pty.ts:18` parses hidden `claude` `/usage` output, but `WEEKLY_RE` only accepts `Current week`. +- `src/renderer/src/components/status-bar/StatusBar.tsx:1112` renders both session and weekly windows when both are present. +- `src/renderer/src/components/status-bar/tooltip.tsx:138` includes weekly usage in the details popover. + +Research: + +- Official Claude Code statusline docs: [`rate_limits.five_hour.used_percentage` and `rate_limits.seven_day.used_percentage`](https://code.claude.com/docs/en/statusline#available-data), plus matching `resets_at`, are the 5-hour and 7-day rate-limit fields. +- `harveyxiacn/cc-usage-monitor` uses Claude Code's statusline `rate_limits` data and shows both [`5h` and `7d` windows](https://github.com/harveyxiacn/cc-usage-monitor), matching Orca's existing `session` and `weekly` model. +- `leeguooooo/claude-code-usage-bar` independently exposes the same [`5h` and `7d` rate-limit usage](https://github.com/leeguooooo/claude-code-usage-bar) in a Claude Code statusLine integration. +- Fable is not part of the documented statusline schema above. Orca only treats it as Fable weekly usage when the live `/usage` panel renders a standalone `Fable` label or an OAuth response uses an explicit weekly/seven-day Fable field name. + +Redacted live `/usage` shape this parser targets: + +```text +Plan usage limits + +Current session +18% remaining +Resets in 2h 10m + +Current week (all models) +84% left +Resets in 5d 4h + +Fable +42% consumed +Resets in 3d 2h +``` + +## Goal + +Make Orca's existing Claude status-bar meter show the weekly Claude and Fable usage windows whenever Claude Code reports them, including newer `/usage` panel wording such as `Weekly limits`, `Fable`, or `7-day`. + +## Non-goals + +- Do not infer subscription quota from token logs. +- Do not spend user Claude quota during automated verification. +- Do not change provider account switching, polling cadence, or OAuth credential handling. + +## Design + +1. Keep `ProviderRateLimits.weekly` as the canonical generic 7-day UI field. OAuth already maps `seven_day` to `weekly`, and the status bar already renders it next to the 5-hour window. +2. Add `ProviderRateLimits.fableWeekly` as a distinct optional Claude window so the chip and popover can render Session, Weekly, and Fable simultaneously. +3. Accept both OAuth `utilization` windows and Claude Code-style `used_percentage` windows with epoch-second `resets_at` values. +4. Broaden the hidden Claude CLI parser so the generic weekly label accepts both old `Current week` wording and newer usage/statusline wording: `Weekly limits`, `Weekly usage`, `weekly rate limit`, and `7-day`. +5. Parse only a standalone `Fable` label into `fableWeekly` instead of collapsing it into generic `weekly`; ambiguous Fable copy is a section boundary, not a meter. +6. Broaden percent parsing to treat `consumed` like `used`, because Anthropic describes rate-limit percentages as consumed. +7. Add focused tests for the new weekly wording and retain existing old-copy coverage. + +## Edge Cases + +- Weekly data may be absent for API-key users or before the first Claude API response; keep `weekly: null`. +- The hidden PTY fallback may still only return session data; the status bar should continue showing the 5-hour meter without error. +- Reset timestamps/descriptions may be absent from CLI output; keep `resetsAt: null` and parse only visible reset text. +- Fable data may be absent from the documented statusline payload even when the interactive `/usage` panel shows it; keep `fableWeekly: null` unless an explicit weekly/seven-day field or standalone `Fable` label is present. +- A bare OAuth `fable` field is ambiguous because it does not encode the window length; ignore it until the upstream contract is clearer. + +## Rollout + +1. Update OAuth window mapping for statusline-style percentages, reset timestamps, and distinct Fable weekly fields when present. +2. Update `claude-pty` weekly label, Fable label, and percent parsing. +3. Add focused tests for statusline-style OAuth data, `Weekly limits`, `Fable`, and `7-day` wording. +4. Run focused tests, then typecheck/lint. +5. Validate in Electron by injecting a Claude provider state with 5-hour, generic weekly, and Fable weekly data and capturing status-bar screenshots. +6. Commit, push, open a PR, and attach screenshots in a PR comment. diff --git a/docs/readme/README.es.md b/docs/readme/README.es.md index 52cf23f2706..e1403aed2df 100644 --- a/docs/readme/README.es.md +++ b/docs/readme/README.es.md @@ -12,7 +12,7 @@

- English · 中文 · 日本語 · 한국어 + English · Português · 中文 · 日本語 · 한국어

@@ -36,7 +36,7 @@ Supervisa y dirige a tus agentes desde el teléfono — recibe una notificación cuando un agente termine y envía instrucciones de seguimiento desde cualquier lugar. -[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) +[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) @@ -227,7 +227,7 @@ yay -S stably-orca-bin Vincúlala con tu app de escritorio para supervisar y dirigir a tus agentes desde el teléfono. - **iOS:** [Descargar desde App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk) +- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) --- diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md index a65661afee1..22e461eb611 100644 --- a/docs/readme/README.ja.md +++ b/docs/readme/README.ja.md @@ -12,7 +12,7 @@

- English · Español · 中文 · 한국어 + English · Español · Português · 中文 · 한국어

@@ -36,7 +36,7 @@ スマートフォンからエージェントを監視・操作 — エージェントの完了を通知で受け取り、どこからでもフォローアップを送信できます。 -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile) @@ -227,7 +227,7 @@ yay -S stably-orca-bin デスクトップアプリとペアリングして、スマートフォンからエージェントを監視・操作できます。 - **iOS:** [App Store からダウンロード](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk) +- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) --- diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md index f62e14648ab..b2fc4857112 100644 --- a/docs/readme/README.ko.md +++ b/docs/readme/README.ko.md @@ -12,7 +12,7 @@

- English · Español · 中文 · 日本語 + English · Español · Português · 中文 · 日本語

@@ -36,7 +36,7 @@ 휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다. -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile) @@ -227,7 +227,7 @@ yay -S stably-orca-bin 데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요. - **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk) +- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) --- diff --git a/docs/readme/README.pt.md b/docs/readme/README.pt.md new file mode 100644 index 00000000000..6c3fc52354c --- /dev/null +++ b/docs/readme/README.pt.md @@ -0,0 +1,261 @@ +

+ Orca Orca +

+ +

+ Estrelas no GitHub + Total de downloads em todas as versões + Licença + Entre no Discord do Orca + Siga o Orca no X + Plataformas compatíveis: macOS, Windows e Linux +

+ +

+ English · Español · 中文 · 日本語 · 한국어 +

+ +

+ O orquestrador de IA para builders 100x.
+ Rode Codex, ClaudeCode, OpenCode ou Pi lado a lado — cada um em seu próprio worktree, acompanhado em um só lugar. +

+ +

Baixar o Orca

+ +

+ App desktop do Orca executando agentes em worktrees paralelos, com o app companion móvel do Orca no canto +

+ +## Recursos + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +### Companion móvel + +Monitore e conduza seus agentes pelo celular — receba uma notificação quando um agente terminar e envie instruções de acompanhamento de qualquer lugar. + +[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.22](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) + + + Orca desktop com o app companion móvel +
+ +### Worktrees paralelos + +Envie um mesmo prompt para cinco agentes, cada um em seu próprio worktree git isolado — compare os resultados e faça merge do vencedor. + +[Docs →](https://www.onorca.dev/docs/model/worktrees) + + + Orquestração de worktrees paralelos +
+ +### Terminais divididos + +Terminais no nível do Ghostty com renderização WebGL, divisões infinitas e scrollback que sobrevive a reinicializações. + +[Docs →](https://www.onorca.dev/docs/terminal) + + + Terminais divididos +
+ +### Modo Design + +Clique em qualquer elemento de UI em uma janela real do Chromium para enviar HTML, CSS e uma captura recortada direto para o prompt do seu agente. + +[Docs →](https://www.onorca.dev/docs/browser/design-mode) + + + Navegador integrado e Modo Design +
+ +### GitHub e Linear nativos + +Navegue por PRs, issues e quadros de projeto dentro do app — abra um worktree a partir de qualquer tarefa e revise sem trocar de contexto. + +[Docs →](https://www.onorca.dev/docs/review/linear) + + + Fluxos de trabalho de tarefas do GitHub e Linear no Orca +
+ +### Worktrees por SSH + +Execute agentes em uma máquina remota potente com edição completa de arquivos, git e terminais — com reconexão automática e encaminhamento de portas incluídos. + +[Docs →](https://www.onorca.dev/docs/ssh) + + + Worktrees remotos por SSH +
+ +### Anotar diffs de IA + +Deixe comentários em qualquer linha de diff e envie-os de volta ao agente — revise, edite e faça commit sem sair do Orca. + +[Docs →](https://www.onorca.dev/docs/review/annotate-ai-diff) + + + Anotar diffs gerados por IA +
+ +### Arraste arquivos para agentes + +O editor do VS Code com salvamento automático em todos os lugares — arraste arquivos ou imagens direto para o prompt de um agente. + +[Docs →](https://www.onorca.dev/docs/editing/file-explorer) + + + Arraste arquivos e imagens para o prompt de um agente +
+ +### Orca CLI + +Agentes também controlam o Orca — automatize qualquer fluxo de trabalho com `orca worktree create`, `snapshot`, `click` e `fill`. + +[Docs →](https://www.onorca.dev/docs/cli/overview) + + + Automatize o Orca pela CLI +
+ +**Também incluído:** + +- **[Abertura rápida](https://www.onorca.dev/docs/model/quick-open)** — Pesquise entre worktrees, arquivos, agentes, comandos e contexto do repositório sem sair do seu fluxo. +- **[Troca de contas e acompanhamento de uso](https://www.onorca.dev/docs/agents/usage-tracking)** — Veja o uso de Claude e Codex, os reinícios de limites e troque contas instantaneamente sem fazer login de novo. +- **[Prévias ricas do repositório](https://www.onorca.dev/docs/editing/markdown)** — Pré-visualize Markdown, imagens, PDFs e documentos do repositório no workspace. +- **[Computer Use](https://www.onorca.dev/docs/cli/computer-use)** — Deixe agentes operarem apps de desktop e UI visível quando um fluxo de trabalho precisa de interação real. +- **[Notificações e estado de não lido](https://www.onorca.dev/docs/notifications)** — Saiba quando um agente termina ou precisa de atenção, depois marque conversas como não lidas para voltar depois. +- **E muito, muito mais** — lançamos novidades todos os dias, então esta lista vive atrasada. O [changelog](https://github.com/stablyai/orca/releases) é a lista real de recursos. + +--- + +## Agentes compatíveis + +Funciona com **qualquer agente CLI** — se roda em um terminal, roda no Orca. + +

+ Logotipo do Claude Code Claude Code   + Logotipo do Codex Codex   + Logotipo do Grok Grok   + Logotipo do Cursor Cursor   + Logotipo do GitHub Copilot GitHub Copilot   + Logotipo do OpenCode OpenCode   + Logotipo do MiMo Code MiMo Code   + Logotipo do Amp Amp   + Logotipo do OpenClaude OpenClaude   + Logotipo do Antigravity Antigravity   + Logotipo do Pi Pi   + Logotipo do oh-my-pi oh-my-pi   + Logotipo do Hermes Agent Hermes Agent   + Logotipo do Devin Devin   + Logotipo do Goose Goose   + Logotipo do Auggie Auggie   + Logotipo do Autohand Code Autohand Code   + Logotipo do Charm Charm   + Logotipo do Cline Cline   + Logotipo do Codebuff Codebuff   + Logotipo do Command Code Command Code   + Logotipo do Continue Continue   + Logotipo do Droid Droid   + Logotipo do Kilocode Kilocode   + Logotipo do Kimi Kimi   + Logotipo do Kiro Kiro   + Logotipo do Mistral Vibe Mistral Vibe   + Logotipo do Qwen Code Qwen Code   + Logotipo do Rovo Dev Rovo Dev   + + qualquer agente CLI +

+ +--- + +## Instalação + +### Desktop — macOS, Windows, Linux + +- **[Baixe em onOrca.dev](https://onorca.dev/download)** +- Ou baixe um build diretamente: [macOS Apple Silicon](https://github.com/stablyai/orca/releases/latest/download/orca-macos-arm64.dmg) · [macOS Intel](https://github.com/stablyai/orca/releases/latest/download/orca-macos-x64.dmg) · [Windows (.exe)](https://github.com/stablyai/orca/releases/latest/download/orca-windows-setup.exe) · [Linux AppImage](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) · [Todos os builds](https://github.com/stablyai/orca/releases/latest) +- Rodando `orca serve` em um servidor Linux headless? Veja o [guia de servidor Linux headless](../reference/headless-linux-server.md). + +_Ou por um gerenciador de pacotes:_ + +```bash +# macOS (Homebrew) +brew install --cask stablyai/orca/orca + +# Arch Linux (AUR) — ou stably-orca-git para compilar a partir do código-fonte +yay -S stably-orca-bin +``` + +### Companion móvel — iOS, Android + +Conecte ao app desktop para monitorar e conduzir seus agentes pelo celular. + +- **iOS:** [Baixar na App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) ou [entrar no TestFlight](https://testflight.apple.com/join/YjeGMQBA) +- **Android:** [Baixar APK 0.0.22](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) + +--- + +## Comunidade e suporte + +- **Discord:** Entre na comunidade no **[Discord](https://discord.gg/fzjDKHxv8Q)**. +- **Twitter / X:** Siga **[@orca_build](https://x.com/orca_build)** para atualizações e anúncios. +- **WeChat:** Escaneie o QR code para entrar na comunidade. + + QR code do WeChat para a comunidade Orca + +- **Feedback e ideias:** Lançamos rápido. Sentiu falta de algo? [Peça um novo recurso](https://github.com/stablyai/orca/issues). +- **Privacidade:** Veja a [documentação de privacidade e telemetria](https://www.onorca.dev/docs/telemetry) para saber quais dados anônimos de uso o Orca coleta e como desativar. +- **Mostre apoio:** Dê uma [estrela](https://github.com/stablyai/orca) neste repositório para acompanhar nossos lançamentos diários. + +--- + +## Desenvolvimento + +Quer contribuir ou rodar localmente? Veja nosso guia [CONTRIBUTING.md](../../.github/CONTRIBUTING.md). + + + Contribuidores do Orca + + +## Licença + +Orca é livre e de código aberto sob a [Licença MIT](../../LICENSE). diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index 46ff846a7ee..4c538b08f22 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -12,7 +12,7 @@

- English · Español · 日本語 · 한국어 + English · Español · Português · 日本語 · 한국어

@@ -36,7 +36,7 @@ 用手机监控并指挥你的智能体 — 智能体完成时收到通知,随时随地发送后续指令。 -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile) @@ -227,7 +227,7 @@ yay -S stably-orca-bin 与桌面应用配对,用手机监控并指挥你的智能体。 - **iOS:** [从 App Store 下载](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk) +- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) --- diff --git a/docs/reference/reliability-gates-implementation-plan.md b/docs/reference/reliability-gates-implementation-plan.md new file mode 100644 index 00000000000..818bdb175e1 --- /dev/null +++ b/docs/reference/reliability-gates-implementation-plan.md @@ -0,0 +1,783 @@ +# Reliability Gates Implementation Plan + +Date: 2026-07-02 + +Source context: [`docs/reference/reliability-pain-points-2026-06-30.md`](./reliability-pain-points-2026-06-30.md). + +## Current Status + +Last updated: 2026-07-03. + +The first repo-side reliability batch is useful, but it should be treated as a first-layer regression net, not a finished terminal reliability program. Before final merge claims, rebase or retarget the stack onto fresh `origin/main` and verify it still includes the protections that landed there after the branch was cut. + +Stack topology facts the rest of this plan depends on (verified 2026-07-02): + +- `brennanb2025/fix-terminal-reliability` is simultaneously the integration worktree and the head branch of #7001, and it is the base branch of all five child PRs (#7004-#7008). Committing broad product changes to it would turn #7001 into the single large terminal PR this plan forbids and would silently change every child PR's diff. +- The committed branch contains only the manifest, checker, checker tests, and these docs (a 5-gate manifest as committed). Everything else this plan describes — the expanded manifest, product hardening, and new or updated tests across roughly 39 modified files plus 3 untracked files — exists only as uncommitted working-tree changes in this worktree. No evidence run below is reproducible from any pushed ref until the split branches are committed and pushed. +- The branch's merge-base with `origin/main` is roughly 97 commits stale. Fresh main renamed `terminal-webgl-paste-recovery.{ts,test.ts}` to `terminal-webgl-atlas-recovery.{ts,test.ts}` in #6949, so the current `xterm-addon.boundary-containment` manifest entry and its documented command break on rebase; see Fresh-Eyes Corrections. +- The manifest is the single source of truth for gate counts and per-gate status. Where this document records counts they are dated snapshots; `pnpm run check:reliability-gates` prints the current gate total. + +Known Orca PR stack: + +- [#7001](https://github.com/stablyai/orca/pull/7001), `Add reliability gate manifest`: merged on 2026-07-03. The manifest, checker, and plan docs are on main; [#7295](https://github.com/stablyai/orca/pull/7295) hardens the checker policy and registers the merged #7133/#7148/#7173/#7192 regression tests as partial gates with fresh main-side evidence. +- [#7005](https://github.com/stablyai/orca/pull/7005), `Add terminal snapshot freshness contract gate`: depends on #7001 and protects the stale-liveness/newborn-PTY class behind `terminal-session.snapshot-freshness`. +- [#7008](https://github.com/stablyai/orca/pull/7008), `Expand provider session replay ownership gate`: depends on #7001 and protects the agent/provider ownership class behind `agent-session.provider-ownership`. +- [#7004](https://github.com/stablyai/orca/pull/7004), `Contain xterm addon failures to terminal panes`: depends on #7001. The current branch has a first executable xterm containment slice for addon load, link-provider, search, and WebGL failure containment; it still needs live typed input/output survival before promotion. +- [#7006](https://github.com/stablyai/orca/pull/7006), `Prove visible terminal size convergence`: depends on #7001. The current branch now has a first deterministic renderer/main contract slice for post-spawn reconcile, split-right 0x0 recovery, applied `pty:getSize`, and resume-time size reassertion; it still needs live shell, SSH/remote, and Windows ConPTY geometry proof before promotion. +- [#7007](https://github.com/stablyai/orca/pull/7007), `Add persisted session upgrade fixture gate`: depends on #7001. The current manifest entry is still commandless in this branch, so treat it as a registered startup-upgrade gap until immutable fixture commands are wired. + +Merged related product hardening: + +- [#7054](https://github.com/stablyai/orca/pull/7054), `Prevent hidden terminal TUI overlap`: merged on 2026-07-01 with `verify`, Wayland terminal input, golden Linux E2E, golden mac E2E, and community tracking checks passing. This fixes the hidden regular-terminal TUI overlap class by scheduling bounded post-parse WebGL atlas recovery for risky hidden TUI redraws while keeping hidden bytes on the background write path. +- [#7133](https://github.com/stablyai/orca/pull/7133), `Fix stale terminal frames on worktree return`: merged on 2026-07-02 as a high-priority regression fix, root-caused on a live repro. Hidden-output restore skipped the destructive clear for alternate-screen snapshots, `?1049h` is a no-op on a pane already on the alt screen, and serialized frames skip blank cells, so the pre-hide frame bled through the final frame until a select or resize. The fix writes an alt-only `\x1b[?1049h\x1b[2J\x1b[H` preamble before alt-screen snapshot data (normal-buffer scrollback untouched, still no `3J`) and hardens the WebGL reveal path: dispose-on-bail with a single-addon invariant, refresh-always atlas reset, and a settled-frame pane-scoped repaint on tab reveal, worktree resume, and window wake. It supersedes the reverted #7058 approach and closes the alt-screen hole that #7054's hidden-output skip machinery made load-bearing. Its deterministic tests — the alt-only-clear-before-snapshot assertions in `pty-connection.test.ts`, plus `pane-webgl-renderer.test.ts`, `pane-reveal-repaint.test.ts`, and `terminal-visibility-resume.test.ts` — are the seed assertions for `terminal-output.scrollback-restore` and the WebGL side of `xterm-addon.boundary-containment` once the stack rebases. + +- [#7148](https://github.com/stablyai/orca/pull/7148), `Match main terminal mirror character widths to the renderer`: merged on 2026-07-02, hours after #7133. Root cause of the residual post-#7133 bottom-row tears: the renderer xterm measures with Unicode 11 + ZWJ joining while the daemon headless mirror used default-width tables, so every positioned write after an emoji lands shifted and the tears are baked into the very mirror that restores replay — #7133's clear made restores faithful to the mirror and exposed it. The fix moves the unicode provider to `src/shared/terminal-unicode-provider.ts` and activates it in the headless emulator. Its red-green width test, `src/main/daemon/headless-emulator-unicode-width.test.ts` on main, is the seed assertion for the new `terminal-mirror.parser-parity` gate, and the shared provider plus `pane-terminal-options.ts` are the shared-construction seams that gate must assert on. #7148 also touches `pane-lifecycle.ts`, which both this worktree and #7004's branch modify — another confirmed rebase-conflict site. +- [#7173](https://github.com/stablyai/orca/pull/7173), `Fix Codex pane output tearing after app occlusion`: merged on 2026-07-03. A delayed background-origin Codex chunk carrying a stateful query could take the hidden live-write path after a newer snapshot restore had already rebuilt the renderer, overwriting newer state; an adversarial review pass added seq-restart guards so revived sessions (hibernation wake, kill plus cold-restore under the same PTY id) cannot silently freeze hidden output against a stale seq high-water mark. Its three red-proven tests in `pty-connection.test.ts` on main — reference-parse buffer equality over the ordered raw stream plus both revival guards — are exactly the exactness-oracle shape this plan prescribes and seed the hidden/live interleaving slice of `terminal-output.scrollback-restore`. +- [#7192](https://github.com/stablyai/orca/pull/7192), `Keep runtime mirror sized with desktop terminal resizes`: merged on 2026-07-03. Desktop `pty:resize` updated the PTY and renderer but never the runtime mirror, so the mirror parsed all subsequent bytes at its birth width, soft-wrapped TUI repaints into a corrupted ladder, and baked that corruption into every restore snapshot; the fix fans accepted desktop resizes to the mirror and serializes mirror reflow with queued output writes on the per-PTY write chain. This exposed a plan gap now fixed: the runtime mirror is a geometry authority in its own right, and renderer-vs-mirror convergence checks cannot see corruption upstream of the mirror. Its red-proven tests in `orca-runtime.test.ts` and `pty.test.ts` on main seed the mirror-geometry slice of `terminal-geometry.visible-convergence`. + +Factory and skill status: + +- [orca-internal #491](https://github.com/stablyai/orca-internal/pull/491), `Require reliability gates in factory reviews`: merged on 2026-07-01. This updates the factory review flow so terminal/session/provider/startup/release changes must name the relevant reliability class, invariant, gate or accepted gap, and validation evidence. +- [orca-internal #493](https://github.com/stablyai/orca-internal/pull/493), `Add Playwright reliability test skill`: as last reviewed, this still needed rebase/regeneration before merge because the reliability plan depends on Playwright tests being evidence-based, low-flake, and tied to named invariants. + +Remaining work: + +- Rebase or retarget the reliability stack on fresh `origin/main` before making final merge claims. Current main already contains terminal fixes that the stack must absorb, including hidden TUI overlap, input-lag, scrollback replay, Windows ConPTY keyboard reset, resume width flicker, remote mirror polling, and the #7133 stale alt-screen frame fixes. As part of the rebase, mechanically verify every declared `testFiles` entry in the manifest exists on fresh `origin/main`; the #6949 WebGL rename is a confirmed break. #7133 (merged 2026-07-02) touches `pty-connection.{ts,test.ts}` and `use-terminal-pane-global-effects.test.ts`, which this worktree also modifies, and `pane-webgl-renderer.{ts,test.ts}`, which #7004's branch also edits — expect real conflicts there. #7148 (merged 2026-07-02) additionally touches `pane-lifecycle.ts` and `headless-emulator.ts`, so the worktree's and #7004's pane-lifecycle edits must absorb its shared unicode-provider wiring. #7173 (merged 2026-07-03) rewrites the hidden live-write path in `pty-connection.{ts,test.ts}` and #7192 (merged 2026-07-03) touches `pty.test.ts` — both carry heavy worktree modifications, so the conflict surface keeps widening the longer the rebase waits. This worktree's replay/SSH/backpressure changes and #7004's containment changes must be rebased over #7133's alt-clear preamble, #7148's width fix, #7173's seq filtering, #7192's mirror resize fan-out, and the reveal-repaint hardening without weakening any side. +- Materialize the split: commit each uncommitted working-tree slice to its owning branch per the file assignment in the PR Split Plan, reset diverged child branches from the worktree versions of their files, and push. Only manifest, checker, and doc changes may be committed to `brennanb2025/fix-terminal-reliability` itself. +- Wire the aggregated non-blocking `reliability-gates` CI job (see Evidence Provenance And CI Wiring) so runtime and flake history accrue with machine provenance instead of hand-entered evidence. +- Split the current integration branch into focused PRs before final review and merge. Do not land this as one large terminal reliability PR; each PR should have its own invariant, evidence command, residual gaps, dependency note, and follow-up relationship to the rest of the stack. +- #7001 merged on 2026-07-03. Merge the gate-specific PRs only after resetting each from the worktree slice per the file assignment. +- Before merging each child PR, recheck it against the latest #7001/main because the shared manifest file can create normal stack drift or conflicts even when the PR currently reports clean. +- After merge, run the registered gate commands in soak and record runtime/flake evidence before promoting any gate to blocking. +- Update the factory skills again after the repo-side gate commands and CI jobs are stable, so agents can invoke exact commands instead of only requiring reliability evidence. +- Keep #7054's accepted gap visible: deterministic Claude-like TUI streams validated the renderer path, but live Claude Code was not part of that PR's validation. #7133 has since closed the alt-screen snapshot-restore hole in the same hidden-output machinery, and its buffer-merge root cause is exactly the class the `terminal-output.scrollback-restore` gate must make hard to reintroduce; the live-Claude validation gap remains. + +## PR Split Plan + +The current `brennanb2025/fix-terminal-reliability` branch is an integration branch, not the desired final review shape. It should be split into smaller PRs so reviewers can verify one reliability class at a time, bisect failures, and avoid merging a broad terminal lifecycle change without understanding which protection each file belongs to. + +Branch and manifest ownership rules: + +1. `brennanb2025/fix-terminal-reliability` is #7001's head branch. Only manifest, checker, checker-test, and plan-doc changes may be committed to it. Split items 2-6 go to their own branches, stacked on this branch and retargeted to `main` after #7001 merges. +2. #7001 may only declare gates whose `testFiles` all exist on `origin/main`. The checker's declared-file rule makes this self-enforcing: a manifest entry whose test files land in a later PR cannot pass CI inside #7001. +3. Every later PR owns its own manifest entries. It adds or upgrades the gates it implements — entry, `evidenceRuns`, and `assertionRefs` — in the same PR as the tests and hardening they reference. The manifest file is shared, but each entry has exactly one owning PR, and manifest conflicts are resolved by entry ownership, never by hand-merging divergent copies of an entry. +4. Commandless registered-gap entries (`protection: "none"`, no test files) may ride in #7001 because they reference nothing that can drift. +5. The integration worktree is the single authoritative copy of every overlapping file. Child branches that have diverged (for example, `resume-sleeping-agent-session.ts` on #7008's branch is ~156 diff lines behind this worktree) must be reset from the worktree slice, not reconciled by hand. + +Recommended split: + +1. Reliability gate manifest and plan docs. + This PR owns `config/reliability-gates.jsonc`, the manifest checker hardening, checker tests, and this plan document. It should merge first because later PRs reference gate ids, evidence metadata, and promotion rules from the manifest. It should not include terminal product behavior changes. + +2. Provider ownership and session replay safety. + This PR owns the agent/provider identity invariant: activation, restore, sleep, hibernate, dedupe, clearing, and reconnect paths must not replay a provider session that is already owned, queued, pending, retained, or live in the workspace. It should include the provider-session ownership tests, repeat-activation lower-layer proofs, automatic resume claim coverage, same-session/wrong-session hook coverage, and any narrowly required product hardening. + +3. Startup hydration, provider fanout, and targeted liveness. + This PR owns startup-adjacent PTY registry hydration, targeted `pty.hasPty(id)` liveness, no-hot `pty:listSessions()` paths, Resource Manager polling scoping, SSH/provider unknown-liveness semantics, degraded-daemon fail-closed behavior, and related tests. It should depend on the manifest PR and should explicitly say that live SSH, WSL, remote-runtime, and production startup reconcile remain follow-up gates unless implemented in the same PR. + +4. Terminal lifecycle observability. + This PR owns compact anomaly breadcrumbs and bounded lifecycle traces for terminal lifecycle diagnosis. It should make failures easier to debug without claiming to prevent them. It should prove that diagnostics are compact, deduped or bounded, privacy-conscious, and included in the right local crash/diagnostic surfaces. Diagnostics-bundle export and live forbidden-transition artifacts should remain visible follow-ups until proven. + +5. PTY output, IPC, and performance hardening. + This PR owns daemon stream batching, main-to-renderer pending output caps, ACK/backpressure behavior, replay queue bounds, runtime/mobile stream byte budgets, and related perf-sensitive contracts. It needs the hardest review because small behavior changes here can cause input latency, hidden-output, memory, or ordering regressions. It should include explicit runtime budgets and should not promote any perf gate to blocking without soak evidence. + +6. Live local PTY and E2E reliability slice. + This PR owns the first live Electron/local PTY liveness gate. It should stay small: spawn a real terminal, prove one active PTY, type through focused xterm, verify process-visible output, hide/restore the workspace without duplicating or losing the PTY, resize and verify applied size, then exit and verify cleanup. It should remain `experimental` until CI/runtime history proves it is not flaky. Tab switch, app restart, SSH, WSL, Windows ConPTY, and scrollback restore should be explicit follow-ups unless included with equally strong oracles. + +Mapping to the open stack and file assignment: + +The six split items and the five open child PRs overlap; this mapping is authoritative. Every working-tree file must land in exactly one PR below. Four files contain changes from multiple reliability classes and must be split by hunk, with the earlier PR in the merge order owning its hunks first: `config/reliability-gates.jsonc` (per-entry ownership), `src/main/ipc/pty.ts` and its test, `src/renderer/src/components/terminal-pane/pty-connection.ts` and its test, and `src/renderer/src/store/slices/terminals.ts`. + +1. #7001 (split item 1, manifest and plan docs): `config/scripts/check-reliability-gates.mjs`, `config/scripts/check-reliability-gates.test.mjs`, `docs/reference/reliability-gates-implementation-plan.md`, and the manifest restricted to gates whose test files exist on `origin/main` plus commandless gap entries. +2. #7008 (split item 2, provider ownership and replay safety): `src/renderer/src/lib/resume-sleeping-agent-session.ts` and test, plus its ownership slice of `terminals.ts`. Reset the branch from the worktree slice; keep the PR number for review continuity. +3. #7005 (snapshot freshness and targeted renderer liveness): `terminal-dead-session-reconcile.ts` and test, `use-terminal-pane-lifecycle.ts` and test, `use-terminal-pane-global-effects.test.ts`, and the `pty.hasPty` preload/API plumbing (`src/preload/api-types.ts`, `src/preload/index.ts`, `src/renderer/src/web/web-preload-api.ts`, the `hasPty` slice of `src/main/ipc/pty.ts`). +4. New PR (split item 3, startup hydration, provider contracts, no-hot listing): `hydrate-local-pty-registry.ts` and test, `daemon-pty-adapter.ts` and test, `daemon-pty-router.ts` and test, `degraded-daemon-pty-provider.ts` and test, `pty-session-id.test.ts`, `src/shared/pty-session-id-format.ts`, `src/main/providers/ssh-pty-provider.ts`, `ResourceUsageStatusSegment.tsx`, `resource-manager-session-polling.test.ts` (currently untracked), `store-session-cascades.test.ts`, and the SSH/liveness slices of `pty.ts`, `pty-connection.ts`, and `terminals.ts`. +5. New PR (split item 4, lifecycle observability): `terminal-lifecycle-diagnostics.ts` and `terminal-lifecycle-diagnostics.test.ts` (currently untracked). +6. New PR (split item 5, PTY output/IPC/perf hardening): `daemon-stream-data-batcher.ts` and test, `terminal-output-batching.test.ts`, `terminal-subscribe-buffer.test.ts`, and the pending-output-cap and replay/ACK slices of `pty.ts` and `pty-connection.ts`. +7. #7004 (xterm addon containment, outside the six split items): keep as its own PR; reset `pane-lifecycle.ts` and test from the worktree versions, and adopt fresh main's renamed WebGL test files during rebase. +8. #7006 (visible geometry, outside the six split items): keep as its own PR; any geometry hunks in `pty-connection.ts` belong to it. +9. #7007 (persisted upgrade fixtures): the gate is commandless with `protection: "none"`, so its manifest entry rides in #7001 under rule 4. Keep #7007 open only if it will deliver the actual fixture corpus and command; otherwise close it as absorbed. +10. New PR (split item 6, live local PTY E2E slice): `tests/e2e/terminal-live-pty-liveness.spec.ts` (currently untracked). + +Merge order: #7001, then #7008, then #7005, then startup hydration/provider contracts, then lifecycle observability, then PTY output/IPC/perf hardening, then #7004, then #7006, then the live E2E slice. #7007 can merge any time after #7001 or be closed. The stack stays flat on #7001; each child rebases onto the merged state immediately before its own merge, and manifest conflicts are resolved by entry ownership. + +If one later PR reveals a missing invariant in an earlier PR, update the earlier manifest entry or PR description before promotion rather than silently broadening the later PR's claim. The split is part of the reliability plan because reviewability, dependency clarity, and targeted rollback are themselves reliability controls. + +Each split PR should follow Brennan's PR process before it is considered ready: write a scoped design doc, run delegated design review, implement from the reviewed doc, verify completeness against the original request and reliability gate, audit headline behavior, run `perf`, run the code-review/fix loop, run merge-confidence validation, open an unmerged PR, verify pushed state, and stabilize CodeRabbit/check findings. The PR text should describe that process as "Brennan's PR process"; do not mention the internal skill name in PR-facing text. + +## PR Proof Contract + +Every split PR must include a short proof section in its PR description before it can be treated as ready. This section should use direct evidence, not confidence language. Avoid absolute claims such as "this guarantees terminal reliability" or "this cannot regress performance." The valid claim is narrower: "this PR is safe to merge because the affected invariant, performance budget, and residual gaps were checked with the evidence below." + +Required PR description sections: + +- **Reliability invariant:** the class-level rule this PR protects and the recent issue, PR, or accepted gap that motivated it. +- **Material impact:** why this will meaningfully reduce escaped regressions, including which known bug class becomes harder to reintroduce. +- **Product change type:** docs/tooling, test-only gate, product hardening, diagnostics, perf hardening, or mixed. +- **Performance risk inventory:** whether the PR touches typing, focus, tab/workspace switch, visibility resume, resize, render, startup, provider listing, hidden output, PTY output, store subscribers, git/worktree scans, SSH, WSL, Windows, or mobile/relay paths. +- **No-regression evidence:** the exact count test, deterministic contract test, metric artifact, typecheck, or live run that proves the PR did not add unbounded scans, polling, hidden-pane wake loops, startup awaits, subprocess churn, renderer jank, or output buffering growth. +- **Correctness evidence:** the exact oracle and command proving the invariant. Tests that only prove implementation shape are not enough. +- **Provider/platform coverage:** local, daemon, SSH, WSL, remote-runtime, mobile/relay, macOS, Linux, and Windows must be marked covered, unaffected, or accepted-gap. +- **Residual gaps:** what this PR still does not prove. These gaps must remain in the manifest until a later PR removes them. +- **Promotion status:** whether the gate is `none`, `partial`, or `active`, and why it is not promoted beyond its current maturity. +- **Rollback or demotion rule:** what signal should cause the PR's gate to be demoted, disabled, or followed up. + +Performance rule: + +- A reliability PR is incomplete if it adds polling, global session listing, provider fanout, hidden-pane renderer wakeups, startup-critical awaits, repeated resize IPC, subprocess churn, unbounded output queues, broad store subscriptions, or uncapped diagnostics without a deterministic count test or metric budget. +- `pty:listSessions()` remains a global management/diagnostic operation, not a hot-path liveness primitive. Typing, focus, tab/workspace switch, visibility resume, resize, render, and per-pane liveness paths should use targeted APIs or cached ownership unless a PR provides measured proof that the broader call is safe. +- Any PR touching git/worktree scans, SSH git providers, WSL paths, cleanup, or startup repository enumeration must include the `git-crash-perf` style proof: bounded subprocesses, timeout/abort behavior, provider parity, and no startup-critical scan. +- Any PR touching terminal/session/provider/startup code must use the terminal-session reliability contract plus a performance budget. A green reliability test without a perf proof is not enough. +- Any PR that changes PTY output, IPC batching, ACK/backpressure, replay, or hidden-output behavior (split item 5 especially) must include a before/after run of an existing terminal perf artifact — `test:e2e:terminal-perf:scale:report` or the typing-latency specs — on the same machine, with both results in the PR description. Deterministic byte/count contracts prove bounds; only the perf artifact proves interactive latency did not regress. +- The gates themselves are a performance surface. Every command-backed gate must declare a runtime budget in the manifest, unit-layer gate commands should stay in single-digit seconds, and the aggregated CI job in Evidence Provenance And CI Wiring must stay inside its own wall-clock budget so reliability coverage does not degrade the development loop it protects. + +Per-PR proof expectations: + +1. Reliability gate manifest and plan docs. + Performance proof: no runtime product path changes. Checker runtime should stay cheap and deterministic, and manifest validation should not depend on network calls or live providers. + Materiality proof: the checker prevents overclaiming by requiring evidence metadata, existing test files, command-backed assertions, covered scope, and clear `none`/`partial`/`active` status. + +2. Provider ownership and session replay safety. + Performance proof: activation and replay checks must be bounded by indexed workspace-local state, not repeated provider listing or O(records x pending-startups) scans. Tests should include repeat activation and bounded-work assertions where scans are introduced. + Materiality proof: this blocks the class where an inactive or hidden but valid provider session is mistaken for unowned and resumed again. + +3. Startup hydration, provider fanout, and targeted liveness. + Performance proof: startup hydration must not block first usable UI, must expose bounded counters, and must not multiply provider/git scans across repos and worktrees without a budget. Hot interaction paths must prove zero broad `pty:listSessions()` calls. + Materiality proof: this prevents stale global snapshots or failed provider observations from closing or replacing the wrong terminal, while reducing session-listing fanout. + +4. Terminal lifecycle observability. + Performance proof: breadcrumbs and traces must be capped, primitive-only, deduped or ring-buffered, and must not serialize terminal output, filesystem trees, scrollback, or large state. Tests must prove caps and sanitization. + Materiality proof: this does not itself prevent regressions, but it materially shortens future diagnosis by preserving the lifecycle facts needed to debug ownership, liveness, restore, resize, and replay failures. + +5. PTY output, IPC, and performance hardening. + Performance proof: this PR must carry the strongest perf evidence in the stack: byte caps, queue caps, ACK/backpressure ordering, active-output priority, hidden-output behavior, and no unbounded renderer/main buffering. It should not become blocking until soak data shows stable runtime and no unexplained flakes. + Materiality proof: this protects the freeze/input-lag/memory-growth class where background or high-volume terminal output harms the active terminal or the app. + +6. Live local PTY and E2E reliability slice. + Performance proof: the Playwright gate must be small, serial where needed, artifact-producing, and timed. It should prove the user path without becoming a broad slow/flaky suite. Runtime and flake history are required before promotion. + Materiality proof: this covers the layer that fake providers cannot prove: real Electron terminal spawn, keyboard input, process-visible output, hide/restore, resize, and cleanup. + +## Factory And Skill Follow-Up + +The current factory skills are close enough that the next step should be targeted edits, not a large new skill stack. The terminal-specific skill already exists, and Brennan's PR process already requires design review, perf review, code review, validation, pushed-state checks, and PR stabilization. The missing factory behavior is that reviewers and validators should explicitly fail PRs that omit the PR Proof Contract above. + +Recommended skill/factory updates: + +1. Update `terminal-session-reliability`. + Add the PR Proof Contract as a required output for terminal/session/provider/startup PRs. The skill should require reliability invariant, material impact, performance risk inventory, no-regression evidence, provider/platform coverage, residual gaps, promotion status, and rollback/demotion rule. + +2. Update `review-code`. + Make missing proof-contract sections a review finding for P0-capable terminal/session/provider/startup/release surfaces. Reviewers should not accept "tests pass" as enough when the PR lacks a named invariant, perf budget, provider/platform matrix, or accepted-gap statement. + +3. Update `brennan-test-changes`. + Require validation reports to say whether the relevant reliability gate command ran, whether the evidence proves the user-visible invariant, whether the test layer is sufficient, and whether any skipped SSH, WSL, Windows, remote-runtime, mobile, or live-Electron path is an accepted gap. + +4. Update `playwright-reliability-tests`. + Add the live-terminal reliability bar directly: Playwright terminal tests must prove real input/output or visible degraded state, use deterministic event oracles instead of blind sleeps, record runtime/artifacts, and stay `experimental` until flake history supports promotion. + +5. Update `perf`. + Add the PR Proof Contract's no-regression evidence language so perf reviewers require deterministic count tests or metric artifacts for polling, global session listing, provider fanout, hidden-pane wakeups, startup awaits, subprocess churn, output queue growth, and broad store subscriptions. + +6. After the repo-side gate commands stabilize, update factory prompts to invoke exact gate ids and commands. + Do this after the PRs land or after their commands are stable enough to reference. Before then, the factory should require the gate class and evidence, but should not hard-code commands that may still split or rename during PR extraction. + +Do not add a separate broad "terminal reliability reviewer" skill unless the existing skills fail to enforce this contract after these edits. Too many overlapping skills can make agents diffuse responsibility; the better shape is one terminal reliability contract plus required calls from design, review, perf, Playwright, and validation stages. + +## Coverage Reality Check + +This first stack is useful, but it must not be described as "terminal reliability is covered." The executable protection in this branch covers a deliberately small first slice of recent escaped classes: + +- stale local/daemon liveness snapshots closing newer PTY bindings; +- provider-session replay ownership for several active, inactive, queued, pending, live, retained, same-session hook, and wrong-session hook records, with real hook timing through Electron still explicitly partial; +- targeted `hasPty` liveness after visibility resume instead of hot-path global `listSessions`; +- degraded daemon fallback fail-closed behavior for restored worktree-scoped and legacy/non-scoped ids; +- SSH provider listing/`hasPty` failures treated as unknown rather than destructive ownership evidence, plus mocked provider/renderer coverage for deferred SSH attach, passphrase cancellation, transient deferred reattach failure, and expired deferred relay fallback; +- remote replay coalescing, FIFO ordering, and bounded burst-tail coalescing at the renderer unit layer; +- visible geometry contract behavior for delayed split layout settle, 0x0 split-right recovery, applied-vs-requested `pty:getSize`, and resume-time size reassertion; +- boot-time local PTY registry hydration counters for repo/worktree enumeration, skipped SSH repos, adapter/session listing, registration/skips, duration, and failure phase; +- terminal lifecycle anomaly breadcrumbs recorded into renderer crash diagnostics with dedupe and compact identity fields; +- live local Electron PTY spawn, active PTY listed exactly once, focused xterm keyboard input, process-visible output, repeated workspace hide/restore of the same PTY, actual resize propagation, and exit cleanup. + +Those gates should catch re-regressions in those classes, and the tests are intentionally deterministic. They mostly use renderer-unit, renderer-state, provider-contract, or fixture layers because those layers provide precise oracles with low flake risk. That is a strength for the classes they cover. + +The manifest also contains commandless registered gaps for persisted-session upgrade fixtures, Windows ConPTY, startup color-query handling, and other future gates. Those entries are useful planning structure, not executable protection. Visible geometry convergence, xterm addon containment, IME/native text forwarding, runtime/mobile streams, and startup hydration counters now have first executable contract slices, but they remain `experimental` until they have repeatable CI/runtime history and red-green evidence. Output backpressure now has daemon stream and main pending-output contract slices, but the broader live perf budget remains experimental until it has repeatable metric artifacts, runtime history, and red-green evidence. The live local PTY gate now has a first executable Playwright slice that includes repeated workspace hide/restore of the same PTY, but it remains `experimental` until it has repeatable CI/runtime history and red-green evidence. + +The critique that this stack is still shallow is fair. The current gates do not yet prove: + +- tab-switch restore, scrollback after restore, app restart persistence, and non-local live providers; +- Windows ConPTY behavior, including shell resolution, cursor restore, rewrite repaint, CJK/IME edge cases, kitty/input protocol behavior, and activation hangs; +- terminal input latency, throughput, hidden-output pressure, renderer CPU, and store/subscriber hot paths under load; +- IME and synthetic input forwarding across macOS, Windows, Linux/fcitx/Sogou, Vietnamese, CJK, Arabic, paste, and modifier paths; +- focus recovery after browser/terminal handoffs, tab switches, app restart, and non-local provider restore; +- daemon degraded spawns, stale bootstrap, remote-runtime startup, SSH/WSL provider boundaries, and mirror polling; +- scrollback clearing/restoration correctness across hidden output, snapshot replay, and restore. + +Those missing classes already appear in the full pain-points report, but this implementation plan should keep them visible so the first stack does not become false confidence. + +## Fresh-Eyes Corrections + +The follow-up reviews changed the plan in several concrete ways: + +- Windows/IME/CJK are not covered by the current executable stack. Orca has useful unit coverage and product logic for local Windows ConPTY compatibility, including withholding Kitty keyboard protocol for local native Windows panes, but no live Windows ConPTY gate proves post-agent ordinary keys, shell parity, CJK repaint, cursor restore, resize readback, or real IME composition. +- SSH restore is better covered than the first manifest draft said, but only at store/provider/main/renderer contract layers. The current gate asserts deferred wake metadata, deferred attach/passphrase/expired-relay contracts, and unknown-liveness behavior with fake transports; it does not prove a live SSH relay, WSL, remote-runtime mirror polling, false-live visual state, or real network reconnect timing. +- Perf/backpressure is currently deterministic byte-accounting proof, not live responsiveness proof. The branch asserts daemon `write(false)`/`drain` behavior, cross-session drain priority for flush-immediate output, pure-backpressure cleanup, and main pending-output caps; it does not yet prove renderer parse pressure, event-loop delay, active key latency, or live hidden-output flood behavior. +- Daemon startup reconcile remains a mechanism, not a production-proven startup behavior. Either wire it with dry-run diagnostics and prior-worktree aliases, or keep it as an accepted gap with a named owner. +- Gate evidence is still too easy to hand-enter. The checker now prevents obvious metadata overclaims, but it does not prove the registered command was run in CI, preserve artifacts, or verify that each declared assertion executed. Add machine-checkable artifact/CI-run provenance before promoting gates to blocking. +- Manifest evidence in this worktree is not yet durable. Three referenced test files are untracked (`resource-manager-session-polling.test.ts`, `terminal-lifecycle-diagnostics.test.ts`, `tests/e2e/terminal-live-pty-liveness.spec.ts`) and roughly 39 more files carry uncommitted modifications. Until each slice is committed and pushed to its owning split branch, the manifest references proof that CI cannot run and that no one can reproduce from a ref. +- Fresh `origin/main` renamed `terminal-webgl-paste-recovery.{ts,test.ts}` to `terminal-webgl-atlas-recovery.{ts,test.ts}` and added `pane-webgl-context-recovery.test.ts` and `pane-webgl-renderer.test.ts` in #6949. The `xterm-addon.boundary-containment` gate and its documented command reference the old filename and will fail the checker after rebase; the gate should adopt the renamed and new tests during the rebase. #7133 (merged 2026-07-02) further modified `pane-webgl-renderer.{ts,test.ts}` and added `pane-reveal-repaint.{ts,test.ts}` and `terminal-visibility-resume.{ts,test.ts}`; the containment gate should evaluate those reveal-hardening tests for adoption in the same sweep. This is a confirmed instance of the stale-branch risk, and it shows the fresh-main review must include a mechanical testFiles-vs-main sweep, not only behavioral spot checks. +- This document itself drifted from the manifest it describes (a prior revision said 23 gates, 13 partial/10 none, while the manifest held 24 gates, 15 partial/9 none). Treat the manifest as authoritative and this document's counts as dated snapshots. +- The Resource Manager status segment no longer polls broad `pty:listSessions()` for its closed-popover badge in this branch. Broad session inventory is now scoped to the open Resource Manager and explicit kill/restart refresh paths; the no-hot-listing plan still needs status/diagnostic budgets and broader Electron counters before promotion. +- Boot-time local PTY registry hydration now exposes a debug snapshot and deterministic tests for retry state, SSH skip-before-worktree-enumeration, local registration counters, adapter/session listing counts, recoverable adapter-list failures, fatal hydration failures, router adapter fanout, duration, and large daemon lists. This is still only a startup-hydration counter slice; it does not prove focus, typing, tab switch, workspace switch, render, high-session provider fanout, or real Electron startup budgets. +- Terminal lifecycle anomaly diagnostics now record compact crash breadcrumbs in addition to console warnings. This is useful support evidence, not yet the full pane lifecycle trace buffer requested in the pain-points report. + +## Fresh Main Architecture Findings + +These findings came from focused reviews of current terminal lifecycle, renderer/input/perf, and provider/platform/startup code against freshly fetched `origin/main`. They are not all regressions introduced by the reliability-gate branch. They are the places the plan must cover before claiming broad terminal reliability. + +Current coverage already exists in more places than the first gate stack showed: + +- `terminal-dead-session-reconcile.test.ts`, `resume-sleeping-agent-session.test.ts`, and `pty-connection.test.ts` have substantial deterministic lifecycle coverage. A focused run of those files passed 292 tests. +- Existing E2E/perf tests include terminal typing latency, hidden TUI visual restore, long-table scroll restore, daemon slow-init PTY restore, SSH localhost, and terminal tab-switch SIGWINCH restore. +- Existing scripts include terminal perf report commands such as `test:e2e:terminal-perf`, `test:e2e:terminal-perf:scale`, `test:e2e:terminal-perf:scale:report`, and `test:e2e:terminal-perf:check-report`. + +The problem is that these tests are not yet organized into reliability gates with named invariants, owners, maturity, runtime budgets, flake history, red/green evidence, and promotion rules. Some also need review before promotion because a useful regression test can still be too broad, too slow, or too flaky to be a blocking gate. + +Local validation evidence from this review pass: + +```sh +pnpm exec vitest run --config config/vitest.config.ts \ + src/renderer/src/components/terminal-pane/terminal-dead-session-reconcile.test.ts \ + src/renderer/src/lib/resume-sleeping-agent-session.test.ts \ + src/renderer/src/components/terminal-pane/pty-connection.test.ts +``` + +Result on 2026-07-01: 3 test files passed, 292 tests passed, duration 6.04s. This asserts the current deterministic lifecycle/unit layer is healthy in this worktree; it does not prove the broader live/platform/provider/perf gaps. + +The provider-session ownership gate now includes the queued/pending resume bridge from fresh `origin/main`: automatic resumes thread `resumeProviderSession` through `pendingStartupByTabId`, claim `automaticAgentResumeClaimsByTabId`, and treat pending startup, time-bounded runtime claims, and live agent statuses as provider-session ownership. The queued/pending claim scan is indexed once per activation so replay protection does not rescan every pending startup for every sleeping record. It also asserts live hook evidence claims only the matching provider session; a wrong-session hook cannot claim a replayed sleeping record; and stale automatic bridge claims cannot suppress recovery forever. + +```sh +pnpm exec vitest run --config config/vitest.config.ts \ + src/renderer/src/lib/resume-sleeping-agent-session.test.ts +``` + +Result on 2026-07-01: 1 test file passed, 34 tests passed, duration 7.86s. This focused command asserts repeat activation or replayed sleeping records do not launch a second resume for the same provider session while the first resume is still pending, queued pending-startup provider-session ids are read once per activation rather than once per replayed record, a live hook for the same provider session owns the replay, and a wrong-session hook does not. It does not yet prove real hook timing through Electron or a full Electron workspace activation loop. + +Structural manifest validation also passed locally: + +```sh +pnpm run check:reliability-gates +``` + +Result on 2026-07-01 after the first expansion: reliability gate manifest check passed for 22 gates. This validated the manifest schema/metadata shape only; it did not prove that the registered commands covered the full terminal runtime. + +After the broader architecture review and fresh-agent review, the manifest was expanded further, including a dedicated WSL restore contract instead of burying WSL under SSH/remote coverage, and later the three convergence/escape-detection gaps registered after the #7133/#7148 saga. As of 2026-07-03, main's manifest holds 27 gates split 9 `partial` (gates whose evidence exists on main itself, including the #7133/#7148/#7173/#7192 regression tests) and 18 `none`; the pending stack raises more gates to `partial` as its slices land. These counts are dated snapshots, and the manifest itself is authoritative. The new entries are intentionally `experimental`; a few now have first executable slices, while others are still commandless gap markers. They are not active protection until each gate gets an implemented command, red/green proof, runtime evidence, and promotion history. + +The manifest checker now enforces the review lessons that were easy to miss manually: + +- declared `testFiles` must exist; +- every declared executable `testFiles` entry must be referenced by at least one gate command, so a manifest cannot borrow credibility from a file the registered command does not run; +- command-backed gates cannot use title selectors such as `-t`, `--grep`, or `--testNamePattern`, because title drift silently changes coverage; +- every gate must declare `protection: none | partial | active`; +- commandless gates must declare `protection: "none"`, while command-backed gates must declare at least `protection: "partial"`; +- `protection: "active"` is reserved for blocking gates with stable flake history and complete red/green evidence; +- `partial` and `active` gates must include at least one passed structured `evidenceRuns` entry whose command exactly matches the gate command; +- `none` gates must have no evidence runs, so planning gaps cannot look like tested coverage; +- `partial` and `active` gates must include `assertionRefs` that point to declared test files and name the invariant assertions inside those files; +- `none` gates must have no assertion refs, so planning gaps cannot borrow credibility from unrelated tests; +- every gate must split risk scope from covered scope with `coveredPlatforms`, `coveredProviders`, and `coverageNotes`; +- `coveredPlatforms` and `coveredProviders` must be inside the declared risk scope, and passed evidence-run platforms must appear in `coveredPlatforms`; +- `soak` and `blocking` gates must have soaking/stable flake evidence and complete red/green evidence. + +How to read the manifest: + +- `platforms` and `providers` describe the affected risk scope for an invariant. They do not mean the current command exercises every listed platform/provider. +- `coveredPlatforms`, `coveredProviders`, and `coverageNotes` describe the scope actually covered by current evidence. Empty `coveredProviders` on a partial gate usually means renderer/state/RPC contract coverage, not a real provider run. +- `protection: "none"` means a registered gap only; `protection: "partial"` means useful executable coverage that is not blocking protection yet; `protection: "active"` means the gate is blocking and has stable evidence. +- `evidenceRuns` are factual run records: date, runner, platform, exact command, result, duration, and summary. A local passed run supports `partial` coverage; it does not imply CI stability or flake history. +- `assertionRefs` explain which assertion groups in the listed test files carry the gate. This is especially important for shared files like `pty-connection.test.ts`, where the whole file passing is not the same as the whole file being relevant. +- `oracle`, `redGreenEvidence`, and `knownGaps` are the authoritative source for what is actually proved today. +- Commandless `experimental` gates are registered gaps, not active protection. + +New or expanded manifest entries: + +- `terminal-platform.live-pty-liveness` +- `terminal-platform.windows-conpty-liveness` +- `terminal-performance.no-hot-list-sessions` +- `terminal-performance.output-backpressure-budget` +- `terminal-performance.input-throughput` +- `terminal-performance.daemon-stream-backpressure` +- `terminal-performance.store-and-git-hot-paths` +- `terminal-provider.daemon-startup-degraded-contract` +- `terminal-provider.ssh-remote-reattach-contract` +- `xterm-addon.boundary-containment` +- `terminal-output.scrollback-replay-fifo` +- `terminal-output.scrollback-restore` +- `terminal-input.ime-and-synthetic-forwarding` +- `terminal-input.windows-conpty-keyboard-reset` +- `terminal-render.windows-cjk-repaint` +- `terminal-shell.windows-resolution-parity` +- `terminal-capability.startup-color-query` +- `terminal-runtime.mobile-stream-budget` +- `terminal-mirror.parser-parity` +- `terminal-observability.restore-convergence-selfcheck` +- `terminal-render.pixel-refresh-repair` + +The visible geometry gate now has its first deterministic renderer/main contract slice: + +```sh +pnpm exec vitest run --config config/vitest.config.ts \ + src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts \ + src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts \ + src/renderer/src/components/terminal-pane/pty-connection.test.ts \ + src/main/ipc/pty.test.ts +``` + +Result on 2026-07-02: 4 test files passed, 481 tests passed, duration 5.98s. This focused command asserts delayed hidden split-layout settles are forwarded instead of stopping on a fixed frame budget, unmeasurable frames do not count as settled, visible 0x0 split-right panes get a nonzero recovery size while hidden background 0x0 panes are not forced to desktop size, `pty:getSize` reports applied size when a provider can expose it, and visibility resume reasserts only real PTY/xterm drift without falling back to `listSessions`. It does not yet prove shell-visible `stty`, SSH/remote geometry, Windows ConPTY geometry/readback, or full Electron geometry under real split/tab/app-restart workflows. + +The live local PTY gate now has its first executable Electron slice: + +```sh +pnpm run ensure:electron-runtime && npx playwright test \ + tests/e2e/terminal-live-pty-liveness.spec.ts \ + --config tests/playwright.config.ts --project electron-headless --workers=1 +``` + +Result on 2026-07-02 after adding two workspace hide/restore cycles, exact active-PTY listing, and actual resize convergence: 1 Playwright test passed, test body 8.6s, full command 58.6s including build/setup. This one local macOS run asserts a real Electron local terminal binds a PTY, lists the active PTY id exactly once, accepts keyboard input through focused xterm, renders process output markers, preserves the same PTY id through repeated worktree switch-away/switch-back cycles, accepts additional keyboard input after each restore, applies an actual `pty:getSize` change after viewport resize, reports the same process-visible size, exits the probe, exits the shell, and removes the PTY id from `pty:listSessions`. It does not yet prove tab switch within the same worktree, scrollback after restore, app restart persistence, daemon, SSH, WSL, remote-runtime, Linux CI stability, or Windows ConPTY behavior. + +The IME/native-text gate now has its first deterministic renderer-unit slice: + +```sh +pnpm exec vitest run --config config/vitest.config.ts \ + src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts \ + src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts \ + src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts +``` + +Result on 2026-07-02: 3 test files passed, 63 tests passed, duration 887ms. This registers existing native-text, input-source, and paste/runtime forwarding contracts as a reliability gate slice. It does not prove real OS IME automation, Windows ConPTY post-agent keyboard reset, Arabic/RTL, JIS-yen, or the full CJK/Vietnamese/platform matrix. + +The xterm addon containment gate now has its first deterministic renderer-unit slice and product hardening for core addon load failures: + +```sh +pnpm exec vitest run --config config/vitest.config.ts \ + src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts \ + src/renderer/src/lib/pane-manager/terminal-link-provider-guard.test.ts \ + src/renderer/src/components/terminal-search-safe-find.test.ts \ + src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts \ + src/renderer/src/components/terminal-pane/terminal-webgl-paste-recovery.test.ts +``` + +Result on 2026-07-02: 5 test files passed, 39 tests passed, duration 370ms. This focused command asserts a core addon `loadAddon` throw is pane-scoped and later addons still load, link provider throws degrade to no-link results, search decoration positive-integer crashes return `false` instead of escaping, and WebGL attach/refresh/recovery failures stay contained. It does not yet prove typed PTY input/output survives a live addon failure in Electron, nor WebGL dispose/reset throws across active, hidden, and resumed panes. + +The runtime/mobile stream budget gate now has its first deterministic runtime-RPC slice: + +```sh +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 +``` + +Result on 2026-07-02: 3 test files passed, 29 tests passed, duration 2.78s. The covered contracts assert mobile initial snapshots downgrade until they fit the 512KB budget, requested binary snapshots downgrade until they fit the 2MB budget, binary live output queued while the initial snapshot is serializing stays within 256KB while preserving the newest tail, large binary output is split into 48KB-or-smaller frames, output bursts are coalesced before emission, aborted subscribes do not register stale listeners, and stale mobile resize re-stream completions are dropped. It does not yet decide JSON subscribe fallback parity/deprecation. + +The replay/scrollback gate now has its first executable slice: + +```sh +pnpm exec vitest run --config config/vitest.config.ts \ + src/renderer/src/components/terminal-pane/pty-connection.test.ts +``` + +Result on 2026-07-02: 1 test file passed as part of the focused pty-connection validation, with 255 tests in that file. The relevant replay assertions cover four replay invariants: remote replay payloads that overlap before parsing starts intentionally coalesce to the latest payload, a replay whose xterm parsing has already started is not overwritten by a newer replay, multiple replay notifications accepted after parsing starts drain FIFO, and burst tails are bounded while keeping the newest snapshot. It does not yet prove fresh-main `clearBeforeReplay` metadata, metadata-only replay, or hidden-output/live-output interleaving. + +The no-hot-`listSessions` gate now has its first executable slice and product hardening. The renderer/preload/main path exposes `pty.hasPty(id)` and uses targeted liveness on visibility resume and first input after resume, instead of calling global `pty:listSessions()` from that hot path. The resize-on-resume slice also asserts it uses targeted `getSize` plus `resize` rather than broad session listing. Light tab switches and visible active-state resume now assert they do not call `pty:listSessions`, `pty.hasPty`, or `pty.getSize`; a representative active-PTY case proves the scheduler hint `setActiveRendererPty` is the only allowed PTY API call there. Resource Manager broad session inventory is now scoped to the open popover instead of polling for its closed badge. + +```sh +pnpm exec vitest run --config config/vitest.config.ts \ + src/renderer/src/components/terminal-pane/terminal-dead-session-reconcile.test.ts \ + src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts \ + src/renderer/src/components/terminal-pane/pty-connection.test.ts \ + src/renderer/src/components/status-bar/resource-manager-session-polling.test.ts \ + src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts +``` + +Result on 2026-07-02: 5 test files passed, 321 tests passed, duration 6.34s. The focused no-hot command asserts visibility resume prefers targeted `hasPty` even when `listSessions` is available, first input after visibility resume calls targeted `hasPty` once, resize re-assertion after visibility resume uses `getSize`/`resize` without `listSessions`, light tab switches and visible active-state resume avoid `listSessions`/`hasPty`/`getSize` fanout while still allowing the active PTY scheduler hint, SSH/remote broad listing is skipped, Resource Manager broad session inventory polling is scoped to the open popover, panes close only on authoritative `false`, and unknown liveness keeps panes alive. It does not yet count raw focus, workspace switch, render, high-session typing, or real provider fanout in Electron. + +The store/git hot-path gate now has its first boot-hydration counter slice. This is not broad interactive perf coverage; it is the narrow startup-adjacent part that previously had no measurable budget. + +```sh +pnpm exec vitest run --config config/vitest.config.ts \ + src/main/memory/hydrate-local-pty-registry.test.ts +``` + +Result on 2026-07-02: 1 test file passed, 8 tests passed, duration 0.342s. The focused command asserts provider-unavailable hydration does not scan repos/worktrees/providers and remains retryable, SSH repos are skipped before worktree enumeration, local daemon sessions are registered with debug counters for repo/worktree/adapter/session/register/skip/duration fields, recoverable adapter-list failures are counted without failing the whole hydration pass, fatal hydration failures record failed phase and error message, router-backed hydration records multi-adapter fanout counts, pre-existing pid rows are not clobbered, and large daemon session lists hydrate without spreading into argument-limit failures. It does not yet count store recomputes, git status requests, provider listings, focus, typing, tab switch, workspace switch, render, or high-session Electron fanout. + +The degraded-daemon provider contract now has its first executable slice and product hardening. Existing-session operations fail closed when ownership is unknown, and restored daemon session ids, including legacy/non-worktree-scoped ids, cannot silently spawn a local fallback PTY under the old id. Fresh degraded-mode PTY creation still intentionally routes through fallback when the caller marks it as new, and process inspection returns benign defaults for unknown ownership instead of leaking fail-closed routing errors into window-close flows. + +```sh +pnpm exec vitest run --config config/vitest.config.ts \ + src/main/daemon/degraded-daemon-pty-provider.test.ts \ + src/main/daemon/daemon-pty-adapter.test.ts \ + src/main/daemon/daemon-pty-router.test.ts +``` + +Result on 2026-07-02: 3 test files passed, 107 tests passed, duration 2.54s. Focused provider validation asserts discovered daemon sessions route to the daemon, fresh degraded-mode PTYs route to fallback only when marked new, router spawn discovers uncached existing sessions before choosing an adapter, router spawn fails closed instead of falling through to current when legacy ownership cannot be listed or a known session has exited, targeted `hasPty` discovery caches legacy ownership before later operations, real daemon adapter `listProcesses()` discovery seeds targeted `hasPty` liveness, folder and floating terminal workspace ids survive startup reconcile when valid, restored worktree-scoped and legacy/non-scoped ids do not fall back through spawn after ownership is lost or unknown, unknown or exited existing ids fail closed for routing operations, startup reconcile can dry-run orphan detection without killing live daemon sessions, and process inspection returns benign defaults for unknown ownership. It does not yet prove production startup reconcile wiring, prior-worktree aliases, or real daemon process restart behavior. + +The SSH/remote provider contract now has its first executable provider/main/renderer slice. Provider observation failures are treated as unknown instead of destructive evidence: a failed SSH `listProcesses()` call does not clear previously learned ownership, and a rejected SSH `hasPty()` probe returns unknown liveness (`null`) rather than dead. The focused renderer/provider tests also assert that saved SSH sessions reattach through relay `pty.attach`, expired relay attach does not silently fresh-spawn inside the provider, deferred passphrase cancellation does not auto-reconnect, saved leaf/tab session ids are used after connection, transient deferred reattach failure preserves the saved session id without clearing pane/tab bindings or fresh-spawning, deferred SSH no-result cleanup clears the pending serializer without consuming the saved restore id, and expired deferred relay state clears stale pane/tab bindings before one fresh replacement spawn. + +```sh +pnpm exec vitest run --config config/vitest.config.ts \ + src/main/providers/ssh-pty-provider.test.ts \ + src/main/ipc/pty.test.ts \ + src/renderer/src/components/terminal-pane/pty-connection.test.ts \ + src/renderer/src/store/slices/store-session-cascades.test.ts +``` + +Result on 2026-07-02: 4 test files passed, 546 tests passed, duration 6.48s. Focused validation asserts store wake-hint metadata for disconnected SSH relay ids, provider attach/expired-attach behavior, main-process ownership and targeted-liveness failure semantics including async provider rejection, mocked-renderer deferred SSH attach/transient-failure/expired-relay fallback, and deferred SSH no-result cleanup that clears the pending serializer without consuming the saved restore id. It does not yet prove WSL, remote-runtime mirror polling, a live SSH relay, pending-vs-attached UI status, or real network reconnect timing. + +API-surface validation after adding `pty.hasPty(id)`: + +```sh +pnpm run typecheck:node +pnpm run typecheck:web +``` + +Result on 2026-07-02: both typechecks passed. + +The output-backpressure budget gate now has a deterministic provider/IPC/renderer slice: + +```sh +pnpm exec vitest run --config config/vitest.config.ts \ + src/main/daemon/daemon-stream-data-batcher.test.ts \ + src/main/ipc/pty.test.ts \ + src/renderer/src/components/terminal-pane/pty-connection.test.ts +``` + +Result on 2026-07-02: 3 test files passed, 469 tests passed, duration 9.35s. The focused command asserts daemon socket `write(false)`/`drain` ordering, cross-session interactive output priority, pure-backpressure cleanup, bounded daemon queued bytes, main pending renderer caps, active pending-output protection, replay/backlog slices, and ACK-gated in-flight bounds. It does not yet prove live hidden-output floods, renderer parse pressure, event-loop delay, active key latency, or full Electron perf artifacts. + +Combined focused terminal/provider validation: + +```sh +pnpm exec vitest run --config config/vitest.config.ts \ + src/main/daemon/degraded-daemon-pty-provider.test.ts \ + src/main/ipc/pty.test.ts \ + src/renderer/src/components/terminal-pane/terminal-dead-session-reconcile.test.ts \ + src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts \ + src/renderer/src/components/terminal-pane/pty-connection.test.ts \ + src/renderer/src/lib/resume-sleeping-agent-session.test.ts +``` + +Result on 2026-07-01: 8 test files passed, 625 tests passed, duration 6.55s. This was focused terminal/provider validation, not structural manifest validation. + +Reliability manifest checker validation is separate: + +```sh +pnpm run check:reliability-gates +``` + +Result on 2026-07-02: reliability gate manifest check passed for 27 gates. This validates the manifest shape and referenced files; it does not prove the registered commands cover the full terminal runtime. + +The highest-risk architecture boundaries are: + +- Terminal/session ownership is spread across tab PTY ids, leaf PTY ids, last-known relay ids, pending reconnect maps, deferred SSH maps, sleeping records, and provider ownership. The plan needs a derived ownership snapshot oracle for tests/logging so attach, reattach, exit, clear, hibernate, and restore can prove all authorities agree. +- Queued or pending provider sessions must count as owned. This branch now restores the pending startup and automatic resume claim bridge and covers same-session/wrong-session hook evidence, but real hook timing and repeat activation through Electron still need coverage. This is the class-level invariant motivated by #6800. +- Stale branch risk is real. The reliability branch previously lacked some protections present on fresh `origin/main`, including queued automatic agent resume claims and exact `hasPty` missing-session reconciliation. This branch has restored those two deterministic slices; the full stack still needs final rebase/retarget verification before landing. +- Main PTY ownership historically had convenient fallback behavior for unknown ownership. This branch now hardens daemon-router and degraded-provider paths so discovered legacy ownership is cached and unknown existing-session operations fail closed, but SSH/remote/diagnostic listing paths still need live coverage. +- Startup daemon reconciliation is present as a mechanism but not wired as production startup behavior. Without it, the daemon can still have a session while the adapter no longer knows it owns that session. +- Degraded daemon routing on fresh `origin/main` can fall back to local for unknown existing-looking sessions. This branch now hardens operation plus restored worktree-scoped and legacy/non-scoped spawn paths to fail closed and registers a focused provider contract, but startup reconcile, prior-worktree aliases, and real daemon restart behavior still need coverage. +- `pty:listSessions` both observes and mutates provider ownership. This branch now asserts failed SSH observation does not clear previously learned ownership and mocked deferred SSH attach/transient-failure/expired-relay fallback behaves correctly, but WSL, remote-runtime mirror polling, live SSH reconnect timing, and false-live tab behavior remain unproved. +- Resource Manager broad session inventory is now limited to popover-open and explicit management refreshes. It still needs a status/diagnostic budget proving open-popover polling is bounded with many providers and does not become a hot-path substitute. +- Boot hydration (`hydrateLocalPtyRegistryAtBoot`) is fire-and-forget, and this branch now records startup phase counters for repo/worktree enumeration, skipped remote repos, adapter/session listing, registrations/skips, duration, and failures. It can still create startup-adjacent git subprocess and daemon-listing churn across many local repos, so do not treat startup reliability as covered until those counters are budgeted in CI/runtime evidence. +- Deferred SSH restore intentionally marks a saved PTY id before actual attach. This branch now covers the mocked renderer contract for passphrase cancellation, attach after connection, transient attach failure preserving retry state, and expired relay fallback; remaining work is false-live visual state, live SSH relay timing, and duplicate-spawn prevention under real reconnect churn. +- Geometry truth is incomplete across SSH/remote paths. Requested size, renderer size, provider-applied size, shell-visible size, and the runtime mirror's parse dimensions must be separated instead of treated as interchangeable. #7192 proved the mirror can silently stay at birth dimensions while every other authority resizes, corrupting every later restore snapshot, and that mirror reflow must stay ordered with queued output writes. +- Production diagnostics are still incomplete. This branch now records compact crash breadcrumbs for explicit terminal lifecycle anomalies, but real user reports still need a bounded pane lifecycle trace for ordinary transitions and a diagnostics-bundle artifact proving those breadcrumbs/traces are included. + +The lifecycle breadcrumb gate now has its first executable slice: + +```sh +pnpm exec vitest run --config config/vitest.config.ts \ + src/renderer/src/components/terminal-pane/terminal-lifecycle-diagnostics.test.ts +``` + +Result on 2026-07-02: 1 test file passed, 2 tests passed, duration 0.593s. The focused command asserts `warnTerminalLifecycleAnomaly` preserves its console warning, records a compact `terminal_lifecycle_anomaly` renderer crash breadcrumb with terminal identity/provider/PTY/reason fields, and dedupes repeated anomalies by lifecycle identity. It does not yet prove a full pane lifecycle trace buffer, diagnostics-bundle export, or forbidden-transition assertions across live Electron/provider flows. + +Specific current-source evidence checked in this pass: + +- Fresh `origin/main` has queued provider-session protections in `src/renderer/src/lib/resume-sleeping-agent-session.ts`: `resumeProviderSession`, `claimAutomaticAgentResume`, and `automaticAgentResumeClaimsByTabId`. +- Fresh `origin/main` has exact liveness reconciliation in `src/renderer/src/components/terminal-pane/terminal-dead-session-reconcile.ts` and the `pty:hasPty` IPC handler in `src/main/ipc/pty.ts`. +- Fresh `origin/main` still uses a single `pendingReplayData` slot in `src/renderer/src/components/terminal-pane/pty-connection.ts` (re-verified after #7133 merged; #7133 changed the same file's `applyMainBufferSnapshot` alt-screen restore path, not replay draining). This branch changes replay draining to coalesce snapshots before xterm parsing starts, queue accepted snapshots FIFO once parsing is in progress, and coalesce burst tails to the newest snapshot once the in-flight queue is full. The broader scrollback gate still needs metadata-only replay, `clearBeforeReplay`, and hidden/live-output interleaving coverage. +- Fresh `origin/main` still has `DaemonPtyAdapter.reconcileOnStartup` with the explicit note that it has no production caller yet. This branch adds a dry-run mode so future startup wiring can audit would-kill sessions before destructive cleanup, but production startup-persistence remains a real gap until wired with prior-worktree aliases or tracked as an accepted gap with a gate. +- Fresh `origin/main` still lets `DegradedDaemonPtyProvider.providerFor()` fall back to local when an existing-looking session id is unmapped and not rediscovered by `hasPty`. This branch changes existing-session operation paths and restored worktree-scoped plus legacy/non-scoped spawn paths to fail closed, adds contract tests, and makes real daemon adapter `listProcesses()` discovery seed targeted `hasPty` liveness. Startup reconcile wiring and live daemon-restart validation are still required before the full provider lifecycle is covered. +- Fresh `origin/main` let the daemon router spawn an unknown existing `sessionId` on the current daemon when routing cache/discovery missed its real owner. This branch now probes/discovers all adapters first, requires `isNewSession` for intentional replacement after an authoritative exit event, and fails closed if legacy ownership cannot be listed or the known id has exited, so a stale existing id cannot silently mint over another daemon's history on current. +- Fresh `origin/main` let canonical daemon-worktree restores treat a non-minted stale/local PTY id as restorable metadata. This branch now rejects non-minted restored ids when the workspace owner is canonical (`repo::path`, `folder:`, or `global-floating-terminal`), clears the stale pane/tab binding, and fresh-spawns instead of calling daemon reattach or attaching that stale id. Shared parsing also recognizes folder and floating minted ids so startup reconcile does not falsely reap valid non-repo terminal sessions. +- Fresh `origin/main` still lets `pty:listSessions` rebuild provider ownership while SSH listing failures become empty lists. This branch adds store/main/provider/renderer contracts proving disconnected SSH relay ids are retained as deferred reconnect metadata and sidebar wake hints rather than attached PTY proof, failed SSH observation does not clear previously learned ownership, `hasPty` failure is unknown, saved SSH sessions attach through relay `pty.attach`, deferred passphrase cancellation does not auto-reconnect, transient deferred reattach failure preserves the saved session id, deferred SSH no-result cleanup clears pending serializer state without consuming retry metadata, and expired deferred relay state clears stale pane/tab bindings before one fresh replacement spawn. Live SSH, WSL, remote-runtime mirror polling, false-live visual state, and real reconnect timing remain unproved. +- Fresh `origin/main` has strong Windows/unit coverage around ConPTY packaging, shell resolution, size validation, keyboard reset, complex-script classification, and Windows PTY compatibility, but the real Windows Electron/ConPTY path is still not covered as a reliable PR gate. +- Fresh `origin/main` still exposes global `pty:listSessions`; this branch now also uses targeted `hasPty` for visibility/input liveness, but gates should keep typing, focus, tab switch, visibility resume, resize, and render paths from reintroducing broad daemon/provider listing. +- Fresh `origin/main` has main-to-renderer ACK/backpressure, renderer output-scheduler caps, terminal perf scripts, and a daily/manual terminal perf workflow. This branch adds daemon stream batcher assertions that socket `write(false)` pauses further stream events until `drain`, flush-immediate output from another session drains before unrelated background backlog, global cleanup clears clients that only have backpressured writes, and queued daemon stream bytes stay bounded by preserving priority output plus the newest tail under sustained pressure; it also caps main pending renderer output per PTY and in total, trims background pending output before active pending output under total pressure, and asserts `seq`/`rawLength` metadata stays correct for the surviving tail. Live hidden-output pressure, active input latency, and full Electron perf artifacts remain unproved. +- Fresh `origin/main` has binary runtime/mobile terminal stream caps, but the legacy JSON subscription path has weaker buffering/backpressure guarantees. Either gate both paths or explicitly deprecate/accept the JSON gap. + +Existing E2E/perf tests that should be reviewed for promotion: + +- `tests/e2e/daemon-slow-init-pty-gate.spec.ts`: strong daemon live-session restore signal; already has a tight user-visible invariant. +- `tests/e2e/daemon-live-session-preservation.spec.ts` and `tests/e2e/daemon-slow-health-check-preservation.spec.ts`: likely useful daemon liveness/preservation gates after oracle and flake review. +- `tests/e2e/terminal-typing-latency.spec.ts`, `tests/e2e/terminal-codex-local-typing-latency.spec.ts`, and `tests/e2e/terminal-history-size-typing-latency.spec.ts`: useful perf signals, but should be tied to stable budgets and artifacts before blocking promotion. +- `tests/e2e/terminal-output-scheduler.spec.ts`, `tests/e2e/artificial-opencode-terminal-load.spec.ts`, and hidden-pressure scenarios: useful for output/backpressure, but should become metric-gates rather than visual-only broad E2Es. +- `tests/e2e/terminal-hidden-tui-visual-restore.spec.ts`, `tests/e2e/terminal-document-visibility-webgl-recovery.spec.ts`, and `tests/e2e/terminal-tab-switch-visual-restore.spec.ts`: useful rendering recovery coverage; must keep deterministic text/buffer oracles and limit screenshots to diagnostics. +- `tests/e2e/terminal-long-table-scroll-restore.spec.ts` and `tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts`: useful scrollback/rendering regression evidence, but they contain timed waits and should be reviewed before blocking promotion. +- `tests/e2e/ssh-localhost.spec.ts`, `tests/e2e/ssh-docker-relay-perf.spec.ts`, and SSH Codex replay/reconnect specs: useful SSH coverage, but should be separated into deterministic provider contracts plus a small number of environment-dependent live SSH soaks. +- `tests/e2e/terminal-windows-shell-paste-ownership.spec.ts` and Windows env/icon tests: useful Windows path coverage, but not a substitute for a Windows ConPTY live liveness gate. + +Promotion split for existing E2Es: + +- Promote after soak: a smaller derivative of `terminal-typing-latency.spec.ts` as the live local PTY input/output gate. +- Promote after soak: daemon preservation specs, especially slow-init and stale launch identity, as daemon warm-reattach gates. +- Promote targeted: paste ownership tests where the oracle is visible output plus exactly-one PTY write. +- Keep in soak/nightly: artificial OpenCode load, terminal scale perf report, hidden real-PTY pressure, and SSH Docker perf. +- Keep as release evidence: raw emoji/golden rendering, long-table rendering, WebGL recovery, and Codex/OpenCode artifact repros. +- Rewrite before blocking: column desync specs need resize-applied acknowledgement or an in-PTY `SIGWINCH` marker instead of post-resize sleeps. +- Rewrite before blocking: SSH Codex artifact repros and headful dead-terminal repros need deterministic fixtures/events instead of long waits and screenshots as primary proof. + +The first live PTY gate is deliberately small: + +- File: `tests/e2e/terminal-live-pty-liveness.spec.ts`. +- Command: `pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-live-pty-liveness.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1`. +- Invariant: a newly opened local terminal has one active PTY id listed exactly once, accepts real keyboard input through focused xterm, delivers process output visibly, keeps the same PTY id through repeated workspace hide/restore cycles, accepts input after restore, applies an actual size change, and exits without leaving a stale binding. +- Oracle: start a deterministic Node raw-mode probe, wait for `LIVE_PTY_READY_`, require the active PTY id to appear exactly once in `pty:listSessions`, type through `.xterm-helper-textarea`, require ordered per-key markers in serialized terminal content, switch to another worktree and back twice, require the active pane to keep the original PTY id, type again after each restore, resize and require `pty:getSize` to change plus a matching process-visible size marker, then exit the probe and shell and require the old PTY id to disappear from `pty:listSessions`. +- Wait rule: no blind sleeps. Wait only on PTY binding, active worktree changes, ready marker, key markers, resize marker, and list-session absence. +- Budget: target p95 under 75s on Linux including e2e build/setup, hard test timeout 90s. Start blocking on Linux only after soak; run macOS/Windows nightly until each platform has its own history. +- Failure artifacts: PTY id, provider classification, pid if available, size snapshots, key latencies, final `listSessions`, trace, and screenshot. +- Red/green proof: intentionally break xterm focus, PTY write forwarding, resize forwarding, and exit cleanup in separate local runs and confirm the gate fails for each class. +- Local run on 2026-07-02: 1 Playwright test passed in 58.6s total, with an 8.6s test body, on this macOS worktree. This is one green local run for the local live PTY path, not enough flake/runtime history for blocking promotion. + +## Gate Coverage Matrix + +This is the intended shape after rebasing the stack. The first two columns are what we have now; the last two are what must be added or promoted before the plan earns broader reliability claims. + +| Reliability class | Existing evidence | Gap | Next gate or action | +| --- | --- | --- | --- | +| Stale liveness snapshots closing newer PTYs | `terminal-session.snapshot-freshness`; deterministic unit coverage | No full Electron tab-survival/input echo proof | Add `terminal-platform.live-pty-liveness` and keep snapshot gate as lower-layer guard | +| Provider-session replay ownership | `agent-session.provider-ownership`; renderer state tests; queued/pending resume claim proof; bounded queued-claim index proof; same-session and wrong-session hook proof | Needs real Electron repeated activation/hook timing and bounded hook/status scan assertions if those grow | Add Electron activation proof | +| Exact PTY liveness | Fresh main has targeted `hasPty` path | Current branch must not lose it; provider failures must be unknown, not dead | Rebase/restore exact liveness reconciliation and register it as a gate | +| Store/session ownership coherence | Many focused tests touch pieces | No single derived oracle across tab, leaf, relay, reconnect, deferred SSH, and sleeping maps | Add ownership snapshot helper for tests/logging | +| Terminal lifecycle diagnostics | `terminal-observability.lifecycle-breadcrumbs` now records compact anomaly breadcrumbs into crash diagnostics | No full pane lifecycle trace buffer or diagnostics-bundle artifact proof | Add bounded trace buffer and bundle artifact test | +| Xterm addon/search/WebGL containment | `xterm-addon.boundary-containment`; deterministic core addon load, link-provider, search, and WebGL containment tests; #7054 hidden TUI product hardening | Needs live Electron typed input/output survival after addon failure, and WebGL dispose/reset throw coverage across active/hidden/resumed panes | Add focused component/live input echo follow-up; promote hidden TUI E2E after flake review | +| Visible terminal geometry | #7006 renderer/provider-style gate; #7192's merged mirror-resize and reflow-ordering red tests on main | SSH/remote and shell-visible truth not fully covered; mirror-geometry slice unregistered until rebase | Add live PTY size readback and provider-applied-size contract; register #7192's mirror-authority tests | +| Persisted session upgrade | #7007 fixture gate | Needs broader old-version corpus and daemon/SSH cases | Build immutable fixture corpus with startup timing | +| Live local PTY lifecycle | `terminal-platform.live-pty-liveness` now has a focused Electron Playwright slice covering local spawn, single active PTY listing, xterm input, PTY output, repeated workspace hide/restore, actual resize propagation, and cleanup | Needs flake/runtime history, red-green evidence, tab switch, scrollback after restore, app restart persistence, and non-local provider coverage | Promote first slice after soak; add tab-switch/scrollback/restart follow-ups | +| Windows ConPTY lifecycle | Recent fixes on main, some unit/platform logic | No required Windows live gate for ConPTY echo/resize/cursor/CJK/IME | Add `terminal-platform.windows-conpty-liveness` | +| Windows keyboard reset | Unit coverage and recent #6999/#6858 fixes | No live proof that Enter/Backspace/Arrow return to normal after agent/TUI exit | Add `terminal-input.windows-conpty-keyboard-reset` | +| Windows CJK/repaint | Complex-script and compatibility tests plus recent CJK repaint fixes | No Windows visual/pixel oracle for wide-glyph in-place redraws | Add `terminal-render.windows-cjk-repaint` | +| Windows shell parity | Shell resolution unit coverage and packaging fixes | No local-vs-daemon parity gate for PowerShell 5/7, cmd, Git Bash, WSL, startup command delivery | Add `terminal-shell.windows-resolution-parity` | +| Input latency/perf | Existing terminal perf scripts and typing-latency spec | Not registered, not tied to budgets/owners/promotion | Add `terminal-performance.input-throughput` | +| Hot interaction listing | Recent #7002 fix, targeted `hasPty` path, deterministic visibility/input/resize no-listing assertions, light tab/active-state resume no-provider-fanout assertions, and closed Resource Manager broad-listing avoidance | No count gate proving `pty:listSessions` stays out of raw focus/workspace-switch/render/high-session Electron scenarios; open Resource Manager polling still needs an explicit budget | Extend `terminal-performance.no-hot-list-sessions` | +| Daemon stream backpressure | Batching/chunking plus new batcher contract proof for `write(false)`/`drain`, cross-session flush-immediate drain priority, pure-backpressure cleanup, and bounded queued tails | No live hidden-output flood proof or active input latency artifact | Extend `terminal-performance.output-backpressure-budget` | +| Renderer/main ACK and backlog | Main ACK gates, renderer scheduler caps, focused unit coverage, and bounded main pending tails exist | No live metric artifact tying hidden restore, active key latency, queued chars, and dropped backlogs to promotion | Extend `terminal-performance.output-backpressure-budget` | +| Runtime/mobile terminal stream | `terminal-runtime.mobile-stream-budget`; runtime-RPC tests assert mobile initial snapshot <=512KB, requested binary snapshot <=2MB, pending live output <=256KB while snapshot loads, output frames <=48KB, output coalescing, abort cleanup, and stale resize re-stream suppression | Legacy JSON path parity/deprecation is undecided | Decide/gate/deprecate JSON fallback | +| Store/git hot paths around terminal changes | Perf/git-crash skills exist; git status limits/coalescing exist; `terminal-performance.store-and-git-hot-paths` now has a boot-hydration counter slice | No Electron count gate for terminal-adjacent store projection, git status, focus, typing, tab/workspace switch, render, or high-session provider fanout | Extend `terminal-performance.store-and-git-hot-paths` with interactive counters | +| IME and synthetic input | Input forwarding code and scattered coverage | No representative matrix or byte/cell oracle | Add `terminal-input.ime-and-synthetic-forwarding` | +| Daemon degraded/startup provider contract | Mechanisms exist; daemon slow-init E2E helps | Startup reconcile is unwired; degraded fallback needs fail-closed/probe proof | Add daemon degraded and startup reconcile contract gates | +| SSH/remote restore and mirror polling | SSH localhost and remote fixes exist; this branch has store/provider/main/renderer proof for deferred SSH wake metadata, SSH attach, listing failure, deferred attach, passphrase cancellation, transient deferred reattach failure, and expired deferred relay fallback | Remote mirror polling, live SSH relay behavior, false-live visual state, pending-vs-attached UI status, and real reconnect timing remain unproved | Add live SSH soak and remote mirror polling gates | +| WSL restore and launch identity | `terminal-provider.wsl-restore-contract` is now registered as a first-class gap | No WSL provider contract or live Windows WSL smoke yet proves cwd/path identity, startup command delivery, targeted liveness, or restore ownership | Add deterministic WSL provider contract, then one focused Windows WSL smoke | +| Scrollback/replay restore | Recent #7012 fix, long-table restore coverage, this branch's remote replay ordering proof, #7133's merged alt-only-clear tests, and #7173's merged ordered-seq interleaving and session-revival tests on main | Normal-buffer clear semantics and metadata-only replay still need deterministic lower-layer proof; the merged #7133/#7173 tests are unregistered until rebase | Add `terminal-output.scrollback-restore` as a dirty-state exactness contract, seeded with #7133's and #7173's tests | +| Renderer/mirror parser parity | #7148 (merged 2026-07-02) fixed the first known width divergence; its red-green width test exists on main | Parser configs can still drift silently on any axis besides widths; no parity gate command is registered | Add `terminal-mirror.parser-parity` built on the shared `terminal-unicode-provider.ts`/`pane-terminal-options.ts` seams | +| Post-restore convergence detection in production | Anomaly-breadcrumb machinery exists in this branch | Nothing measures renderer-vs-mirror divergence after real restores; #7133-class corruption stays silent until a user notices | Add `terminal-observability.restore-convergence-selfcheck` probe plus telemetry | +| Stale pixels after reveal (render-level) | #7133's reveal hardening and the live repro harness's refresh-repair oracle | Buffer oracles are blind to buffer-clean, pixels-stale variants by definition | Add `terminal-render.pixel-refresh-repair` to the release-blocking terminal-rendering-golden suite | + +## Convergence Oracles And Escape Detection + +The #7133 saga is the design input for this section. One user-visible symptom — stale terminal frames on worktree return — decomposed into five distinct mechanisms: the skipped alt-screen clear (#7133 primary fix), the WebGL render-model fossil (#7133 hardening), the daemon mirror unicode width divergence (#7148), the stale hidden-chunk ordering hole with seq-restart data loss (#7173), and desktop resizes never reaching the runtime mirror (#7192). None of the three was predictable in advance, and seventeen faithful fresh-window repro attempts missed the class because the bugs only exist in dirty, long-lived pane states. Mechanism-enumeration testing cannot catch this family. Layer-convergence assertion can. + +Orca holds four copies of what a terminal shows: the PTY application's intended screen, the main-process headless mirror buffer, the renderer xterm buffer, and the WebGL-rendered pixels. Every bug in this family is two adjacent copies silently disagreeing. The strategy is two layers that map to the program's two goals — catching issues nobody predicted, and catching regressions to issues already fixed. + +Catch novel escapes: + +1. `terminal-observability.restore-convergence-selfcheck`: after every hidden-to-visible reveal settles — whether or not a restore was triggered — compare a bounded row-hash sample of the renderer buffer against the current main-mirror state; on mismatch, record a compact content-free `terminal_lifecycle_anomaly` breadcrumb (machinery already in this branch) and count it in telemetry. Per-reveal scope is load-bearing: #7173's frozen-output variant restores faithfully and then silently drops later hidden output, so a restore-scoped probe would never fire. This turns silent corruption into a measurable signal — #7133 and #7173 would have appeared in anomaly counts weeks before the user reports — and after each fix the anomaly rate is the in-production regression detector. Budget: one bounded comparison per reveal, no polling, no content capture beyond hashed row samples. +2. `terminal-render.pixel-refresh-repair`: the render-level variant is invisible to every buffer oracle by definition (buffer clean, pixels stale). The content-independent oracle: screenshot the revealed pane, force a full model-invalidating repaint, screenshot again; a material diff means something rendered stale, whatever the mechanism. Productize the existing live repro harness with this oracle plus a JS-level render-model-vs-buffer comparison, and land it in the release-blocking `terminal-rendering-golden` suite that `release-cut.yml` already runs against every release tag. Use nightly runs only as the interim phase to stabilize the per-platform diff threshold; golden-set membership is what makes "the next release does not ship this class" directly checkable. +3. Session-corpus replay: record real agent-session byte streams (a Claude run is exactly the #7133 shape — banner, prompt echo, final frame with blank regions) as fixtures and replay them through the hide/skip/restore machinery under the convergence oracles. Real corpora contain escape-sequence conjunctions nobody writes by hand. This is fixture strategy for the gates above, not a separate gate. + +Catch regressions to known fixes: + +4. `terminal-mirror.parser-parity`: feed identical byte corpora to the renderer xterm configuration and the main headless mirror and assert cell-identical buffers, with both parsers constructed from one shared configuration module. #7148's divergence (Unicode 11 + ZWJ in the renderer, default width tables in the mirror) existed since the mirror was born and was catchable by a millisecond-scale unit test; this gate also blocks the next one-sided config drift. +5. The dirty-state restore exactness contract as `terminal-output.scrollback-restore`'s command: apply snapshots onto adversarially dirty panes and assert the resulting buffer exactly equals the snapshot frame. Exactness is load-bearing — presence-of-marker oracles pass on merged frames. Seeded with #7133's merged alt-only-clear tests and #7148's positioned-overwrite width oracle. +6. Manifest registration of every escape: each fixed escape's red-green test becomes gate evidence, and per the Operating Rules the fix PR must also strengthen the relevant convergence oracle, so each escape permanently shrinks the space for the next one. + +Seam placement is part of the design: convergence must be asserted at every adjacent pair of copies, not at one seam. #7192 proved this the hard way — a renderer-vs-mirror check is blind to corruption inside the mirror itself, because the restore is perfectly faithful to a corrupted source. That is why the geometry gate owns the runtime mirror's dimensions and reflow ordering as a first-class size authority, upstream of the seam this section's self-check watches. + +The honest limit stands: this shrinks the escape space rather than zeroing it. The first four mechanisms in the saga are caught by items 1, 2, 4, and 5 without predicting any of them; the fifth (#7192) escaped every renderer-side oracle as originally specced and was closed by adding the mirror to the geometry authority list — evidence that the escape-driven loop (item 6) is not optional. The three new gates and the strengthened `terminal-output.scrollback-restore` entry are registered in the manifest as `protection: "none"` gaps, so the checker, factory reviews, and this plan cannot claim them as coverage before their commands exist. + +## Immediate Fix Candidates + +These are the concrete pieces to prioritize before declaring the plan implemented: + +Three mechanical prerequisites gate everything below: + +- Materialize the split: commit each working-tree slice to its owning branch per the file assignment in the PR Split Plan, reset the diverged child branches (#7004, #7005, #7006, #7008) from the worktree versions of their files, and push. Until then, every evidence run in this plan and in the manifest refers to unreproducible state. +- During the rebase, run the testFiles-vs-fresh-main audit and fix the confirmed #6949 WebGL rename by adopting `terminal-webgl-atlas-recovery.test.ts` and evaluating `pane-webgl-context-recovery.test.ts` and `pane-webgl-renderer.test.ts` for the containment gate. +- Open the aggregated non-blocking `reliability-gates` CI job so promotion evidence starts accruing with machine provenance. + +1. Rebase or retarget the existing reliability PR stack onto fresh `origin/main`, then verify the stack still contains queued automatic resume claims and exact `hasPty` missing-session reconciliation. This branch has restored both deterministic slices, but final stack verification is still required. +2. Extend the provider-session ownership test from the current queued/pending, bounded-index, same-session hook, and wrong-session hook proof to a real Electron repeated-activation test, plus bounded-work assertions for any new hook/status scans. +3. Add or restore exact liveness tests: `hasPty(id) === false` tears down only the matching local PTY, while `true`, `null`, rejection, SSH/remote unknown states, and stale pre-bind responses do not close panes. +4. Add a small derived terminal ownership snapshot helper for tests and diagnostics. It should report whether each live PTY is represented once by the intended tab/leaf and absent from stale maps after attach, reattach, exit, clear, hibernate, and restore. +5. Keep the replay FIFO/burst fix and tests in the stack, rebased over #7133's alt-clear restore preamble in the same file. This branch now asserts that multiple replay notifications arriving during an async drain cannot overwrite one another, cannot reorder accepted replay snapshots, and cannot grow an unbounded replay queue under bursty snapshots; remaining replay work is normal-buffer clear semantics (alt-screen clear landed in #7133), metadata-only replay, and hidden/live-output interleaving. +6. Wire `DaemonPtyAdapter.reconcileOnStartup`, or explicitly create a provider-contract gate and accepted gap if wiring is not safe yet. +7. Keep the degraded daemon fail-closed contract in place and extend it to startup/restart coverage. This branch now prevents unknown existing ids from silently routing operation calls or restored worktree-scoped and legacy/non-scoped spawn calls to fallback; the remaining work is production startup reconcile, prior-worktree aliases, and live daemon-restart validation. +8. Keep the daemon-router targeted-discovery fix in place. This branch now caches ownership discovered by `hasPty()` before later write/resize-style operations and fails closed for operations on unknown existing session ids, preventing legacy-daemon sessions from silently routing to the current daemon. +9. Extend provider listing failure contract tests. This branch now asserts disconnected SSH relay ids are retained as deferred reconnect metadata and sidebar wake hints rather than attached PTY proof, a rejected SSH listing does not clear previously learned ownership, thrown SSH `hasPty` is unknown, provider expired attach does not silently fresh-spawn, deferred SSH passphrase cancellation does not reconnect, deferred attach uses saved session ids, transient deferred reattach failure preserves retry metadata instead of fresh-spawning, and expired deferred relay fallback clears stale pane/tab bindings before one fresh spawn. Remaining work is empty-but-ambiguous provider snapshots, WSL, remote-runtime mirror polling, live SSH relay timing, false-live visual state, pending-vs-attached UI status, and pane-close behavior. +10. Extend the new lifecycle anomaly breadcrumbs into a bounded structured lifecycle trace outside E2E-only mode for reattach, expired sessions, provider-list failure, unknown ownership, fallback routing, replay, and terminal input recovery. +11. Extend the hot-interaction listing count gate. The targeted `hasPty`, resize-on-resume, light tab/active-state resume, and closed Resource Manager polling slices are implemented; next it should prove `pty:listSessions()` calls are zero during raw focus, workspace switch, render, high-session Electron scenarios, and separately budget open status/diagnostic polling. +12. Add a cheaper Resource Manager session-status path or an explicit budget for open-popover `pty:listSessions()` polling. The closed-popover poll is removed, but the visible Resource Manager inventory still fans out to providers and mutates ownership, so it should have a measured ceiling. +13. Budget the new startup hydration counters for `hydrateLocalPtyRegistryAtBoot` in CI/runtime evidence, including repo/worktree enumeration count, git subprocess count proxy, daemon session-list duration, and confirmation that no startup-critical path awaits the scan. +14. Extend the output backpressure proof. The daemon stream batcher now pauses after `write(false)`, prioritizes flush-immediate output from another session ahead of unrelated background backlog on `drain`, clears pure-backpressure queues on global cleanup, bounds queued stream bytes, and resumes on `drain`; main pending renderer output is capped per PTY and in total. Next, hidden-output floods and active key latency need metric artifacts under the perf budget. +15. Add Windows platform gates rather than relying on Linux/macOS live PTY evidence: ConPTY liveness, keyboard reset, CJK repaint, shell resolution parity, and IME/native-text forwarding. +16. Decide whether the legacy JSON runtime terminal subscribe path is supported. If supported, gate its snapshot/live-output byte caps; if not, mark it as an accepted gap with a deprecation path. +17. Implement the dirty-state restore exactness contract as `terminal-output.scrollback-restore`'s first command, seeded with #7133's merged alt-only-clear tests and #7173's ordered-seq interleaving and session-revival tests: apply snapshots onto already-alt-screen panes with stale content in cells the new frame leaves blank, include revived sessions with restarted PTY seq counters in the dirty-state matrix, and assert exact buffer equality. +18. Implement the parser-parity seed during the rebase, now that #7148 has merged: register `headless-emulator-unicode-width.test.ts` plus a shared parser-construction assertion over `terminal-unicode-provider.ts` and `pane-terminal-options.ts` as `terminal-mirror.parser-parity`'s first command. The test file exists on fresh main but not at this branch's stale merge-base, so the command cannot be registered before the rebase. +19. Implement the per-reveal convergence self-check behind the existing anomaly-breadcrumb machinery — comparing the renderer buffer against current mirror state on every reveal, not only after restores, with a strict per-reveal budget and no content capture — and productize the stale-render repro harness into `terminal-render.pixel-refresh-repair` targeting the release-blocking terminal-rendering-golden suite. +20. Register #7192's merged mirror-geometry red tests as the mirror-authority slice of `terminal-geometry.visible-convergence` during the rebase, covering both accepted-resize fan-out to the runtime mirror and reflow-vs-queued-output ordering. + +Next gates to add before claiming broad terminal reliability coverage: + +1. `terminal-platform.live-pty-liveness`: first focused Electron/live-PTY slice is implemented for local Linux/macOS-style terminals. One local macOS run asserts bind, single active PTY listing, focus, keyboard input, PTY echo, repeated workspace hide/restore of the same PTY, post-restore input, actual size propagation, and exit cleanup. Follow up with scrollback-after-restore, tab switch, app restart, and non-local providers before broad live-terminal claims; keep it `experimental` until it has flake/runtime and red-green evidence. +2. `terminal-platform.windows-conpty-liveness`: Windows-specific gate for ConPTY spawn/activation, PowerShell resolution, cursor/rewrite repaint, CJK/IME bytes, and no stuck `stale_bootstrap`. Use lower-level provider contracts where possible and one focused Windows Electron smoke for the real ConPTY path. +3. `terminal-input.windows-conpty-keyboard-reset`: Windows local ConPTY gate proving Enter, Backspace, Arrow, paste, and ordinary shell submission behave normally after an agent or TUI exits. Oracle should include PTY input logs proving stale Kitty/CSI-u mode is not still applied to standard keys. +4. `terminal-render.windows-cjk-repaint`: Windows local ConPTY wide-glyph redraw gate. Oracle should combine xterm buffer/text evidence with screenshot/canvas/pixel evidence and a bounded refresh count. +5. `terminal-shell.windows-resolution-parity`: local and daemon providers must resolve equivalent shell path, args, cwd, env, startup command delivery, and fallback behavior for PowerShell 5/7, cmd, Git Bash, WSL, and missing `pwsh`. +6. `terminal-performance.input-throughput`: non-blocking perf gate for typing latency, hidden-output pressure, renderer CPU, terminal output throughput, resize churn, and store subscriber work. This should be metric-based, not screenshot-based. +7. `terminal-performance.no-hot-list-sessions`: deterministic count gate proving `pty:listSessions()` is not called from typing, focus, tab/workspace switch, visibility resume, resize, render, or per-pane liveness paths. +8. `terminal-performance.daemon-stream-backpressure`: provider/daemon contract gate proving daemon socket writes respect backpressure under output floods and do not starve active input. +9. `terminal-performance.output-backpressure-budget`: main-to-renderer ACK and renderer scheduler budget gate. Suggested ceilings: peak renderer in-flight bytes <=8MB total, <=512KB per PTY plus active reserve, renderer queued chars <=2MB, dropped renderer backlogs 0 in normal perf scenarios, and hidden restore <=1000ms. +10. `terminal-runtime.mobile-stream-budget`: runtime/mobile stream gate proving binary terminal snapshots stay <=512KB mobile or <=2MB requested, live pending output stays <=256KB while snapshot loads, chunks stay <=48KB, and batches stay <=64KB. +11. `terminal-input.ime-and-synthetic-forwarding`: representative IME/input matrix with deterministic byte/cell oracles at the lowest reliable layer, plus platform-specific manual/soak coverage where real IMEs are required. +12. `terminal-provider.remote-daemon-contract`: provider contract for daemon degraded spawn, SSH/remote unknown liveness, mirror polling, reconnect, and path/cwd authority. +13. `terminal-provider.wsl-restore-contract`: Windows WSL provider contract proving host/guest cwd identity, shell launch args, startup command delivery, targeted liveness semantics, and restore ownership, followed by one focused live WSL smoke. +14. `terminal-output.scrollback-restore`: deterministic scrollback/snapshot/replay gate for hidden output, restore, clearing, and no cross-tab or stale-output overlap. Seed it with #7133's merged assertions — the alt-only clear preamble written before alt-screen snapshot data (and no `3J`) in `pty-connection.test.ts`, plus the reveal-repaint ordering tests — then extend to normal-buffer clear semantics, metadata-only replay, and hidden/live-output interleaving. #7133's buffer-merge escape (pre-hide frame bleeding through blank cells) is the motivating regression for this gate. +15. `terminal-capability.startup-color-query`: startup OSC 10/11 replies are answered out of band at startup only, do not leak into shell/renderer streams, and ordinary OSC color queries remain renderer-handled. +16. `terminal-mirror.parser-parity`: renderer xterm and the main headless mirror parse identical byte corpora into cell-identical buffers, with both parsers constructed from one shared configuration module. Seed with #7148's merged width test (`headless-emulator-unicode-width.test.ts`) plus a shared-construction assertion over `terminal-unicode-provider.ts` and `pane-terminal-options.ts`; extend with recorded agent-session corpora. +17. `terminal-observability.restore-convergence-selfcheck`: production per-reveal probe comparing a bounded row-hash sample of the renderer buffer against current main-mirror state — on every reveal, not only after restores, because #7173's frozen-output variant never triggers another restore — recording deduped content-free anomaly breadcrumbs and telemetry counts. This is the in-production escape detector for the whole restore family; corruption upstream of the mirror itself is owned by the geometry gate. +18. `terminal-render.pixel-refresh-repair`: content-independent refresh-repair oracle (screenshot, force a model-invalidating repaint, screenshot, bounded diff) plus a render-model-vs-buffer comparison, productized from the existing stale-render repro harness into the release-blocking `terminal-rendering-golden` suite; nightly runs only until the diff threshold is stable. + +Specific near-term hardening before promotion: + +- #7008 now includes a bounded queued-claim assertion plus same-session and wrong-session hook cases for provider-session ownership checks. It should still be followed by a real Electron repeated-activation proof and any additional bounded-work assertions if hook/status ownership scans grow. +- Each gate PR should keep its residual gaps in the PR description and manifest until a real follow-up gate removes them. Do not let a green lower-layer gate imply live Electron, SSH, WSL, Windows ConPTY, or IME coverage. + +Promotion rule for these additions is unchanged: do not make any of them blocking until they have red/green proof, stable runtime history, zero unexplained flakes in the promotion window, a clear owner, and a demotion rule. A non-blocking gate is useful evidence and documentation; it becomes protection only after promotion criteria are met. + +## Goal + +Prevent the recent terminal, tab, session, startup, provider, and performance regressions from escaping again by turning each high-risk reliability class into a small executable gate with explicit maturity, owner, runtime budget, flake history, and promotion evidence. + +This plan is broad in scope but staged in rollout. The first milestone exercises the operating model end to end; later milestones add live Electron, Windows ConPTY, SSH/remote, daemon, IME, scrollback, and performance gates without turning the suite into noisy sprawl. + +## Operating Rules + +- Add gates only when they protect a named invariant tied to a real issue, PR, or accepted gap. +- Prefer deterministic unit or provider-contract tests over Electron UI tests when the lower layer proves the user-visible invariant. +- Never promote a gate to blocking until it has red/green evidence, stable runtime evidence, and a named demotion rule. +- Keep stress/torture runs non-blocking unless they have deterministic oracles and flake history. +- Include a performance or crash-safety budget whenever the protected change touches terminal throughput, hidden panes, persistence, startup, polling, subprocesses, git/worktree scans, SSH, WSL, or Windows paths. +- Restore, replay, reattach, and snapshot gates must start from adversarially dirty initial states — already-alt-screen panes, stale content occupying cells the new frame leaves blank, scrollback present, wide/emoji glyphs — never only fresh terminals. Fresh-state-only testing is why the #7133 class survived seventeen faithful repro attempts. +- Restore and replay oracles must assert frame exactness or stale-content absence. Marker-presence oracles pass on merged frames and are not acceptable correctness evidence for restore paths. +- Every escaped terminal bug's fix PR must ship both the narrow mechanism regression test and a strengthening of the relevant convergence oracle (parser parity, restore exactness, convergence self-check, or pixel refresh-repair), so each escape permanently shrinks the space for the next one. + +## Milestone 1 + +Milestone 1 creates the reliability-gate loop with the two highest-leverage escaped classes: + +1. `terminal-session.snapshot-freshness` + A stale local/daemon liveness snapshot cannot close a newer PTY binding. + +2. `agent-session.provider-ownership` + Workspace activation, restore, sleep, hibernate, dedupe, clearing, or reconnect code cannot replay or resume a provider session already owned, queued, pending, or live in the workspace. + +Both gates already have targeted tests in the repo. Milestone 1 makes them reviewable factory artifacts by registering their invariant, command, owner, maturity, known gaps, and promotion criteria in `config/reliability-gates.jsonc`. + +## First Commands + +Run the structural manifest check: + +```sh +pnpm run check:reliability-gates +``` + +Run the first two gate tests: + +```sh +pnpm exec vitest run --config config/vitest.config.ts \ + src/renderer/src/components/terminal-pane/terminal-dead-session-reconcile.test.ts \ + src/renderer/src/lib/resume-sleeping-agent-session.test.ts +``` + +Run the existing terminal perf report gate before promoting terminal lifecycle gates to blocking: + +```sh +pnpm run test:e2e:terminal-perf:scale:report +``` + +## Promotion Criteria + +A gate can move from `experimental` to `soak` when: + +- it is registered in the manifest; +- it has a deterministic oracle with no blind sleeps; +- it has a cheap command that can run repeatedly; +- it names the failure artifact reviewers should inspect; +- it has a clear owner and demotion rule. + +A gate can move from `soak` to `blocking` when: + +- it has red/green evidence against the old behavior, a regression fixture, or an intentionally broken variant; +- it has at least 100 consecutive passing soak runs or 14 days of required-platform CI history, whichever is more appropriate for the gate; +- that history comes from the aggregated `reliability-gates` CI job with machine-recorded results (see Evidence Provenance And CI Wiring), not from hand-entered local runs; +- p95 runtime is within the manifest budget; +- there are zero unexplained flakes in the promotion window; +- any required perf/git-crash budget has measured evidence. + +For visual and live release-assurance gates, `blocking` means membership in the release-blocking `terminal-rendering-golden` suite that `release-cut.yml` runs against every release tag — the existing golden rail, not a new checkpoint mechanism, and never PR-blocking. Deterministic unit and contract gates stay on the PR path, where catching a regression is strictly cheaper than catching it at release time. + +The manifest also allows `accepted-gap` for explicitly deferred reliability work with a named owner and `deprecated` for gates superseded by a stronger invariant or removed product surface. Those levels should stay visible in the manifest so factory review can distinguish intentional gaps from missing coverage. + +## Factory Contract + +`brennan-yolo-lite`, `review-code`, `perf`, and `git-crash-perf` should treat the manifest as the source of truth. + +For any PR touching a P0-capable surface, the agent or reviewer should: + +- identify the touched reliability class; +- name the matching manifest gate or accepted gap; +- run the gate command or explain why it does not apply; +- require a new manifest entry when the PR introduces a new reliability class; +- invoke `perf` or `git-crash-perf` when the touched surface matches their risk scope. + +## Evidence Provenance And CI Wiring + +Hand-entered evidence is the weakest link in this plan: the checker validates metadata shape, but nothing yet proves a registered command actually ran against the code it claims to cover. Reliability gates become protection only when they run automatically, against pushed commits, with recorded history. Requirements: + +1. Every `evidenceRuns` entry must record the pushed commit SHA it ran against. A run against uncommitted working-tree state is a development note, not promotion evidence; it must be re-run and re-recorded once the owning PR's branch is pushed. All evidence runs currently in the manifest predate the split and must be refreshed this way. +2. Add one aggregated non-blocking `reliability-gates` CI job that runs `check:reliability-gates` plus every command-backed gate command, and uploads a machine-readable per-gate result artifact (gate id, command, result, duration, commit SHA, runner platform). Run it nightly and on PRs that touch terminal/session/provider/startup paths. Because every gate command except the live Playwright slice is a focused vitest run measured in seconds (a combined run of all 28 unit-layer gate test files completed in 10.2s with 928 tests on 2026-07-02), the job's cost is dominated by setup, not by gate count. +3. Promotion to `active`/blocking requires history from that CI job — the 100-consecutive-run or 14-day criteria in Promotion Criteria — not local runs. Flakes recorded by the job must be triaged before the affected gate can be promoted, and an unexplained flake on an `active` gate is the demotion signal. +4. Checker follow-up: once the CI artifact exists, extend the checker so `active` gates require at least one CI-provenance evidence run, and so evidence-run commit SHAs are verified to be ancestors of the branch under check. +5. Rebase audit: before any merge claim, mechanically verify every declared `testFiles` entry exists on fresh `origin/main` (or on the rebased tree) rather than trusting memory. The #6949 WebGL rename is the standing example of why. + +## Milestone 2 + +After Milestone 1 works, add the next three gates as experimental entries with deterministic harnesses: + +- `terminal-geometry.visible-convergence` +- `xterm-addon.boundary-containment` +- `startup-upgrade.persisted-session-corpus` + +These should not become blocking until the team has red/green, runtime, and flake evidence. The goal is fewer useful gates, not a larger noisy suite. + +## Milestone 3 + +Milestone 3 is where the plan stops being only a lower-layer regression net and starts exercising the real terminal runtime: + +- Register existing terminal perf and E2E tests as experimental manifest gates only after reviewing their oracles, waits, artifacts, and flake history. +- Add the live local PTY gate with a tight oracle: spawn a shell, print a unique token, switch hidden-visible, assert the same PTY/session id, verify visible buffer content, type again, and prove no duplicate tab or blank pane appeared. +- Add Windows ConPTY coverage on Windows CI for echo, resize/readback, cursor visibility, CJK/emoji output, keyboard reset, shell resolution parity, and activation after restore. +- Add daemon degraded/startup reconcile contracts before relying on daemon fallback behavior. +- Add live SSH, WSL, remote-runtime mirror polling, and provider-listing edge contracts before treating remote/SSH restore as broadly covered; the current mocked SSH deferred-reattach and expired-relay tests are useful lower-layer proof, not live remote coverage. +- Add perf gates with explicit budgets for input latency, event-loop delay, hidden-output pressure, daemon stream backpressure, no hot `listSessions`, pending bytes, renderer ACK behavior, store hot paths, and runtime/mobile stream byte caps. +- Add IME/synthetic-input coverage at the lowest deterministic layer, with platform-specific manual or soak coverage only where real IMEs are required. + +Milestone 3 gates should begin as `experimental`. They become `soak` only when the command is stable enough to run repeatedly and the failure artifact tells an engineer exactly what broke. They become `blocking` only after the promotion criteria above are satisfied. diff --git a/docs/reference/reliability-pain-points-2026-06-30.md b/docs/reference/reliability-pain-points-2026-06-30.md new file mode 100644 index 00000000000..8049e3e7191 --- /dev/null +++ b/docs/reference/reliability-pain-points-2026-06-30.md @@ -0,0 +1,712 @@ +# Reliability Pain Points and Improvement Plan + +Date: 2026-06-30 + +## Scope + +This note summarizes the recent reliability review across: + +- Recently merged reliability PRs, especially terminal/tab/startup/session work from 2026-06-26 through 2026-06-30. +- GitHub issues labeled `P0` and `P1`. +- Local git history for authorship when issue text or PR bodies named a regressing change. + +The goal is to identify where Orca is hurting, what engineering improvements would reduce repeat incidents, and what can fairly be said about attribution. + +## Issue Snapshot + +At the time of review: + +- Open `P0`: 9 +- Total `P0`: 14 +- Open `P1`: 191 +- Open `P1` split: 90 bugs, 86 enhancements, 15 other + +Last seven days reviewed: 2026-06-23 through 2026-06-30. + +- Merged PRs in the window: 386 +- `P0` issues created in the window: 8 +- `P1` issues created in the window: 83 + +The last-seven-day issue set is the strongest signal for the current pain. It includes the new-tab autoclose `P0`, Windows terminal activation hangs, renderer CPU/input lag, Windows startup visibility failure, Codex config corruption on Windows, a large terminal/IME/rendering cluster, WSL/remote/provider drift, and agent/sidebar/session identity drift. + +Open `P0` issues: + +- [#6877](https://github.com/stablyai/orca/issues/6877): macOS 1.4.106 nested `simcam` dylib is unnotarized and rejected by Gatekeeper. +- [#6874](https://github.com/stablyai/orca/issues/6874): Windows `AppHangB1` when opening or activating a terminal session; runtime stuck in `stale_bootstrap`. +- [#6873](https://github.com/stablyai/orca/issues/6873): likely duplicate or near-duplicate of #6874, though the issue metadata says macOS while the title/body describe Windows. +- [#6795](https://github.com/stablyai/orca/issues/6795): app became very slow after an update; typing can lag by 2-5 seconds. +- [#6773](https://github.com/stablyai/orca/issues/6773): cannot create a new terminal tab or agent; the tab opens and closes immediately. +- [#6655](https://github.com/stablyai/orca/issues/6655): high `Orca Helper (Renderer)` CPU on Mac Mini M4 causing severe UI/input lag. +- [#5787](https://github.com/stablyai/orca/issues/5787): whole Windows window freezes; input dies in every pane; sessions close on click. +- [#5356](https://github.com/stablyai/orca/issues/5356): session loss on first launch after upgrading from 1.4.65 to 1.4.68. +- [#5314](https://github.com/stablyai/orca/issues/5314): Windows close button and Alt+F4 do nothing. + +Closed `P0` issues worth keeping in the reliability history: + +- [#6233](https://github.com/stablyai/orca/issues/6233): Windows window fails to appear while the process runs in the background. +- [#6163](https://github.com/stablyai/orca/issues/6163): Orca-managed Codex `config.toml` duplicate key. +- [#5377](https://github.com/stablyai/orca/issues/5377): native watcher main-process crash on macOS. +- [#5144](https://github.com/stablyai/orca/issues/5144): Windows close-confirmation deadlock. +- [#5109](https://github.com/stablyai/orca/issues/5109): sidebar agent status mismatch. + +## P1 Reliability Clusters + +The `P1` set is too large to treat as a flat list. The high-signal reliability clusters are: + +### Terminal Rendering and Input + +- [#6901](https://github.com/stablyai/orca/issues/6901): garbled text. +- [#6764](https://github.com/stablyai/orca/issues/6764): Windows terminal issues. +- [#6632](https://github.com/stablyai/orca/issues/6632): display artifacts in Claude Code full-screen TUI. +- [#6144](https://github.com/stablyai/orca/issues/6144): laggy Claude Code terminal scroll. +- [#5969](https://github.com/stablyai/orca/issues/5969): remote-over-SSH Codex display artifacts. +- [#5345](https://github.com/stablyai/orca/issues/5345): Windows/WSL scrolling causes duplicated or garbled text and content overlap. + +### IME and International Text + +- [#6905](https://github.com/stablyai/orca/issues/6905): Vietnamese IME input broken in terminal. +- [#6765](https://github.com/stablyai/orca/issues/6765): Linux Sogou Pinyin/fcitx only commits the first CJK character. +- [#6698](https://github.com/stablyai/orca/issues/6698): Vietnamese Telex input loses characters. +- [#5921](https://github.com/stablyai/orca/issues/5921): CJK characters duplicated in Orca terminal on Windows. +- [#5262](https://github.com/stablyai/orca/issues/5262): Arabic text rendering appears reversed or disconnected in terminal input. + +### Paste and Clipboard + +- [#5365](https://github.com/stablyai/orca/issues/5365): terminal freezes for seconds when pasting long text on Windows. +- [#5358](https://github.com/stablyai/orca/issues/5358): Windows paste preview collapses long text and cannot expand it. +- [#5919](https://github.com/stablyai/orca/issues/5919): generic paste reliability issue. +- [#5960](https://github.com/stablyai/orca/issues/5960): bad screen result when pasting links. +- [#6364](https://github.com/stablyai/orca/issues/6364): pasting images into an agent CLI does not work using Remote Host. + +### SSH, Remote Runtime, WSL, and Provider Boundaries + +- [#6106](https://github.com/stablyai/orca/issues/6106): SSH terminal loses pre-TUI shell output after Codex tab restore. +- [#6846](https://github.com/stablyai/orca/issues/6846): remote PTY sessions do not stay alive on disconnect. +- [#6908](https://github.com/stablyai/orca/issues/6908): WSL project added through UI does not discover branches/base refs, while CLI repo add works. +- [#6907](https://github.com/stablyai/orca/issues/6907): Codex launched in a WSL worktree does not appear in agent status/sidebar. +- [#6916](https://github.com/stablyai/orca/issues/6916): OMP launched in a WSL worktree has no live agent status. +- [#6032](https://github.com/stablyai/orca/issues/6032): Remote Orca Servers can lose host-scoped repo and project state. +- [#6688](https://github.com/stablyai/orca/issues/6688): viewing a diff on remote host errors. +- [#6753](https://github.com/stablyai/orca/issues/6753): remote-host GitHub merge errors. + +### Agent, Tab, Session, and Sidebar State Drift + +- [#6910](https://github.com/stablyai/orca/issues/6910): active agent tabs duplicate UI sessions when switching repositories. +- [#6803](https://github.com/stablyai/orca/issues/6803): multiple working agents thrash their order in the sidebar. +- [#6072](https://github.com/stablyai/orca/issues/6072): mobile keeps showing old agent rows after terminals are closed. +- [#5913](https://github.com/stablyai/orca/issues/5913): Pi compact agent row remains after closing its terminal tab. +- [#5718](https://github.com/stablyai/orca/issues/5718): OMP sessions not showing up in Agent Session History or filter options. +- [#5404](https://github.com/stablyai/orca/issues/5404): false "agent running" notifications persist after starting Claude Agents on VSSH/WSL. + +### Mobile Terminal and Tabs + +- [#5421](https://github.com/stablyai/orca/issues/5421): iOS workspace tabs do not open. +- [#6756](https://github.com/stablyai/orca/issues/6756): iOS terminal renders in cursive/italic font and continuously flickers. +- [#5628](https://github.com/stablyai/orca/issues/5628): mobile resizing feature request, closely related to terminal fit/reflow reliability. + +### Browser, Profile, and Embedded Runtime + +- [#6923](https://github.com/stablyai/orca/issues/6923): browser profiles do not isolate storage. +- [#6760](https://github.com/stablyai/orca/issues/6760): floating browser cannot load any page. +- [#6268](https://github.com/stablyai/orca/issues/6268): embedded-browser cookie/session limitation prevents Auth0 login. +- [#6875](https://github.com/stablyai/orca/issues/6875): cannot copy Google Chrome cookies database. +- [#6652](https://github.com/stablyai/orca/issues/6652): browser zoom broken. + +### Startup, Install, and Platform Hangs + +- [#5657](https://github.com/stablyai/orca/issues/5657): macOS startup PATH probe can hang under Endpoint Security agents. +- [#5107](https://github.com/stablyai/orca/issues/5107): Orca opens very slowly on Windows 11 and leaves a residual process. +- [#5989](https://github.com/stablyai/orca/issues/5989): Windows Defender blocks application during installation. + +## Recent Reliability PR Context + +The late-June reliability work clustered around the same few subsystems: + +- New terminal tabs opening and immediately closing: [#6796](https://github.com/stablyai/orca/pull/6796), [#6801](https://github.com/stablyai/orca/pull/6801). +- Stale/frozen/blank terminals: [#6514](https://github.com/stablyai/orca/pull/6514), [#6800](https://github.com/stablyai/orca/pull/6800), [#6833](https://github.com/stablyai/orca/pull/6833), [#6830](https://github.com/stablyai/orca/pull/6830), [#6866](https://github.com/stablyai/orca/pull/6866). +- PTY size and layout desync: [#6644](https://github.com/stablyai/orca/pull/6644), [#6649](https://github.com/stablyai/orca/pull/6649), [#6684](https://github.com/stablyai/orca/pull/6684), [#6725](https://github.com/stablyai/orca/pull/6725), [#6785](https://github.com/stablyai/orca/pull/6785), [#6853](https://github.com/stablyai/orca/pull/6853). +- Renderer/xterm crash hardening: [#6852](https://github.com/stablyai/orca/pull/6852), [#6872](https://github.com/stablyai/orca/pull/6872), [#6855](https://github.com/stablyai/orca/pull/6855), [#6868](https://github.com/stablyai/orca/pull/6868), [#6856](https://github.com/stablyai/orca/pull/6856), [#6857](https://github.com/stablyai/orca/pull/6857). +- Windows shell/spawn/input: [#6537](https://github.com/stablyai/orca/pull/6537), [#6876](https://github.com/stablyai/orca/pull/6876), [#6858](https://github.com/stablyai/orca/pull/6858), [#6890](https://github.com/stablyai/orca/pull/6890). +- Hidden/agent startup behavior: [#6824](https://github.com/stablyai/orca/pull/6824), [#6798](https://github.com/stablyai/orca/pull/6798), [#6836](https://github.com/stablyai/orca/pull/6836). + +## Last-Seven-Day Regression Origin Map + +This section maps recent regressions to the place where the system failed, and to the gate that should catch the same class next time. + +| Issue | Symptom | Regression point | Fix / related PR | Missing gate | +| --- | --- | --- | --- | --- | +| [#6773](https://github.com/stablyai/orca/issues/6773) | New terminal/agent tab opens and closes immediately | Dead-session reconciliation could treat a fresh PTY binding as absent from a stale `listSessions()` snapshot. This likely traces to the local dead-session reconcile path introduced for hidden exited panes in [#6514](https://github.com/stablyai/orca/pull/6514). | [#6796](https://github.com/stablyai/orca/pull/6796), [#6801](https://github.com/stablyai/orca/pull/6801) | Newborn terminal lifecycle test: create shell and agent tabs while liveness reconciliation is in flight; assert the tab cannot be closed by an older snapshot. | +| [#5356](https://github.com/stablyai/orca/issues/5356) | First launch after upgrade loses active floating/agent terminal sessions | Cold-restore relied on state that only versions after the fix could write, so users upgrading from pre-fix versions had no migration source. Issue text explicitly names [#5234](https://github.com/stablyai/orca/pull/5234) and [#5240](https://github.com/stablyai/orca/pull/5240). | Not fully closed in current open `P0` set | Upgrade fixture test: boot current app with persisted state from the last affected production version and assert active terminal/agent sessions are preserved or visibly recoverable. | +| [#5319](https://github.com/stablyai/orca/issues/5319) | Linux/Wayland terminals render but stop accepting keyboard/scroll input | Wayland-aware GPU safeguard was removed in [#1344](https://github.com/stablyai/orca/pull/1344), leaving eager GPU channel setup able to wedge terminal input on affected systems. | [#6557](https://github.com/stablyai/orca/pull/6557) | Platform GPU matrix test: Linux Wayland launch with terminal WebGL/input smoke and assertion that GPU flags preserve input, not only rendering. | +| [#6233](https://github.com/stablyai/orca/issues/6233) | Windows process runs but no window appears | Startup window reveal depended on Electron `ready-to-show`; if that event stalled, there was no fallback reveal or user-visible failure. | [#6462](https://github.com/stablyai/orca/pull/6462) | Startup watchdog test: simulate missing `ready-to-show` on Windows and assert the app either reveals or exits with a clear recovery path. | +| [#6163](https://github.com/stablyai/orca/issues/6163) | Orca-managed Codex `config.toml` fails with duplicate `hooks.state` keys on Windows | Two TOML writers serialized the same Windows path key differently, and Orca deduped textually rather than by decoded key. | [#6318](https://github.com/stablyai/orca/pull/6318), follow-up [#6388](https://github.com/stablyai/orca/pull/6388) bug-scan fix | Cross-writer config round-trip test: seed config with Codex-written literal Windows paths, run Orca hook sync twice, then parse with a TOML parser and assert one decoded key. | +| [#6244](https://github.com/stablyai/orca/issues/6244) | Headless-launched automation terminal showed only a bash prompt until unmount/remount | Hidden/background automation worktrees were not mounted early enough for output/replay to hydrate on first view. | [#6568](https://github.com/stablyai/orca/pull/6568) | Headless automation mount test: launch background automation, wait for output, first-focus the tab, and assert output is already visible. | +| [#6331](https://github.com/stablyai/orca/issues/6331) | WSL workspaces with UNC cwd hit `DaemonProtocolError` | Windows/WSL path handling crossed provider boundaries and daemon spawn assumptions. | [#6536](https://github.com/stablyai/orca/pull/6536) | WSL UNC provider contract: add/open/delete workspace whose root is `\\wsl.localhost\...`; assert daemon and file operations use the right host/path semantics. | +| [#6336](https://github.com/stablyai/orca/issues/6336) | Monorepo subfolder import opened the repo root instead of the selected subfolder | Git-root normalization leaked into the first-terminal cwd/user-facing workspace root behavior. | [#6574](https://github.com/stablyai/orca/pull/6574) | Add-project matrix: repo root, monorepo subfolder, folder workspace, remote runtime; assert file tree root and first terminal cwd separately. | +| [#6147](https://github.com/stablyai/orca/issues/6147) | Chinese full-width punctuation became half-width ASCII in terminal | IME/composition forwarding lacked coverage for macOS full-width punctuation and composition-owned text. | [#6417](https://github.com/stablyai/orca/pull/6417) | IME matrix test: macOS CJK full-width punctuation, Vietnamese Telex, Linux fcitx/Sogou, Windows CJK; assert committed bytes and displayed cells. | +| [#5656](https://github.com/stablyai/orca/issues/5656) / [#5653](https://github.com/stablyai/orca/issues/5653) | Windows Claude Code prompt showed phantom/overwritten characters until resize | Rewrite-style terminal output did not reliably force visible row repaint for CR/CHA/erase updates. | [#6544](https://github.com/stablyai/orca/pull/6544), [#6449](https://github.com/stablyai/orca/pull/6449) | Terminal redraw fixture: feed CR, CHA, backspace, erase-line/screen and assert visible cells after each chunk without resize. | +| [#5161](https://github.com/stablyai/orca/issues/5161) | PowerShell terminal failed to spawn with Windows error code 5 | Windows shell resolver handed ConPTY a bare `pwsh.exe`, which could resolve to an App Execution Alias stub. | [#6537](https://github.com/stablyai/orca/pull/6537), [#6876](https://github.com/stablyai/orca/pull/6876) | Windows shell resolution contract: auto and explicit PowerShell selections must resolve to real executables and skip WindowsApps aliases. | +| [#6270](https://github.com/stablyai/orca/issues/6270) | Restoring agent sessions was not working for SSH/runtime use cases | Resume logic and runtime host restrictions did not cover SSH workspaces. | [#6685](https://github.com/stablyai/orca/pull/6685) | Agent resume provider matrix: local, daemon, SSH, remote runtime, Windows shell; assert resume command is queued on the configured host/provider. | +| [#6852](https://github.com/stablyai/orca/pull/6852) / [#6872](https://github.com/stablyai/orca/pull/6872) | Terminal search could crash terminal surface on narrow viewports | xterm search decorations received invalid dimensions and threw through the terminal surface. | [#6852](https://github.com/stablyai/orca/pull/6852), [#6872](https://github.com/stablyai/orca/pull/6872) | xterm addon safety test: narrow viewport, zero/near-zero columns, wrapped matches, keyboard next/previous; assert addon errors are caught and pane remains usable. | +| [#6855](https://github.com/stablyai/orca/pull/6855) | xterm web-link provider `RangeError` could kill the Windows renderer | Link provider ran synchronously inside xterm with no guard around pathological wrapped lines. | [#6855](https://github.com/stablyai/orca/pull/6855) | Addon boundary policy: all xterm addon callbacks are guarded and failure disables only that addon for that pane. | +| [#6853](https://github.com/stablyai/orca/pull/6853) | Split-right PTY stayed at `0x0` and rendered a white screen | Deferred spawn measured an unlaid-out pane; post-spawn reconcile could terminate before forwarding a usable size. | [#6853](https://github.com/stablyai/orca/pull/6853) | Split spawn invariant: visible panes must never keep PTY size `0x0`; fallback size must be forwarded if measurement is unavailable. | + +The repeated pattern is not one person repeatedly making the same mistake. It is that Orca does not yet have enough executable contracts around lifecycle, provider boundaries, terminal sizing, xterm safety, platform shell behavior, and upgrade state. + +## PR Process Evidence + +Reading the PR descriptions for the confirmed or likely regression chains shows that the failure was usually not "no process." The failure was that the process accepted plausible local validation without requiring the invariant that would have protected the escaped user path. + +### #6514: Dead-Session Reconcile Introduced a Newborn-Tab Hazard + +[PR #6514](https://github.com/stablyai/orca/pull/6514) was a Brennan-authored reliability fix for frozen terminal panes after a backgrounded agent exits. The PR body shows a substantial process: design review, completeness verification, headline behavior status, perf audit, code-review loop, Electron validation, and targeted unit tests. The stated non-goal explicitly excluded SSH/remote reconciliation, and the implementation revalidated pane/PTY identity at apply time. + +What still got through: + +- The PR protected "stale dead pane should close" but did not name the dual invariant "a fresh or newly bound pane cannot be closed by a liveness snapshot requested before that binding existed." +- The tests covered live/dead/remote/idempotency paths, but not the TOCTOU case where `listSessions()` is requested, a new PTY binds before the response returns, and the stale response omits the newborn id. +- Reviewers accepted "healthy panes are untouched" as a semantic claim, but the executable gate did not prove that claim under delayed session enumeration. + +Skill implication: + +- `brennan-yolo-lite`, `auto-design-review-fix`, `review-code`, and `brennan-test-changes` all need to force lifecycle changes to state the inverse safety property, not only the intended cleanup behavior. +- For terminal lifecycle changes, the review checklist should ask "what newer state can this async observation accidentally destroy?" + +### #5234 and #5240: Session-Restore Fixes Missed Old-Version Upgrade Fixtures + +[PR #5234](https://github.com/stablyai/orca/pull/5234) and [PR #5240](https://github.com/stablyai/orca/pull/5240) were Jinwoo-authored fixes for slow-startup daemon PTY restoration and quit-time agent session persistence. Their PR descriptions show AI review, negative controls, targeted tests, and high-quality e2e coverage against the newly modeled behavior. + +What still got through: + +- The tests proved the new behavior once the new data and startup gates existed. +- They did not appear to boot the current app against serialized persisted state from the affected production versions before the fix. +- The escaped class is an upgrade/migration class: a fix can be correct for state written after the fix while still losing users who upgrade from state written before it. + +Skill implication: + +- Startup, restore, daemon, and persistence PRs need an "old state corpus" gate: seed data from the last affected production version, not only state produced by current code. +- Negative controls should include "old production fixture fails before fix and is preserved after fix" when the bug is upgrade-related. + +### #1344: GPU Rendering Parity Missed Linux Wayland Input Failure + +[PR #1344](https://github.com/stablyai/orca/pull/1344) replaced global GPU opt-ins with VS Code-style GPU startup flags and terminal GPU acceleration settings. The PR description cites reference behavior and local tests, but it does not show the later, stricter Brennan PR process markers used in the June reliability fixes. + +What still got through: + +- The change removed or bypassed the Wayland-aware GPU safeguard later identified by [PR #6557](https://github.com/stablyai/orca/pull/6557). +- Validation covered configuration and pane lifecycle tests, but not a Linux Wayland terminal input smoke where rendering appears live while keyboard/scroll input is wedged. +- One TypeScript command was reported as failing on existing project-include issues, which made the PR less clean as a release-risk artifact. + +Skill implication: + +- Cross-platform rendering/startup changes need an explicit platform matrix tied to the failure mode: not just "renders," but "renders and accepts input." +- Reference implementations are useful context, but factory review must still require Orca-specific platform hazards to be named and tested or accepted. + +### Regression Lessons From Fresh Review + +Fresh reviewer passes on the recent regression chains pointed to these reusable misses. These are class-level gates, not new blame findings. + +- [#6514](https://github.com/stablyai/orca/pull/6514) -> [#6773](https://github.com/stablyai/orca/issues/6773), fixed by [#6796](https://github.com/stablyai/orca/pull/6796) and [#6801](https://github.com/stablyai/orca/pull/6801): the original PR correctly proved the forward cleanup case, where a hidden local/daemon PTY exits and should reconcile on resume while live listed panes survive. The missed invariant was the inverse lifecycle property: a destructive liveness snapshot can only close state that existed before the snapshot was requested. The escaped path was a macOS local new shell or agent tab where `listSessions()` was requested, a new PTY bound before the response returned, the stale response omitted the newborn id, and the renderer routed the healthy binding through exit teardown. The next gate is a Terminal Snapshot Freshness and Ownership Gate: a fake daemon holds `listSessions()` at `t0`, binds a newborn PTY at `t1`, resolves the stale `t0` snapshot without that PTY, and asserts the tab/pane survives, still owns the same PTY, accepts input, and renders output. A later post-bind snapshot may close a genuinely dead PTY, but only when provider, pane, PTY id, and binding epoch still match. +- [#6800](https://github.com/stablyai/orca/pull/6800), with [#5240](https://github.com/stablyai/orca/pull/5240), [#6411](https://github.com/stablyai/orca/pull/6411), [#6514](https://github.com/stablyai/orca/pull/6514), and [#6833](https://github.com/stablyai/orca/pull/6833) as related context: the original fix correctly proved that active worktree-sleep records should not be claimed merely because old tab/layout state exists; visible connecting panes with restorable identity can own wake, stale hidden panes fresh-resume, and completed hibernation evidence stays passive. The missed invariant was provider-session ownership, not "pane connects now." For each provider-session claim key, exactly one durable owner must exist: a live hook/status for that same provider session, a queued/sent resume command for that same provider session, or an explicit retained record because spawn/connect failed. Layout match, PTY wake hints, replayed terminal bytes, and cold-restored screen snapshots are display evidence, not ownership evidence. The next gate is an Agent Provider Session Ownership Matrix covering active focused tabs, inactive tabs, visible non-focused splits, live/quit/worktree-sleep origins, replay-only/cold-restore-only/wrong-session/no-hook/delayed-hook cases, and repeated workspace activation. +- [#5234](https://github.com/stablyai/orca/pull/5234) / [#5240](https://github.com/stablyai/orca/pull/5240) -> [#5356](https://github.com/stablyai/orca/issues/5356): the original PRs correctly proved slow-daemon restoration and newly written quit-origin provider-session persistence. The missed invariant was first-open-after-upgrade compatibility: startup/restore fixes must preserve or explicitly recover user sessions from the last affected production persisted-state schema, not only from state written by the fixed build. The escaped path was first launch after a 1.4.65 -> 1.4.68 upgrade, including Windows 10 active floating shell and Claude panes, where the old version had no `sleepingAgentSessionsByPaneKey` payload to consume. The next gate is a Persisted Session Upgrade/Restore Contract: boot current Orca against immutable user-data fixtures from the last affected version, including active and inactive tabs, visible and hidden splits, old tab-level PTY wake hints, missing `sleepingAgentSessionsByPaneKey`, live/quit/worktree-sleep/legacy origins, repeat activation, and a second restart after current code writes upgraded state. The oracle rejects blank replacement panes, duplicate resume tabs, silent session loss, and "works only after current code writes new state." +- [#1344](https://github.com/stablyai/orca/pull/1344) -> [#5319](https://github.com/stablyai/orca/issues/5319) / [#6557](https://github.com/stablyai/orca/pull/6557): the original PR correctly attempted to replace broad GPU opt-ins with VS Code-style GPU channel flags and prove terminal GPU/WebGL fallback at unit level. The missed invariant was terminal platform liveness, not visual readiness. A terminal is healthy only if it renders output, receives focus, delivers keyboard input to the correct PTY, echoes process output back to the xterm buffer, scrolls, and survives GPU/renderer startup on the target platform. The next gate is a Terminal Platform Liveness Gate across Linux Wayland GPU paths, headless Weston plus at least one scheduled/manual headful Wayland run, control Linux/macOS/Windows paths when renderer policy is shared, active and inactive tabs, visible and hidden panes, first mount, tab switch, repeated workspace activation, local PTY, and SSH/remote PTY. Screenshots alone never pass. +- [#6644](https://github.com/stablyai/orca/pull/6644) / [#6649](https://github.com/stablyai/orca/pull/6649) / [#6725](https://github.com/stablyai/orca/pull/6725) / [#6785](https://github.com/stablyai/orca/pull/6785) / [#6794](https://github.com/stablyai/orca/pull/6794) / [#6853](https://github.com/stablyai/orca/pull/6853) / [#6939](https://github.com/stablyai/orca/pull/6939): the fixes correctly proved pieces of the PTY geometry class: first-mount column desync, single-frame insufficiency, split-mount convergence, requested-size versus applied-size drift, mobile-owned PTY exceptions, visible split `0x0` fallback, and visible geometry reassertion after stable fit. The missed invariant was cross-layer terminal geometry authority. A visible desktop-owned terminal is not valid until xterm `cols/rows`, fit/proposed dimensions, applied PTY winsize, shell-visible `stty`/`stdout.columns`, and echo-wrap behavior converge after layout settles. Hidden/inactive panes may defer resize, and mobile/remote providers may intentionally diverge, but every transition into visible desktop ownership must converge. The next gate is a terminal geometry matrix with fault injection for first `0x0` fit, delayed layout settle, dropped/delayed resize acknowledgement, requested-size-versus-applied-size drift, SSH-style unknown applied size, hidden-to-visible activation, split create/collapse, restore, repeated workspace activation, and no redundant hidden SIGWINCH. +- [#6852](https://github.com/stablyai/orca/pull/6852) / [#6872](https://github.com/stablyai/orca/pull/6872) / [#6855](https://github.com/stablyai/orca/pull/6855), with [#6868](https://github.com/stablyai/orca/pull/6868), [#6856](https://github.com/stablyai/orca/pull/6856), and [#6857](https://github.com/stablyai/orca/pull/6857) as nearby WebGL/renderer-risk hardening: the fixes correctly moved toward safe wrappers for search dimensions, keyboard search navigation, link providers, WebGL addon patches, and renderer/GPU crash fallback. The missed invariant was xterm addon boundary containment. Optional addons are untrusted boundary code: search decorations, link providers, WebGL attach/reset/dispose, renderer fallback, hover callbacks, and stale async callbacks must not throw across the pane, React, or window boundary. The next gate is an xterm-addon-boundary harness that injects throwing addon behavior under active tabs, inactive tabs, visible and hidden splits, hidden-to-visible resume, floating tab switch, repeated workspace activation, and pane destroy/recreate; the oracle is pane-scoped addon disable/degrade with one breadcrumb, no React/window crash, and surviving focus, input, PTY echo, and rendering. + +Concrete gate shapes from the fresh passes: + +- **Snapshot freshness harness**: defer and reorder `listSessions()` / `listTerminals()` responses. Request a snapshot at `t0`, bind a new PTY at `t1`, resolve the `t0` response without the new PTY, and assert the pane survives with PTY id, tab ownership, and input/output proof. Then request a fresh `t2 > t1` snapshot for a genuinely killed PTY and assert teardown works. Run active/inactive tabs, visible/hidden splits, first-input probes, restore/reattach, repeat activation, and SSH/remote unknown-liveness variants. +- **Provider-session ownership matrix**: model provider-session ownership separately from terminal render output. For live, quit, active worktree-sleep, and completed worktree-sleep origins, prove each provider session is exactly one of owned, resuming, or intentionally retained. Cover active focused tabs, inactive hidden tabs, visible non-focused splits, delayed/missing/wrong hooks, duplicate legacy records, fresh-spawn failure, and repeat activation. Displayed replay must never count as ownership. +- **Old-version restore corpus**: keep immutable user-data fixtures from the last affected production versions, especially pre-#5232 session state. Boot current Orca against those fixtures and assert every persisted PTY/agent record is preserved, resumed once, warm-reattached once, or visibly marked unrecoverable. Include second restart after current Orca writes upgraded state. +- **Wayland platform liveness**: extend the Linux Wayland verifier so it proves terminal render, focus, keyboard input, PTY echo, scrollback movement, and GPU crash/stall absence across active/inactive tabs, visible/hidden splits, first mount, tab switch, restore, repeated activation, local PTY, and SSH/remote runtime. Screenshot-only evidence is not enough. +- **Terminal geometry convergence**: add an `assertTerminalGeometryConverged` oracle that compares xterm cols/rows, fit/proposed dimensions, applied PTY size when available, shell-visible `stty size` / `$COLUMNS`, and deterministic echo/wrap behavior. Inject dropped resize, delayed resize until visible, stale applied size, null SSH-style readback, initial `0x0`, and layout settling after more frames than old fixed budgets. +- **xterm addon boundary harness**: inject throwing search decorations, link providers, WebGL attach/reset/dispose, and stale async callbacks under active tab, inactive tab, visible split, hidden split, hidden-to-visible resume, floating tab switch, repeat activation, and pane destroy/recreate. Assert no `window.onerror` or React boundary hit, one structured breadcrumb, addon-scoped degradation, focus/input survival, PTY/output rendering, and WebGL fallback to DOM when needed. + +### Corrective PRs Show the Desired Direction + +The later fixes, especially [#6801](https://github.com/stablyai/orca/pull/6801), show the right shape: root-cause description, happens-before proof, explicit `auto-design-review-fix`, completeness verification, code-review loop, `brennan-test-changes`, and targeted tests for the stale snapshot/newborn binding race. [#6852](https://github.com/stablyai/orca/pull/6852), [#6853](https://github.com/stablyai/orca/pull/6853), and [#6855](https://github.com/stablyai/orca/pull/6855) also show good local invariant tests around xterm addon throws and PTY `0x0` sizing. + +The process improvement is to make that shape mandatory before the regression escapes, and to promote only the deterministic, red/green parts into blocking CI. + +## Core Pain Points + +### 1. Terminal Lifecycle Is Inferred Instead of Owned + +Many paths infer terminal state from a combination of pane visibility, provider session membership, stored pane layout, active leaf ids, sleeping-agent records, and xterm mount state. That makes it easy for a stale observation to tear down a fresh pane, or for a dead pane to look live. + +Common symptoms: + +- New tab opens and immediately closes. +- Hidden terminal exits but the visible pane later looks frozen and swallows input. +- Sleeping or hibernated agents get stranded in stale panes. +- Mobile and desktop disagree about which terminal/session still exists. + +### 2. PTY Size Authority Is Split Across Too Many Places + +`FitAddon`, `ResizeObserver`, deferred `requestAnimationFrame`, mobile-fit locks, hidden-pane guards, PTY locks, split layout equalization, and provider resize forwarding all compete to decide the active terminal size. + +Common symptoms: + +- PTY born at `0x0`. +- White split pane. +- First-mount column desync. +- TUI output wraps at the wrong width until manual resize. +- Mobile-size terminals fight desktop-size restoration. + +### 3. xterm Addons Are Unsafe Boundary Code + +The terminal surface trusts addon behavior too much. Recent failures came from search decorations, web-link providers, WebGL texture atlas resets, CJK/wide glyph rendering, and synchronized-output buffering. + +Any addon that can throw synchronously or produce invalid dimensions can take down the workbench unless Orca wraps it defensively. + +### 4. Provider Semantics Drift + +Local PTY, daemon PTY, SSH relay, remote runtime, WSL, Windows ConPTY, Linux Wayland, and mobile sessions do not share the same truth source for liveness, size, replay, cwd, shell, or reconnect behavior. + +Common symptoms: + +- Local-only liveness checks are accidentally applied to SSH/remote panes. +- Remote paths miss fixes made only in the renderer. +- WSL works through CLI add but not UI add. +- Daemon can be protocol-healthy while PTY spawn is degraded. + +### 5. Agent Identity Is Coupled to Terminal UI Details + +Agent status, sidebar rows, compact rows, mobile session tabs, sleeping-agent records, terminal helper identity, and pane reuse all share responsibility for "what agent is this?" + +Common symptoms: + +- Duplicate agent tabs. +- Stale sidebar rows after terminal close. +- Wrong agent icon/title. +- False running/completed notifications. +- Worktree switching changes agent ordering or ownership. + +Class-level invariant: + +- Agent/provider session replay must be ownership-safe. When code touches agent launch, restore, sleep, hibernate, dedupe, clearing, or workspace activation, it must prove Orca does not replay or resume a provider session id that is already owned, queued, pending, or live in that workspace. This applies across active and inactive tabs, visible and hidden panes, repeat activation of the same workspace, and session records whose origin is live, quit, or worktree sleep. +- Motivating example: the #6800 escape showed how code can conflate "this pane will connect right now" with "this pane/session owns this provider session." A valid inactive or hidden existing session can then look unowned, causing workspace activation to replay `agent resume ` instead of recognizing the session is already represented. The gate should protect the whole class, not only the #6800 path. +- Display evidence is not ownership evidence. Replayed terminal bytes, cold-restored screen snapshots, tab layout matches, and "will connect soon" state must never count as proof that a provider session id is already claimed. + +### 6. Upgrade and Startup Are Under-Tested as First-Class Flows + +Several failures only appear after an app update, daemon survival across versions, old persisted state, stale helper paths, or a first launch after migration. + +Common symptoms: + +- First launch after upgrade loses sessions. +- Daemon remains reachable but cannot spawn PTYs. +- App process runs but no window appears. +- Release packaging/notarization works at top level but fails for a nested binary. + +### 7. Reliability Fixes Can Regress Performance or Crash Safety + +Several reliability fixes add watchers, reconciliation loops, terminal replay, git scans, or richer diagnostics. Those are high-risk performance surfaces if they wake hidden panes, serialize large state, multiply subprocesses, or make cleanup scans wait for final results before showing progress. + +Common symptoms: + +- Terminal input becomes correct but slower under foreground TUI redraw or background PTY output. +- Hidden panes keep waking the renderer for data/title/spinner frames. +- Store subscribers or persistence writes serialize large terminal/worktree maps on common interactions. +- Git/worktree cleanup opens a modal that spins, duplicates rows, hangs on a subprocess, or crashes on Windows/WSL/SSH paths. +- Safety checks run unbounded git subprocesses or trust stale cleanup evidence before destructive removal. + +## Proposed Improvements + +### A. Build a Terminal Lifecycle State Machine + +Create a single source of truth for terminal lifecycle: + +- `newborn` +- `connecting` +- `live` +- `hidden-live` +- `exited-observed` +- `exited-unobserved` +- `hibernated` +- `restoring` +- `degraded` +- `closed` + +All teardown, restore, reconnect, hibernation, and liveness decisions should transition through this machine. Avoid having individual React effects independently infer lifecycle from partial state. + +### B. Make Liveness Reconciliation Epoch-Based + +Every pane/PTY binding should carry a monotonic renderer-local epoch or generation. Any async liveness snapshot must include the epoch it was requested against. + +Rules: + +- A snapshot requested before a bind cannot close that bind. +- A provider membership result can only prove death when the current binding still exactly matches the request context: pane id, provider id, connection id, PTY id, and epoch. +- Remote/SSH/WSL providers must supply provider-scoped liveness, or the reconciler must treat them as unknown. + +This generalizes the timestamp guard added for newborn terminal tabs and makes it harder to reintroduce the class. + +### C. Define a PTY Size Ownership Protocol + +Document and enforce one protocol for size authority: + +- Initial spawn size source. +- Hidden-pane spawn behavior. +- Mobile-fit lock behavior. +- Split layout settle behavior. +- Fallback size behavior. +- Provider resize acknowledgement. +- Applied PTY size reporting. +- Dropped, delayed, or skipped resize recovery. +- Hand-off from reconcile loop to live `ResizeObserver`. + +Add invariant tests that a visible terminal never remains `0x0`, and that xterm `cols/rows` converge with PTY-reported `cols/rows` under split, restore, reload, and mobile-fit scenarios. + +Class-level invariant: + +- A visible terminal pane must have one authoritative size path from layout measurement to xterm fit to provider resize to applied PTY size. `0x0` and unknown layout measurements are non-authoritative for visible panes. If a resize is skipped because of a mobile lock, PTY lock, hidden state, split layout settle, first mount, provider delay, or dropped acknowledgement, Orca must keep reconciling until the applied PTY size and xterm size converge or the pane enters an explicit degraded state. + +Required deterministic hooks: + +- Force first measurement to `0x0` during split-right spawn. +- Delay split layout settlement across frames. +- Hold and release mobile-fit or PTY locks while reconcile is active. +- Drop or delay one provider resize acknowledgement. +- Report applied PTY size separately from requested size. +- Switch hidden-to-visible or worktree-to-worktree while a resize is pending. + +Required oracle: + +- After the fault, assert visible xterm `cols/rows`, provider-reported applied PTY `cols/rows`, and process-observed `stdout.columns/rows` converge. +- Assert a width-sensitive TUI or long-line fixture renders without one-column wrapping. +- Assert typed input reaches the process and echoed output appears after convergence. +- Assert no test is promoted to blocking if it relies on blind `waitForTimeout()` instead of size/lifecycle events or controlled fault release. + +### D. Add Provider Contract Tests + +Start with a deterministic fake/fault-injection provider, then run the same lifecycle contract against real providers. + +The fake provider should be able to: + +- Delay and reorder `listSessions()` responses. +- Return `unknown` separately from empty/live/dead. +- Delay attach/replay and resize acknowledgements. +- Kill processes while panes are hidden. +- Drop or duplicate exit/replay/resize events. +- Simulate stale cwd/helper paths. + +After the fake provider is stable, run the contract against: + +- local PTY +- daemon PTY +- degraded daemon wrapper +- SSH relay +- remote runtime +- WSL path/shell combinations +- mobile session replay + +Required scenarios: + +- spawn +- hidden spawn +- visible restore +- hidden exit +- reconnect +- resize before/after mount +- cwd deleted +- helper binary moved after upgrade +- process dies while renderer is hidden + +### E. Create a Terminal Torture Harness + +Add a nightly and pre-release reliability suite that exercises: + +- rapid tab create/close +- agent create/close +- split right/down +- reload with split layout +- hidden-to-visible switching +- huge streaming TUI output +- CJK, Vietnamese, Arabic, and emoji input/output +- long paste, link paste, image paste +- browser/terminal focus handoffs +- hibernation wake +- daemon restart/degraded fallback +- mobile connect/disconnect during active terminal traffic + +This should record screenshots, terminal snapshots, PTY sizes, lifecycle transitions, and renderer CPU. + +Each scenario must also declare its oracle before it can be promoted beyond `experimental`: the observable pass condition, the failure artifact, and whether it is a discovery/stress run or a merge-blocking gate. Stress scenarios without deterministic oracles should stay nightly/pre-release only. + +### F. Wrap xterm Addons Behind Safe Adapters + +Search, links, WebGL, decorations, and input-protocol helpers should be treated as untrusted code: + +- Catch synchronous addon throws. +- Validate all rows/cols/decoration dimensions before calling xterm APIs. +- Disable or degrade an addon for a pane after repeated failures. +- Emit a structured breadcrumb with pane id, provider, terminal size, addon name, and sanitized error. + +Class-level invariant: + +- xterm addon failures must be contained to the addon and pane. A search, link, WebGL, decoration, input-protocol, or keyboard-navigation error must never unmount the terminal surface, crash the renderer, break focus, or stop typed input/output for the PTY. Invalid dimensions, wrapped-line ranges, zero columns, texture atlas resets, and synchronous callback throws should either be validated away before the addon call or caught and downgraded after the call. + +Required deterministic hooks: + +- Force zero or near-zero terminal columns. +- Force wrapped search and link ranges across line boundaries. +- Throw from search, link, decoration, and WebGL callback boundaries. +- Simulate WebGL texture/atlas reset or addon initialization failure. +- Invoke next/previous search navigation while the search addon reports an invalid match. + +Required oracle: + +- The terminal pane remains mounted and focused. +- The failing addon is disabled or downgraded only for the affected pane. +- A structured breadcrumb names the addon and sanitized failure. +- Typed input reaches the process and echoed output appears after the addon failure. + +### G. Add Upgrade Fixtures + +Keep serialized workspaces and daemon/session state from known old versions: + +- pre-#5232 session restore data +- stale daemon cwd +- stale node-pty helper path +- old hibernated agent records +- old mobile session snapshots +- old Windows shell settings + +CI should boot the current app against these fixtures and assert no session loss, no blank first pane, and no startup deadlock. + +Fixture rules: + +- Name the exact production version and build source. +- Store the pre-migration state immutably and copy it to a temp profile before boot. +- Document the expected pre-migration shape and post-migration behavior. +- Include stale daemon/helper/path state when that is the real user upgrade path. +- Treat silent blank replacement as a failure. If exact restoration is impossible, the accepted outcome must be an explicit recoverable/degraded state or user-facing migration notice, not silent session loss. + +### H. Add Platform Startup, Input, and Release Artifact Contracts + +Some failures look visually healthy while the product is broken. Platform contracts must prove the user action, not only the frame. + +Required contracts: + +- Terminal rendering/input: on Linux Wayland, Windows ConPTY, and macOS, assert the terminal renders, accepts typed input, echoes process output, scrolls, and preserves focus after GPU/renderer startup paths touched by the PR. +- IME/input: when terminal input handling changes, assert representative CJK, Vietnamese, Arabic, paste, and modifier paths at the lowest deterministic layer available. +- Startup/window lifecycle: packaged app reveals, close button and Alt+F4 work on Windows, startup watchdogs recover when `ready-to-show` stalls, and PATH/shell probes time out with a recoverable state. +- Release artifact: packaged builds include required nested binaries and pass signing/notarization checks, not only top-level app validation. +- Daemon degradation: daemon reachable-but-unable-to-spawn and stale helper/cwd states produce a visible degraded path instead of blank or frozen terminals. + +### I. Add an Agent Identity Contract + +Provider and terminal tests are not enough for agent/session identity drift. Add a contract for: + +- Pane reuse and tab close. +- Worktree switch and sidebar ordering. +- Sleep/wake and hibernation records. +- Provider session id capture and consumption. +- Mobile rows and desktop rows converging after terminal close. +- False running/completed notifications. +- Agent history/filter visibility for restored sessions. +- Provider session replay ownership: workspace activation, launch, restore, sleep/hibernate, dedupe, clearing, and reconnect code must not issue a second resume/launch for a provider session id already owned by an active tab, inactive tab, visible pane, hidden pane, queued pane, pending connection, or live session in the same workspace. +- Session record origins: the same invariant must be proved for live sessions, quit-restored sessions, and worktree-sleep records, including repeat activation of an already-active workspace. + +Required agent identity scenarios: + +- Activate a workspace with an existing visible live agent pane; assert no duplicate `resume ` is queued. +- Activate a workspace with an existing hidden/inactive live agent pane; assert the provider session is recognized as represented even before the pane visibly reconnects. +- Re-activate the same workspace repeatedly while agent restore/session discovery is pending; assert the pending ownership claim dedupes all replay attempts. +- Restore a quit-origin session and a worktree-sleep-origin session; assert each is claimed once, survives hidden-to-visible transitions, and does not produce duplicate sidebar/mobile rows. +- Clear or close stale panes while a same-session replacement is queued or pending; assert cleanup cannot erase the newer ownership claim or trigger a second replay. + +### J. Introduce Reliability Gates for Terminal/Session PRs + +For PRs touching terminal, agent status, sessions, startup, provider routing, or hibernation, require: + +- Provider matrix explicitly listed. +- Hidden-pane behavior listed. +- SSH/remote behavior listed. +- Windows/WSL behavior listed when shell, cwd, path, ConPTY, or process launch is touched. +- A regression test that fails against the old behavior. +- A manual or automated stress command if the change touches timing. +- PTY size authority proof when the PR touches terminal spawn, split layout, hidden/visible transitions, mobile fit, resize forwarding, PTY locks, provider resize acknowledgement, or xterm fit. The proof must cover `0x0` quarantine, requested versus applied PTY size, xterm/process size convergence, and a user-visible input/output oracle. +- Agent/provider replay ownership proof when the PR touches agent launch, restore, sleep/hibernate, dedupe, clearing, workspace activation, pane reuse, provider session id capture, sidebar/mobile agent rows, or session history. The proof must cover active versus inactive tabs, visible versus hidden panes, repeat workspace activation, and live, quit, and worktree-sleep session record origins. +- xterm addon containment proof when the PR touches search, links, WebGL, decorations, keyboard terminal shortcuts, synchronized output, or xterm addon registration. The proof must show addon throws and invalid geometry cannot crash the terminal surface, unmount the pane, or break typed input/output. +- The terminal/session checklist should explicitly ask: "Can this change make an already represented provider session look unowned, or replay/resume it a second time?" +- The terminal/session checklist should explicitly ask: "Can this change make a visible pane trust `0x0`, stale requested size, or renderer-only size instead of the size the PTY actually applied?" +- The terminal/session checklist should explicitly ask: "Can an xterm addon callback or invalid addon geometry throw through the terminal surface instead of being contained?" + +### K. Make Reliability Tests Predictable and Useful + +The goal is not "more tests." The goal is a small set of executable contracts that protect named invariants from real failures. + +Required shape for every new reliability gate: + +- Name the invariant it protects. +- Name the real issue, PR, or failure mode that motivated it. +- Prove red/green: the gate must fail against the old behavior, a regression fixture, or a small intentionally broken variant before it becomes a trusted blocker. +- Attach a promotion record: invariant id, motivating issue/PR, old-fail evidence, new-pass evidence, exact command, failure output or CI link, deterministic wait condition, owner, runtime budget, and demotion rule. +- Pick the cheapest deterministic layer: pure state-machine or provider-contract tests first, integration tests second, Electron/UI tests only for the small number of golden paths that require the real app shell. +- Avoid sleeps as proof. Wait for observable state, protocol events, lifecycle transitions, PTY size convergence, replay completion, or process exit. +- Record why the test is allowed to block merges: expected runtime, flake history, covered invariant, and owner. + +Test maturity levels: + +- `experimental`: local or nightly only; useful for shaking out the harness, not allowed to block merges. +- `soak`: runs repeatedly in CI but non-blocking; collects flake rate, runtime, and whether failures are actionable. +- `blocking`: promoted only after red/green proof, stable runtime, and low flake rate. + +Flake policy: + +- Blocking promotion should require numeric evidence, for example 100 consecutive soak runs or 14 days across required CI platforms, p95 runtime within the gate budget, and zero unexplained flakes. Tune the numbers later, but do not leave "stable" undefined. +- A blocking reliability test that flakes above the agreed threshold should be demoted or fixed quickly; rerun-only culture hides weak gates. +- CI should quarantine or demote a blocking test automatically when it exceeds the threshold over the configured window, open or update an owner issue, and require a fresh soak window before re-promotion. +- A flaky test should be treated as a product or harness bug with an owner, not as background noise. +- If a scenario cannot be made deterministic yet, keep it as soak/manual evidence and do not let it become required CI until the signal is trustworthy. + +Deletion policy: + +- Delete or demote tests that do not map to an invariant, duplicate a stronger gate, assert implementation details instead of behavior, only fail from timing/environment noise, or require blind sleeps. +- Prefer one high-signal contract test over several UI tests that all exercise the same happy path. +- Track regression coverage by invariant, not by raw test count. +- Maintain a reliability-gates manifest in the repo. Each gate should list invariant id, motivating issue/PR, layer, owner, maturity, runtime budget, flake history link, promotion record, and replacement gate if deprecated. Tests without current metadata should fail review or be demoted. + +How to know the program is working: + +- Every recent P0/P1 reliability class maps to an invariant and an executable gate or explicitly accepted gap. +- New gates carry red/green evidence. +- Blocking gate flake rate and runtime stay within budget. +- Future incidents identify either a missing invariant, a failed gate, or an explicit accepted gap instead of requiring archaeology. +- Accepted gaps have an owner, reason, affected platforms/providers, issue link, and expiry date. Expired gaps should block future high-risk PRs until renewed or closed. +- The dashboard tracks touched high-risk PRs, gates required, gates implemented, accepted gaps by age/owner, promotions, demotions, and escaped regressions by invariant. + +### L. Make Performance and Git Crash Safety First-Class Gates + +Correctness gates must include a performance budget when the fix touches terminal throughput, hidden panes, store subscribers, persistence, startup, polling, git/worktree scans, cleanup modals, subprocesses, SSH/WSL providers, or Windows git paths. + +Required performance contract: + +- Name the user-visible performance symptom that could regress: typing latency, scroll/resize jank, terminal throughput, startup delay, modal loading, CPU, memory, subprocess churn, or crash/reload. +- Name the resource being protected: renderer frame time, event-loop delay, PTY IPC count, xterm writes, serialized bytes, subprocess count, file descriptors, WebGL contexts, webviews, or retained buffers. +- Add a before/after measurement or deterministic count/leak test. Do not rely on "felt faster." +- Keep foreground input echo and control responses latency-sensitive while batching background or high-volume redraw streams. +- Prove hidden snapshot-capable panes do not wake the renderer per PTY chunk/title frame and restore from main-owned buffers when shown. +- Preserve `orca terminal read` pagination, cursor metadata, bounded preview behavior, partial-line handling, truncation flags, and total counts when changing terminal buffering. +- Prove subscriber, persistence, and publication paths avoid unchanged large slices and record serialized byte size when persisted payloads are touched. +- Keep stress/torture runs non-blocking until they have numeric runtime and flake evidence. + +Required git crash/perf contract: + +- Bound every git subprocess in scan/cleanup paths with a timeout or abort signal, and kill the process tree where applicable. +- Use bounded concurrency and in-flight dedupe; avoid one subprocess per repo x worktree x polling tick. +- Stream progress incrementally to the renderer and remove IPC listeners in `finally`. +- Verify close/reopen/refresh during active or just-settled scans clears loading state, preserves rows, and does not duplicate progress. +- Recheck cleanup candidates immediately before destructive delete, including dirty files, unpushed commits, unknown upstream/base, folder repos, main worktrees, pinned workspaces, running terminals, disconnected SSH, and WSL paths. +- Keep local, WSL, and SSH git providers at parity for timeouts, aborts, errors, and path handling. +- On Windows-risky cleanup changes, validate a real Electron flow when feasible: progress appears before final completion, close/reopen recovers, a clean old worktree delete does not crash, and no orphan Electron/git process remains. + +Promotion rule: + +- A reliability fix cannot become a blocking gate if it makes the product slower or crashier. The promotion record must include the perf command or measurement, budget, result, platform/provider scope, and any accepted perf gap. + +### M. Make the Factory Enforce Those Gates + +The factory should turn the reliability gate into default behavior for planning, review, and merge-confidence testing. This should be treated as part of the prevention plan, not as a substitute for the actual test harnesses and CI gates above. + +Factory changes to carry forward: + +- `brennan-yolo-lite` should require a Reliability regression gate in the design doc whenever terminal, tab, session, provider, startup, upgrade, persistence, packaging, shell, path, or release behavior is touched. +- The gate should force the agent to classify touched P0-capable surfaces: terminal lifecycle, tab creation/binding, dead-session reconciliation, hidden-to-visible resume, PTY identity/routing, PTY geometry including `0x0` quarantine, xterm addon boundaries, IME/paste/redraw behavior, local/daemon/SSH/remote/WSL/mobile provider contracts, attach/replay/resize/exit ordering, startup/upgrade persisted state, daemon survival, renderer/GPU crash containment, packaging/notarization, shell/PATH probing, terminal throughput, hidden-pane output, store subscriber hot paths, persistence payload size, git/worktree scan subprocess churn, cleanup modal progress, and Windows/WSL/SSH git crash risk. +- Completeness verification should block when a touched P0-capable surface has no implemented or explicitly accepted reliability gate. +- `review-code` should treat missing reliability tests or validation gates as findings for these surfaces, with the same review weight as lifecycle, provider, or cross-platform regressions. +- `review-code` should invoke the `perf` skill for slow UI, terminal throughput, high CPU, memory/resource leaks, polling, subprocess churn, startup latency, render churn, or persistence writes. +- `review-code` should invoke the `git-crash-perf` skill for worktree cleanup, git scans/status/listing, destructive removal, cleanup modal progress, SSH/WSL git providers, subprocess timeouts, or Windows git crash risk. +- `brennan-test-changes` should run or explicitly justify the relevant terminal torture, provider contract, startup/upgrade, persisted-state, release, cross-platform, perf, or git crash/perf matrix subset. + +Current implementation status: + +- Internal factory skill update PR: https://github.com/stablyai/orca-internal/pull/491 +- Scope of that PR: add the reliability gate to `brennan-yolo-lite` and expand `review-code` to inspect the recent terminal/session/provider/startup/release failure classes. +- Follow-up needed: once the concrete repo-side harnesses exist, update the skills again to name the exact commands and required CI jobs instead of only naming the expected reliability evidence. + +Additional factory follow-up: + +- `auto-design-review-fix`: treat missing reliability gates for P0-capable surfaces as design findings; require inverse invariants for cleanup/reconcile/init code. +- `review-code-fix-loop`: require the final loop report to name the touched reliability classes, invariant, gate, and whether both reviewers addressed them. A clean review that never discusses the required reliability class should not count as clean. +- `brennan-test-changes`: add merge-confidence sub-gates for stale snapshot/newborn binding races, hidden-to-visible resume, attach/replay/exit ordering, old-version persisted state, provider/platform matrices, and render-live/input-live checks. +- `pr-review`: classify P0-capable surfaces before review/fix and require the same invariant/test/platform matrix before approval. +- `yolo-lite` and `electron`: require terminal/runtime validation to prove input, scroll, focus, process output, and relevant platform behavior, not only screenshots or visible frames. +- `playwright-reliability-tests`: add a focused skill for writing and reviewing Playwright tests that are evidence-based, low-flake, and tied to named invariants, so browser/Electron tests do not become more noisy checklist coverage. +- `perf`: require measurement-backed review for terminal throughput, hidden pane output, store subscribers, persistence, startup, polling, subprocess churn, resource leaks, and memory growth. +- `git-crash-perf`: require bounded subprocesses, progress streaming, close/reopen recovery, fresh delete preflight, provider parity, and Windows Electron validation for git cleanup/worktree changes. + +### N. Add Pane Lifecycle Telemetry and Diagnostics + +Each terminal pane should be able to emit a compact lifecycle trace: + +- pane id, tab id, worktree id +- provider and connection id +- pty id and binding epoch +- visibility transitions +- spawn/connect timestamps +- resize measurements and forwarded sizes +- liveness snapshot request/response times +- teardown reason +- hibernation/restore reason + +This will make future reports much easier to attribute without guessing. It should also be test-consumable: CI should be able to fail on forbidden transitions, stale close attempts, stuck `0x0`, missing input echo, and startup watchdog expiry. + +## Attribution Methodology + +"Who caused it?" is not reliably answerable from labels alone. This review separates: + +- **Confirmed**: the issue body or fix PR explicitly names the introducing PR/change. +- **Likely**: the fix PR identifies the failing subsystem and local git history shows the regressing mechanism was introduced by a specific PR. +- **Area ownership**: the same people are repeatedly fixing or modifying the subsystem, but there is not enough evidence to say they caused a given issue. +- **Systemic**: no single introducer is evident; the problem is a missing invariant, provider contract, or test harness. + +## Attribution Findings + +### Confirmed Regression Chains + +- [#5356](https://github.com/stablyai/orca/issues/5356), session loss on first launch after upgrading from 1.4.65 to 1.4.68, explicitly names PRs [#5234](https://github.com/stablyai/orca/pull/5234) and [#5240](https://github.com/stablyai/orca/pull/5240). Local git history shows both PRs were authored by Jinwoo Hong. Count: Jinwoo 1 confirmed open `P0` regression chain. +- [#5319](https://github.com/stablyai/orca/issues/5319), Linux/Wayland terminal input freeze, is closed, but the fixing PR [#6557](https://github.com/stablyai/orca/pull/6557) says the Wayland-aware GPU path had been removed in [#1344](https://github.com/stablyai/orca/pull/1344). Local git history shows #1344 was authored by Neil. Count: Neil 1 confirmed closed `P1` regression chain. + +### Likely Regression Chains + +- [#6773](https://github.com/stablyai/orca/issues/6773), new terminal/agent tabs opening and immediately closing, was fixed by [#6796](https://github.com/stablyai/orca/pull/6796) and [#6801](https://github.com/stablyai/orca/pull/6801). Those PRs identify the dead-session reconciler as the cause. Local git history shows [#6514](https://github.com/stablyai/orca/pull/6514), which introduced the focused dead-session reconcile module for hidden exited panes, was authored by Brennan Benson. Count: Brennan 1 likely open `P0` regression chain. + +### Area Ownership Without Clear Causation + +The recent terminal reliability churn is concentrated among Brennan, Neil, and Jinwoo because they authored many of the fixes and nearby changes. That does not mean each issue was caused by them. It does mean the riskiest ownership surfaces are: + +- Terminal lifecycle/reconcile/session persistence: Brennan, Neil, Jinwoo. +- PTY size/layout/reflow: Neil, Jinjing, Jinwoo, Brennan. +- Daemon/degraded spawn/focus/remote lifecycle: Neil, Jinwoo. +- Windows terminal/shell/ConPTY/GPU recovery: Brennan, Neil, Jinwoo. +- Agent status/sidebar/session identity: Brennan, Neil, Jinwoo, Jinjing. + +### Current Conservative Count + +For currently open `P0`/`P1` issues with a clear or likely PR-caused chain: + +- Jinwoo: 1 confirmed open `P0` regression chain. +- Brennan: 1 likely open `P0` regression chain. +- Neil: 0 confirmed open `P0`/`P1` chains from the evidence pulled, plus 1 confirmed closed `P1` chain. + +Most open `P0`/`P1` issues do not identify an introducing PR. Treating those as individual blame would be misleading. The stronger conclusion is that Orca has systemic reliability gaps around terminal/session lifecycle, provider contracts, PTY sizing, and upgrade/startup flows. + +## Recommended Next Steps + +Implementation plan: [`docs/reference/reliability-gates-implementation-plan.md`](./reliability-gates-implementation-plan.md). + +1. Deduplicate and update `P0` issues #6873/#6874 and #6773 with the suspected fix status. +2. Add narrow red/green gates for known escapes first: newborn liveness race, PTY `0x0`, xterm addon throw containment, old-version upgrade fixture, and Linux Wayland render-live/input-live smoke. +3. Define test maturity levels, promotion records, numeric flake/demotion policy, and a reliability-gates manifest before making new reliability gates blocking. +4. Build the fake/fault-injection provider contract kit, then run the same contract against local and daemon PTY before expanding to SSH, WSL, remote runtime, and mobile. +5. Add upgrade/startup/release fixtures for pre-#5232 session data, stale daemon/helper paths, packaged app reveal/close, nested notarization, PATH probe timeout, and Windows terminal activation. +6. Add the terminal lifecycle state machine design doc and implement it incrementally behind the known escaped-regression gates. +7. Formalize the PTY size protocol and make `0x0` a quarantined/non-authoritative state for visible panes. +8. Add the agent identity contract for pane reuse, worktree switch, sidebar/mobile rows, provider session ids, stale notifications, and ownership-safe provider session replay across active/inactive tabs, visible/hidden panes, repeated activation, and live/quit/worktree-sleep records. +9. Add performance budgets and measurements to reliability gates that touch terminal throughput, hidden panes, store subscribers, persistence, startup, polling, subprocess churn, git/worktree scans, cleanup modals, SSH/WSL git providers, or Windows git crash risk. +10. Gate terminal/session PRs behind the reliability checklist above, including perf and git crash/perf checks when those surfaces are touched. +11. Keep the factory skill update as the enforcement layer, add the Playwright reliability-testing skill, and make `perf` plus `git-crash-perf` mandatory companion skills for relevant PRs; then revise companion skills once exact repo commands and CI job names exist. +12. Add pane lifecycle traces so future issues can be attributed from evidence instead of archaeology. diff --git a/docs/reference/terminal-switch-typing-lag-investigation.md b/docs/reference/terminal-switch-typing-lag-investigation.md new file mode 100644 index 00000000000..d8f6da5a608 --- /dev/null +++ b/docs/reference/terminal-switch-typing-lag-investigation.md @@ -0,0 +1,362 @@ +# Terminal Switch Typing Lag Investigation + +Date: 2026-07-01 + +## Scope + +This note tracks the investigation into terminal input lag that appears after switching workspaces or terminals in a heavy packaged Orca profile. + +The user-visible symptom is that typing after switching back to a workspace can feel delayed for about one second. Text may appear all at once after the delay. The issue reproduced in the user's main packaged Orca app, but not reliably in lighter dev profiles. + +## Current Reproduction Setup + +- Main packaged Orca app is used for the meaningful repro. +- The main profile had roughly 150 terminals when the lag reproduced. +- The current worktree under test is `/Users/jinwoohong/orca/workspaces/orca/osprey`. +- The main app did not expose CDP, so browser-level renderer profiling was limited. +- The harness creates a throwaway bash terminal, switches away and back, sends a marker command, waits for a receipt file, and closes the throwaway terminal. +- Harness path: `.tmp/terminal-main-app-typing-lag/probe.mjs` +- The strongest repro switches through an old, output-rich workspace before typing in the probe terminal. +- Current deterministic alternate terminal: + `term_4d7a0b50-e9ae-420e-ac5d-7ec12cbfa408` in the `triage-issues` workspace. + +Useful harness modes: + +```sh +ORCA_PROBE_RUNS=8 ORCA_PROBE_FOCUS_EACH_RUN=0 ORCA_PROBE_TYPE_MODE=terminal-send ORCA_PROBE_RECEIPT_MODE=file node .tmp/terminal-main-app-typing-lag/probe.mjs +ORCA_PROBE_RUNS=8 ORCA_PROBE_FOCUS_EACH_RUN=0 ORCA_PROBE_TYPE_MODE=daemon-direct-request ORCA_PROBE_RECEIPT_MODE=file node .tmp/terminal-main-app-typing-lag/probe.mjs +ORCA_PROBE_RUNS=8 ORCA_PROBE_SKIP_SWITCH=1 ORCA_PROBE_FOCUS_EACH_RUN=0 ORCA_PROBE_TYPE_MODE=daemon-direct-request ORCA_PROBE_RECEIPT_MODE=file node .tmp/terminal-main-app-typing-lag/probe.mjs +ORCA_PROBE_RUNS=4 ORCA_PROBE_FOCUS_EACH_RUN=0 ORCA_PROBE_SKIP_FOCUS=1 ORCA_PROBE_SAMPLE_RENDERER=0 ORCA_PROBE_ALT_TERMINAL=term_4d7a0b50-e9ae-420e-ac5d-7ec12cbfa408 ORCA_PROBE_TYPE_MODE=daemon-direct-request ORCA_PROBE_RECEIPT_MODE=file node .tmp/terminal-main-app-typing-lag/probe.mjs +``` + +## Key Measurements + +### 2026-07-01 follow-up: cold visit vs warm revisit + +After the metadata-only `listSessions()` fix, the user reported a sharper +pattern: + +- First visit to a terminal, where the terminal briefly shows blank while the + renderer/webgl surface loads, does not show the typing lag. +- Revisiting that same already-mounted terminal brings the lag back. + +Code-level reproduction: + +- `connectPanePty` previously armed a one-shot input liveness check from + `noteVisibilityResume()`. +- The first `xterm.onData` input after a warm visibility resume called + `window.api.pty.listSessions()` before forwarding the byte with + `transport.sendInput(data)`. +- A focused unit regression captured the bad order as + `["listSessions", "sendInput"]`. + +Fix direction: + +- Terminal input no longer starts a renderer→main→daemon session enumeration. +- Hidden→visible lifecycle reconciliation and daemon missing-session exit events + remain responsible for stale pane cleanup. +- The focused regression now asserts that repeated warm resumes plus typing + produce zero `listSessions()` calls from the input handler. + +### 2026-07-01 follow-up: preserved old daemon after input fix + +After the input-handler fix, the main packaged profile still reproduced the +warm-switch lag because the live v18 daemon was preserved from an older build: + +- Fresh main-profile repro: + `.tmp/terminal-main-app-typing-lag/result-2026-07-01T09-59-08-111Z.json` +- Direct daemon writes after switching took 661, 982, 998, 980, and 1000 ms. +- Receipt latency after the daemon write returned stayed low at 77-85 ms. +- No-switch control: + `.tmp/terminal-main-app-typing-lag/result-2026-07-01T09-59-28-346Z.json` + ended with fast direct daemon writes once the probe terminal settled. + +The remaining source hot path was the visibility-resume dead-session sweep: + +1. Switching a warm terminal hidden→visible ran the lifecycle visibility effect. +2. The effect scheduled `reconcileDeadSessions`. +3. `reconcileDeadSessions` invoked `window.api.pty.listSessions()`. +4. A preserved old daemon still implemented `listSessions` by snapshotting every + live session, so the user's next daemon `write` queued behind that work. + +Fix: + +- Replace the automatic visibility-resume `listSessions()` sweep with a targeted + single-session liveness check. +- Main exposes `pty:hasPty(id)`, which reads provider-owned in-memory PTY state + and returns `null` when the provider cannot answer authoritatively. +- Renderer visible-resume asks only about each mounted pane's current PTY id. + The pane tears down only on an authoritative `false`; `true`, `null`, rejected + checks, remote-runtime ids, SSH ids, and stale/newborn races all fail open. +- Keep visibility resume process tracking and PTY-size reassertion intact. +- This preserves the recovery added by `a9ef6f916` for panes that missed + `pty:exit` while hidden, without putting a daemon-wide session enumeration on + the warm-switch/input path. + +Verification: + +- Focused vitest suite: `508` tests passed across terminal lifecycle, + pty-connection, dead-session reconcile, and PTY IPC. +- Headful fullscreen E2E harness: + `tests/e2e/terminal-warm-switch-no-list-sessions.tmp.spec.ts` + wraps main-process `pty:listSessions` with an 800 ms stall and then switches + warm workspaces and types into the terminal. +- Latest E2E artifact: + `.tmp/terminal-warm-switch-no-list-sessions/result-1782929853828.json` + showed `fullscreen: true`, `listSessionCallCount: 0`, and + `postTypeEchoLatencyMs: 10`. + +### CLI `terminal-send`, switch away/back + +Result file: `.tmp/terminal-main-app-typing-lag/result-2026-07-01T06-59-45-098Z.json` + +- Average echo latency was about 1841 ms. +- Receipt latency was roughly 836-1445 ms. + +### CLI `terminal-send`, no switch + +- Average echo latency was about 312 ms. + +### CLI `terminal-send`, switch away/back, 1000 ms settle before send + +- Average echo latency was about 574 ms. +- Receipt latency was mostly 1-227 ms, with one run around 684 ms. +- This suggests the problematic window is bounded and concentrated immediately after switch/resume. + +### Direct daemon request, switch away/back + +Result file: `.tmp/terminal-main-app-typing-lag/result-2026-07-01T07-04-43-497Z.json` + +- The harness opened its own daemon control socket and sent the real daemon `write` request directly. +- Daemon write response time was roughly 947-1142 ms. +- Receipt latency after the daemon write response was about 76 ms. +- Average echo latency was about 1385 ms. + +### Direct daemon request, no switch + +Result file: `.tmp/terminal-main-app-typing-lag/result-2026-07-01T07-05-12-496Z.json` + +- Daemon write response time was 0-45 ms. +- Receipt latency was roughly 76-85 ms. +- Average echo latency was about 258 ms. + +### Direct daemon request, output-rich workspace switch + +Result file: `.tmp/terminal-main-app-typing-lag/result-2026-07-01T07-22-06-789Z.json` + +- The alternate terminal was `term_4d7a0b50-e9ae-420e-ac5d-7ec12cbfa408` in the `triage-issues` workspace. +- All four runs delayed. +- Direct daemon `write` response times were 1566, 1527, 1477, and 1333 ms. +- Average echo latency was about 1790 ms. +- A daemon CPU sample was captured at `.tmp/terminal-main-app-typing-lag/daemon-71111-sample-20260701032206.txt`. + +Latest reproduced run: +`.tmp/terminal-main-app-typing-lag/result-2026-07-01T07-27-25-513Z.json` + +- Two runs after switching through `triage-issues`. +- Direct daemon `write` response times were 1556 and 1582 ms. +- Receipt latency after the daemon write response was about 76-77 ms. +- This confirms the daemon request itself waits; once it returns, the PTY and shell process the input quickly. + +### Direct daemon ping loop during switch + +A direct daemon client sent `ping` requests every roughly 50 ms while the harness issued `orca terminal switch` away and back. + +- Pings before and after the switch were effectively immediate. +- One ping sent around 469 ms after switch start waited 1212 ms. +- No terminal input was involved in this probe. + +This is the strongest evidence so far that workspace/terminal switching creates a daemon event-loop stall. The typing lag is a user-visible symptom of the same stall, not the root trigger. + +### Direct daemon request, no switch + +Latest no-switch result: +`.tmp/terminal-main-app-typing-lag/result-2026-07-01T07-27-58-622Z.json` + +- First run had a direct daemon `write` response of 571 ms, likely a new-probe/settling artifact. +- Next two runs were 0-1 ms. +- No-switch is generally fast once the probe terminal is settled. + +### Pause after switching away + +- Adding `ORCA_PROBE_AFTER_SWITCH_AWAY_MS=1500` before switching back made direct daemon writes fast again. +- This indicates expensive work starts when Orca switches into or resumes the output-rich workspace, then spills into the immediate switch-back/write window. + +Latest pause-control result: +`.tmp/terminal-main-app-typing-lag/result-2026-07-01T07-29-58-568Z.json` + +- Direct daemon `write` response times were 0, 0, and 0 ms. +- Echo latency was about 343-363 ms. +- This bounds the problematic window to roughly the first 1-1.5 seconds after switching into/through the expensive workspace. + +### Synthetic heavy terminals + +- Synthetic split panes with large scrollback were not enough to reproduce reliably. +- A plain heavy scrollback split reproduced once in four runs, then larger synthetic scrollback stayed fast. +- A synthetic TUI repaint split stayed fast. +- The issue is therefore not simply "large output" or "any hidden repaint"; old retained/reattach/snapshot state is still suspect. + +### Output timestamp check + +- `lastOutputAt` did not advance when switching through the `triage-issues` terminal and away. +- That weakens the theory that a resume-triggered SIGWINCH caused the child TUI to emit a fresh repaint burst. +- The delay can happen without fresh PTY output from the child process. + +### Snapshot and resize controls + +Direct `getSnapshot` probes on real sessions were much cheaper than the observed lag: + +- Output-rich `triage` terminal: about 10 ms, roughly 122 KB response. +- Active `osprey` Codex terminal: about 57 ms, roughly 804 KB response. + +Synthetic resize pulses on throwaway heavy-scrollback terminals were also cheap: + +- Background 80x24 resize pulse: about 2 ms. +- Focused 232x86 resize pulse: about 2 ms. + +These controls weaken the idea that one ordinary snapshot or one ordinary resize explains a 1.2-1.6 second stall. The remaining suspicious shape is switch-time fanout: many warm reattachments, snapshots, visibility/resume requests, pending-output drains, or checkpoint-like work running serially in the daemon. + +### Daemon `listSessions` proof + +The daemon-only measurement identified the blocking request: + +```json +[ + { "type": "ping", "elapsedMs": 0 }, + { "type": "listSessions", "elapsedMs": 552, "count": 137 }, + { "type": "ping", "elapsedMs": 0 }, + { "type": "listSessions", "elapsedMs": 547, "count": 137 }, + { "type": "ping", "elapsedMs": 0 } +] +``` + +A second daemon-only queue test sent two `listSessions` requests and then a `ping` from another client: + +```json +{ + "totalMs": 1069, + "listSessions1Ms": 1068, + "listSessions2Ms": 1068, + "pingBehindListSessionsMs": 1034, + "sessions": 137 +} +``` + +This reproduces the same stall shape without terminal input or UI switching: a control request sent behind resume-time `listSessions` waits about one second. + +Source cause: + +- `TerminalHost.listSessions()` loops every live daemon session. +- For each session, it calls `session.getSnapshot()` only to read `cols` and `rows`. +- `getSnapshot()` serializes the headless xterm buffer, so a liveness/session-list request scales with terminal scrollback/state across the whole profile. +- Renderer visibility resume calls `window.api.pty.listSessions()` for dead-session reconciliation. With about 137 live daemon sessions, one resume-time list was about 550 ms; two back-to-back resumes were about 1.0-1.1 seconds. + +## What This Rules Out + +- It is probably not keyboard focus. Direct daemon writes reproduce the delay. +- It is probably not only renderer paint. Direct daemon writes wait before the shell receives bytes. +- It is probably not bash or node-pty readiness. No-switch direct writes are fast, and receipt latency after a delayed daemon response is low. +- It is probably not queueing only on Orca's normal daemon client socket. The direct-daemon harness uses a separate socket and still sees the delay. +- It is not caused by typing itself. A ping loop with no write showed the daemon stall during switching. +- It is unlikely to be a single normal `getSnapshot` or `resize` call, because direct controls for those operations are much cheaper than the observed stall. +- The resume-time dead-pane recovery is still necessary; the hot path should + use single-PTY liveness, not a global session list. + +## Current Conclusion + +Switching workspaces or terminals can create a short daemon/main busy window +when warm resume triggers global session enumeration. During that window, even a +direct terminal `write` request waits before the daemon services it. Once the +daemon services the write, the shell receives and processes the bytes quickly. + +The confirmed root for the remaining warm-switch lag is the visibility-resume +dead-session reconciliation path calling global `listSessions()` in profiles +with many preserved daemon sessions. The original dead-session recovery is valid; +the expensive primitive was the problem. + +## Leading Hypotheses + +1. Confirmed: resume-time dead-session reconciliation called daemon + `listSessions`, and older daemons synchronously snapshot every live session + to return cols/rows. +2. Confirmed: avoiding global `listSessions()` on warm resume removes the + request that queued ahead of first post-switch input. +3. Possible secondary contributor: switching to certain old or output-rich + workspaces may also reattach existing daemon-backed PTYs. The daemon + `createOrAttach` path synchronously calls `existing.getSnapshot()` before + responding. +4. Possible secondary contributor: hidden-output recovery, pending-output + draining, or another snapshot-like serialization path runs synchronously on + resume and delays request handling. +5. Less likely: workspace or terminal resume triggers visible-terminal + resize/SIGWINCH or TUI repaint output. The unchanged `lastOutputAt` + observation currently makes this less likely than `listSessions`. + +## Relevant Code Areas + +- CLI terminal send: `src/cli/handlers/terminal.ts` +- Runtime terminal send/focus/write: `src/main/runtime/orca-runtime.ts` +- Runtime PTY data handling: `onPtyData`, `trackHeadlessTerminalData`, hidden-output serialization in `src/main/runtime/orca-runtime.ts` +- Daemon request routing: `src/main/daemon/daemon-server.ts` +- Daemon session write/resize/output handling: `src/main/daemon/session.ts` +- Daemon-side headless terminal state: `src/main/daemon/headless-emulator.ts` +- Daemon adapter: `src/main/daemon/daemon-pty-adapter.ts` +- Main IPC PTY controller: `src/main/ipc/pty.ts` +- Renderer terminal resume: `src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts` +- Renderer PTY connection and resume size reassertion: `src/renderer/src/components/terminal-pane/pty-connection.ts` +- Renderer output scheduler: `src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts` +- Existing-session reattach snapshot: `TerminalHost.createOrAttach` in `src/main/daemon/terminal-host.ts` +- Session snapshot serialization: `TerminalSession.getSnapshot()` in `src/main/daemon/session.ts` +- Snapshot cost benchmark: `src/main/daemon/headless-emulator-snapshot-cost.bench.test.ts` + +## Current Root-Cause Theory + +The root cause is using global session enumeration as a per-pane liveness check: + +1. Workspace switching makes a terminal pane visible again. +2. The renderer schedules dead-session reconciliation on hidden-to-visible resume. +3. The old reconcile path called `window.api.pty.listSessions()`, which reaches + the daemon `listSessions` RPC. +4. Older preserved daemons implement `TerminalHost.listSessions()` by calling + `session.getSnapshot()` for every live session. +5. With a heavy profile, those snapshot serializations block the daemon event + loop for about 550 ms per list. +6. A switch away/back can put a `write` behind two resume-time lists, yielding + about 1.0-1.6 seconds of delayed input. + +This theory fits the current evidence: + +- Direct daemon writes block, so the delay is below keyboard focus and normal CLI plumbing. +- Receipt handling after the daemon write response is fast, so the shell and PTY are not the bottleneck. +- No-switch writes are fast. +- Waiting after switching away lets the expensive resume work finish, so switching back and typing is fast. +- `lastOutputAt` does not advance, so the child process probably is not generating the expensive work. +- Synthetic fresh heavy output did not reproduce reliably, which points toward retained old session/state, request fanout, or workspace-specific resume behavior rather than simple line count. + +The correct fix is two-layered: + +1. Keep daemon `listSessions` metadata-only for builds where callers genuinely + need the global session list. +2. Do not use `listSessions` for warm-resume dead-pane recovery. Use + `pty:hasPty(id)` to ask about the pane's own PTY id, backed by provider + in-memory state. Close only on authoritative `false`; fail open on `true`, + `null`, unsupported providers, remote-runtime/SSH ids, and stale/newborn + races. + +## Investigation Constraints + +- Do not kill or restart the user's main packaged Orca app or daemon without explicit approval. +- Use throwaway terminals for probes and close them afterward. +- Keep temp harnesses and screenshots out of commits unless explicitly requested. +- Reproduce in the real main-app flow when possible; lighter dev profiles may not show the problem. + +## Verification Plan + +1. Unit-test that `pty:hasPty(id)` does not call provider `listProcesses()`. +2. Unit-test that targeted liveness still closes a missing local PTY and fails + open for live/unknown/stale cases. +3. Unit-test that terminal input and warm resume do not call `listSessions()`. +4. Re-run the headful fullscreen warm-switch E2E with `pty:listSessions` + artificially delayed and assert the count stays zero. +5. Re-run the main-app direct-daemon switch/no-switch harness when validating + against the user's heavy profile. diff --git a/mobile/.gitignore b/mobile/.gitignore index 9fa2fa13bb7..449fa3f2439 100644 --- a/mobile/.gitignore +++ b/mobile/.gitignore @@ -1,4 +1,5 @@ node_modules/ +src/terminal/terminal-webview-engine.generated.ts .expo/ dist/ /android/ diff --git a/mobile/.oxlintrc.json b/mobile/.oxlintrc.json index 828d42615a1..51dd82305f1 100644 --- a/mobile/.oxlintrc.json +++ b/mobile/.oxlintrc.json @@ -1,6 +1,7 @@ { "$schema": "./node_modules/oxlint/configuration_schema.json", "extends": ["../.oxlintrc.json"], + "ignorePatterns": ["src/terminal/terminal-webview-engine.generated.ts"], "rules": { "react-hooks/exhaustive-deps": "off", "react/no-unescaped-entities": "off", @@ -35,7 +36,7 @@ { "files": ["src/terminal/terminal-webview-html.ts"], "rules": { - "max-lines": ["error", { "max": 1778, "skipBlankLines": true, "skipComments": true }] + "max-lines": ["error", { "max": 1784, "skipBlankLines": true, "skipComments": true }] } }, { diff --git a/mobile/app.json b/mobile/app.json index 9394a9a63dc..27d3b7d88c6 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Orca", "slug": "orca-mobile", - "version": "0.0.18", + "version": "0.0.22", "orientation": "default", "icon": "./assets/icon.png", "userInterfaceStyle": "automatic", @@ -75,7 +75,7 @@ "allowBackup": false, "permissions": ["RECORD_AUDIO", "MODIFY_AUDIO_SETTINGS"], "package": "com.stably.orca.mobile", - "versionCode": 2 + "versionCode": 4 }, "plugins": [ "expo-router", diff --git a/mobile/app/h/[hostId]/files/preview/[worktreeId].tsx b/mobile/app/h/[hostId]/files/preview/[worktreeId].tsx index 8ab79117ce8..65412d51958 100644 --- a/mobile/app/h/[hostId]/files/preview/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/files/preview/[worktreeId].tsx @@ -7,6 +7,11 @@ export default function MobileFilePreviewRoute() { hostId?: string | string[] worktreeId?: string | string[] relativePath?: string | string[] + source?: string | string[] + absolutePath?: string | string[] + grantId?: string | string[] + line?: string | string[] + column?: string | string[] name?: string | string[] worktreeName?: string | string[] }>() diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 1912f7a04a4..994798712f4 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -1,8 +1,6 @@ import { useState, useEffect, useRef, useCallback, useMemo } from 'react' import { Animated, AppState, Linking, type AppStateStatus } from 'react-native' import * as Clipboard from 'expo-clipboard' -import { ImageManipulator, SaveFormat } from 'expo-image-manipulator' -import { File as FsFile, Paths } from 'expo-file-system' import { BackHandler, FlatList, @@ -48,7 +46,6 @@ import { X } from 'lucide-react-native' import type { RpcClient } from '../../../../src/transport/rpc-client' -import type { RuntimeTerminalPathResolution } from '../../../../../src/shared/runtime-types' import { loadHosts } from '../../../../src/transport/host-store' import { loadTerminalAutocompleteEnabled, @@ -95,14 +92,17 @@ import { getVisibleTerminalAccessoryKeys, loadTerminalAccessoryLayout } from '../../../../src/terminal/terminal-accessory-layout' +import { createTerminalLiveAccessoryInput } from '../../../../src/terminal/terminal-live-accessory-input' +import { getTerminalLiveAccessoryRawSendTarget } from '../../../../src/terminal/terminal-live-accessory-raw-send-target' import { clearTerminalLiveInputFocusTimer, - defaultTerminalLiveInputHandles, - getTerminalLiveSpecialKeyBytes, + focusTerminalLiveInputTarget, isTerminalLiveInputWithinByteLimit, - pruneTerminalLiveInputHandles, scheduleTerminalLiveInputFocus } from '../../../../src/terminal/terminal-live-input' +import type { TerminalLiveInputSender } from '../../../../src/terminal/terminal-live-input-sender' +import { isTerminalSendRpcAccepted } from '../../../../src/terminal/terminal-send-rpc-response' +import { useTerminalLiveInputCommit } from '../../../../src/terminal/use-terminal-live-input-commit' import { getTerminalCommandKeyboardType, getTerminalLiveInputKeyboardType @@ -162,16 +162,13 @@ import { type MobileNewTabAgentOption, type MobileNewTabAgentSettings } from '../../../../src/session/mobile-new-tab-agent-options' -import { - buildMobileImagePastePayload, - prepareMobileClipboardImageBase64, - saveMobileClipboardImageAsTempFile, - type MobileClipboardImageResizer -} from '../../../../src/session/mobile-clipboard-image' import { useMobileImageAttachment } from '../../../../src/session/use-mobile-image-attachment' +import { useMobileTerminalPaste } from '../../../../src/session/use-mobile-terminal-paste' +import { useTerminalLiveInputModePreference } from '../../../../src/session/use-terminal-live-input-mode-preference' import { MobileTerminalLiveInputStatus } from '../../../../src/session/MobileTerminalLiveInputStatus' import { MobileTerminalInputActions } from '../../../../src/session/MobileTerminalInputActions' import { classifyMobileArtifact } from '../../../../src/session/mobile-artifact-kind' +import { openMobileTerminalFileTap } from '../../../../src/session/mobile-terminal-file-tap-open' import { useLiveWorktreeName } from '../../../../src/session/use-live-worktree-name' import { acceptSessionSnapshot, @@ -233,52 +230,9 @@ import type { TerminalGestureInputQueue } from './mobile-session-route-types' -const CLIPBOARD_IMAGE_DATA_URL_PREFIX_RE = /^data:image\/[a-z0-9.+-]+;base64,/i -const TERMINAL_KEYBOARD_DISMISS_ACTION_SHEET_FALLBACK_MS = 450 +type TerminalLiveAccessoryInput = ReturnType -// Why: clipboard images are re-encoded as lossless PNG, so high-res screenshots and -// photos can exceed the upload byte budget; resize the raster down to fit before upload. -// The image is staged to a temp file first because the iOS ImageManipulator loader -// (Data(contentsOf:)) cannot decode large base64 data URIs — it needs a file:// URI. -const resizeMobileClipboardImage: MobileClipboardImageResizer = async (source, target) => { - const base64 = source.replace(CLIPBOARD_IMAGE_DATA_URL_PREFIX_RE, '') - const file = new FsFile(Paths.cache, `orca-clip-resize-${Date.now()}.png`) - let context: ReturnType | null = null - let rendered: Awaited< - ReturnType['renderAsync']> - > | null = null - let resultUri: string | null = null - try { - file.create({ overwrite: true }) - file.write(base64, { encoding: 'base64' }) - context = ImageManipulator.manipulate(file.uri) - context.resize({ width: target.width, height: target.height }) - rendered = await context.renderAsync() - const result = await rendered.saveAsync({ format: SaveFormat.PNG, base64: true }) - resultUri = result.uri - // Why: empty base64 would pass the downstream base64 check and upload a corrupt - // image, so fail loudly here instead of silently sending an invalid payload. - if (!result.base64) { - throw new Error('Failed to encode resized clipboard image') - } - return { data: result.base64, width: result.width, height: result.height } - } finally { - rendered?.release() - context?.release() - if (resultUri) { - try { - new FsFile(resultUri).delete() - } catch { - // Best-effort cleanup; ImageManipulator saves into cache for every retry. - } - } - try { - file.delete() - } catch { - // Best-effort cleanup; the OS reclaims the cache directory regardless. - } - } -} +const TERMINAL_KEYBOARD_DISMISS_ACTION_SHEET_FALLBACK_MS = 450 function getActiveTabIdForHandle( tabs: MobileSessionTab[], @@ -835,6 +789,21 @@ function FileReader({ return renderSourceText(doc.content) } +function updateTerminalCwdFromStreamEvent( + handle: string, + data: Record, + terminalCwd: Map +): void { + if (!('cwd' in data)) { + return + } + if (typeof data.cwd === 'string' && data.cwd.trim().length > 0) { + terminalCwd.set(handle, data.cwd) + return + } + terminalCwd.delete(handle) +} + export default function SessionScreen() { const { hostId, @@ -927,11 +896,14 @@ export default function SessionScreen() { const [terminalLinkOpenMode, setTerminalLinkOpenMode] = useState('orca-browser') const [liveInputCapture, setLiveInputCapture] = useState('') - const [liveInputTerminalHandles, setLiveInputTerminalHandles] = useState>( - () => new Set() - ) - const liveInputTerminalHandlesRef = useRef>(new Set()) - const defaultedLiveInputTerminalHandlesRef = useRef>(new Set()) + const { + clearTerminalLiveInputDefault, + defaultTerminalHandlesToLiveInput, + liveInputTerminalHandles, + liveInputTerminalHandlesRef, + pruneTerminalHandlesFromLiveInput, + toggleTerminalLiveInput + } = useTerminalLiveInputModePreference({ hostId, worktreeId }) const [activeHandle, setActiveHandle] = useState(null) const [activeSessionTabId, setActiveSessionTabId] = useState(null) const activeSessionTabIdRef = useRef(null) @@ -1023,6 +995,7 @@ export default function SessionScreen() { const terminalGestureInputBucketsRef = useRef>(new Map()) const terminalGestureInputQueuesRef = useRef>(new Map()) const terminalGestureInputInFlightRef = useRef>(new Set()) + const terminalCwdRef = useRef>(new Map()) const initialModesSeenRef = useRef>(new Set()) const deviceTokenRef = useRef(null) const clientRef = useRef(null) @@ -1034,6 +1007,7 @@ export default function SessionScreen() { const terminalRefs = useRef>(new Map()) const liveInputRef = useRef(null) const liveInputFocusTimerRef = useRef | null>(null) + const sendLiveTerminalInputRef = useRef(async () => false) const sessionTabActionSheetKeyboardHideSubRef = useRef | null>(null) @@ -1068,6 +1042,7 @@ export default function SessionScreen() { // render where client was still null/connecting, silently no-opping the // in-app-browser open). Route through a ref kept current every render. const handleCreateBrowserRef = useRef<((rawUrl?: string) => Promise) | null>(null) + const initialEmptySessionAutoCreateRef = useRef(null) const markdownSaveSeqRef = useRef>(new Map()) const markdownSaveInFlightRef = useRef>(new Set()) @@ -1094,6 +1069,24 @@ export default function SessionScreen() { const [terminalFrameWidth, setTerminalFrameWidth] = useState(0) const activeSessionTab = sessionTabs.find((tab) => tab.id === activeSessionTabId) ?? null + const { + clearPendingLiveInputCommit, + flushPendingLiveInputBeforeExternalSend, + handleLiveInputAccessoryBytes, + handleLiveInputChange, + handleLiveInputKeyPress, + handleLiveInputSubmit + } = useTerminalLiveInputCommit({ + activeHandle, + activeHandleRef, + activeSessionTabType: activeSessionTab?.type, + activeSessionTabTypeRef, + liveInputRef, + liveInputTerminalHandles, + liveInputTerminalHandlesRef, + sendLiveTerminalInputRef, + setLiveInputCapture + }) const canSend = connState === 'connected' && activeHandle != null && @@ -1114,7 +1107,6 @@ export default function SessionScreen() { sessionTabsRef.current = sessionTabs activeSessionTabIdRef.current = activeSessionTabId markdownDocsRef.current = markdownDocs - liveInputTerminalHandlesRef.current = liveInputTerminalHandles const reconciledCreateWarningState = reconcileMobileSessionCreateWarningState( createWarningState, initialCreateWarning @@ -1180,52 +1172,6 @@ export default function SessionScreen() { [clearToastHideTimer] ) - // Why: direct input is now the mobile default, but only once per discovered - // handle so a user's buffered-mode toggle survives tab/list refreshes. - const defaultTerminalHandlesToLiveInput = useCallback((handles: readonly string[]) => { - const result = defaultTerminalLiveInputHandles( - liveInputTerminalHandlesRef.current, - defaultedLiveInputTerminalHandlesRef.current, - handles - ) - if (!result.changed) { - return - } - const nextEnabledHandles = new Set(result.enabledHandles) - const nextDefaultedHandles = new Set(result.defaultedHandles) - liveInputTerminalHandlesRef.current = nextEnabledHandles - defaultedLiveInputTerminalHandlesRef.current = nextDefaultedHandles - setLiveInputTerminalHandles(nextEnabledHandles) - }, []) - - const pruneTerminalHandlesFromLiveInput = useCallback((liveHandles: ReadonlySet) => { - const result = pruneTerminalLiveInputHandles( - liveInputTerminalHandlesRef.current, - defaultedLiveInputTerminalHandlesRef.current, - liveHandles - ) - if (!result.changed) { - return - } - const nextEnabledHandles = new Set(result.enabledHandles) - const nextDefaultedHandles = new Set(result.defaultedHandles) - liveInputTerminalHandlesRef.current = nextEnabledHandles - defaultedLiveInputTerminalHandlesRef.current = nextDefaultedHandles - setLiveInputTerminalHandles(nextEnabledHandles) - }, []) - - const clearTerminalLiveInputDefault = useCallback( - (handle: string) => { - const liveHandles = new Set([ - ...liveInputTerminalHandlesRef.current, - ...defaultedLiveInputTerminalHandlesRef.current - ]) - liveHandles.delete(handle) - pruneTerminalHandlesFromLiveInput(liveHandles) - }, - [pruneTerminalHandlesFromLiveInput] - ) - const dictation = useMobileDictation({ client, enabled: canSend, @@ -1244,8 +1190,16 @@ export default function SessionScreen() { if (!insertHandle) { return } - sendLiveTerminalInput(insertHandle, route.text) - showToast('Dictation inserted') + void (async () => { + const flushedPendingInput = await flushPendingLiveInputBeforeExternalSend(insertHandle) + if (!flushedPendingInput) { + return + } + const sent = await sendLiveTerminalInput(insertHandle, route.text) + if (sent) { + showToast('Dictation inserted') + } + })() return } setInput((current) => appendBufferedDictation(current, route.text)) @@ -1361,6 +1315,7 @@ export default function SessionScreen() { webReadyHandlesRef.current.clear() subscribeSeqRef.current.clear() layoutSeqRef.current.clear() + terminalCwdRef.current.clear() setTerminalKeyboardMetrics(new Map()) for (const term of terminalRefs.current.values()) { term.clear() @@ -1460,6 +1415,7 @@ export default function SessionScreen() { if (initializedHandlesRef.current.has(handle)) { return } + updateTerminalCwdFromStreamEvent(handle, data, terminalCwdRef.current) const cols = (data.cols as number) || 80 const rows = (data.rows as number) || 24 const scrollbackCols = cols @@ -1555,7 +1511,10 @@ export default function SessionScreen() { } })() } + } else if (data.type === 'metadata') { + updateTerminalCwdFromStreamEvent(handle, data, terminalCwdRef.current) } else if (data.type === 'data') { + updateTerminalCwdFromStreamEvent(handle, data, terminalCwdRef.current) // Why: log when data arrives but the WebView ref is missing // — this is the most likely cause of "blank but input works": // server stream is alive, sends flow, but writes are dropped @@ -1578,6 +1537,7 @@ export default function SessionScreen() { } dataRef.write(data.chunk as string) } else if (data.type === 'resized') { + updateTerminalCwdFromStreamEvent(handle, data, terminalCwdRef.current) // Why: inline resize event — the server changed the PTY dimensions // (mode toggle, desktop restore, or a width reflow). When the server // includes a fresh full-buffer snapshot (width reflow), reinitialize @@ -2680,19 +2640,23 @@ export default function SessionScreen() { terminalsRef.current = [] setSessionTabs([]) setActiveSessionTabId(null) - setLiveInputCapture('') - liveInputTerminalHandlesRef.current = new Set() - defaultedLiveInputTerminalHandlesRef.current = new Set() - setLiveInputTerminalHandles(new Set()) + clearPendingLiveInputCommit() setMarkdownDocs(new Map()) setFileDocs(new Map()) clearDelayedActionTimers() return () => { sessionTabActionSheetRequestSeqRef.current += 1 sessionTabActionSheetKeyboardHideSubRef.current?.remove() + clearPendingLiveInputCommit() clearDelayedActionTimers() } - }, [clearDelayedActionTimers, clearTerminalCache, hostId, worktreeId]) + }, [ + clearDelayedActionTimers, + clearPendingLiveInputCommit, + clearTerminalCache, + hostId, + worktreeId + ]) useEffect(() => { if (connState !== 'connected') { @@ -3097,46 +3061,62 @@ export default function SessionScreen() { } } - async function handleAccessoryKey(bytes: string) { + async function handleAccessoryKey(input: TerminalLiveAccessoryInput) { if (!client || !activeHandle || !canSend) { return } - - try { - await client.sendRequest('terminal.send', { - terminal: activeHandle, - text: bytes, + const targetHandle = activeHandle + const accessoryCommit = await handleLiveInputAccessoryBytes(input) + if (accessoryCommit.kind !== 'allow-raw') { + return + } + const currentClient = clientRef.current + // Why: async IME flushing can outlive the original terminal selection. + const rawSendTarget = getTerminalLiveAccessoryRawSendTarget({ + targetHandle, + activeHandle: activeHandleRef.current, + activeSessionTabType: activeSessionTabTypeRef.current + }) + if (!currentClient || !rawSendTarget || connStateRef.current !== 'connected') { + return + } + await currentClient + .sendRequest('terminal.send', { + terminal: rawSendTarget, + text: input.bytes, enter: false, ...(deviceTokenRef.current ? { client: { id: deviceTokenRef.current, type: 'mobile' as const } } : {}) }) - } catch { - // Transient failure - } + .then( + () => undefined, + () => undefined + ) } const sendLiveTerminalInput = useCallback( - (handle: string, bytes: string) => { + async (handle: string, bytes: string): Promise => { const text = normalizeTerminalTextInput(bytes) if (text.length === 0) { - return + return false } if (!isTerminalLiveInputWithinByteLimit(text)) { triggerError() showToast('Input too large (max 256 KiB)', 1500) - return + return false } const rpc = clientRef.current + // Why: callers suppress follow-up controls/toasts when this live send is stale. if ( !rpc || connStateRef.current !== 'connected' || handle !== activeHandleRef.current || activeSessionTabTypeRef.current !== 'terminal' ) { - return + return false } - void rpc + return rpc .sendRequest('terminal.send', { terminal: handle, text, @@ -3145,19 +3125,22 @@ export default function SessionScreen() { ? { client: { id: deviceTokenRef.current, type: 'mobile' as const } } : {}) }) - .catch(() => { - // Transient failure - }) + .then(isTerminalSendRpcAccepted, () => false) }, [showToast] ) + sendLiveTerminalInputRef.current = sendLiveTerminalInput const focusLiveInput = useCallback(() => { if (!canSend || !liveInputEnabled) { return } - liveInputRef.current?.focus() - }, [canSend, liveInputEnabled]) + focusTerminalLiveInputTarget(liveInputRef.current, { + keyboardHeight, + refocus: () => + scheduleTerminalLiveInputFocus(liveInputFocusTimerRef, () => liveInputRef.current?.focus()) + }) + }, [canSend, keyboardHeight, liveInputEnabled]) const clearSessionTabActionSheetKeyboardListener = useCallback(() => { sessionTabActionSheetKeyboardHideSubRef.current?.remove() @@ -3238,82 +3221,42 @@ export default function SessionScreen() { // Tap on a file path in terminal output → resolve it on the host and open it // as a file tab (mirrors desktop Cmd/Ctrl-click). Silent on a miss; the // WebView only emits this when the tap landed on a detected path. + const handleFileTapActivationSeqRef = useRef(0) const handleFileTap = useCallback( - (handle: string, pathText: string) => { + (handle: string, pathText: string, line: number | null, column: number | null) => { if (handle !== activeHandleRef.current || !client) { return } - void (async () => { - try { - const worktree = `id:${worktreeId}` - const response = await client.sendRequest( - 'files.resolveTerminalPath', - { worktree, pathText }, - { timeoutMs: 10_000 } - ) - if (!response.ok) { - return - } - const resolved = (response as RpcSuccess).result as RuntimeTerminalPathResolution - if (!resolved.exists || resolved.isDirectory || !resolved.relativePath) { - return - } - // Confirm the tap landed on something openable before giving feedback. - triggerSelection() - // Why: HTML opens in a browser pane (streamed from the desktop), - // matching desktop's terminal-click behavior, instead of a file view. - if (classifyMobileArtifact(resolved.relativePath) === 'html' && resolved.absolutePath) { - void handleCreateBrowser('file://' + resolved.absolutePath) - return - } - const openResponse = await client.sendRequest( - 'files.open', - { worktree, relativePath: resolved.relativePath }, - { timeoutMs: 15_000 } - ) - if (!openResponse.ok) { - return - } - // Why: the host opens the file as a markdown/file/image tab (the type - // depends on the file — .md opens as a 'markdown' tab), and from a terminal - // the active tab stays on the terminal. Once the new tab syncs in, switch to - // it by relativePath across ANY openable type. Poll since it arrives async. - const openedPath = resolved.relativePath - // Why: retries poll for the async-arriving tab, but once activation lands - // a later retry would steal focus back from the user — short-circuit the - // remaining ones once the opened tab is (or becomes) the active tab. - let activated = false - const activateOpenedTab = async (): Promise => { - if (activated) { - return - } - await fetchSessionTabs() - if (activated) { - return - } - const opened = sessionTabsRef.current.find( - (tab): tab is Extract => - 'relativePath' in tab && tab.relativePath === openedPath - ) - if (!opened) { - return - } - if (activeSessionTabIdRef.current === opened.id) { - activated = true - return - } - switchSessionTabRef.current?.(opened) - activated = true - } - scheduleDelayedAction(() => void activateOpenedTab(), 300) - scheduleDelayedAction(() => void activateOpenedTab(), 900) - scheduleDelayedAction(() => void activateOpenedTab(), 1800) - } catch { - // Resolution/open is best-effort; a failed tap silently no-ops. - } - })() + const activationSeq = ++handleFileTapActivationSeqRef.current + openMobileTerminalFileTap({ + client, + hostId, + worktreeId, + worktreeName: routeWorktreeName, + terminalHandle: handle, + pathText, + cwd: terminalCwdRef.current.get(handle) ?? null, + line, + column, + pushPreviewRoute: (href) => router.push(href), + openBrowser: (url) => void handleCreateBrowserRef.current?.(url), + triggerOpenFeedback: triggerSelection, + fetchSessionTabs, + getSessionTabs: () => sessionTabsRef.current, + getActiveSessionTabId: () => activeSessionTabIdRef.current, + getActivationState: (activated) => ({ + activated, + activationSeq, + latestActivationSeq: handleFileTapActivationSeqRef.current, + sourceTerminalHandle: handle, + activeTerminalHandle: activeHandleRef.current, + activeTabType: activeSessionTabTypeRef.current + }), + switchSessionTab: (tab) => switchSessionTabRef.current?.(tab), + scheduleDelayedAction + }) }, - [client, worktreeId, scheduleDelayedAction, fetchSessionTabs] + [client, fetchSessionTabs, hostId, routeWorktreeName, router, scheduleDelayedAction, worktreeId] ) const handleOpenedFileDiffActivationSeqRef = useRef(0) @@ -3376,81 +3319,15 @@ export default function SessionScreen() { if (!activeHandle) { return } - const nextEnabled = !liveInputTerminalHandles.has(activeHandle) - setLiveInputTerminalHandles((prev) => { - const next = new Set(prev) - if (nextEnabled) { - next.add(activeHandle) - } else { - next.delete(activeHandle) - } - liveInputTerminalHandlesRef.current = next - return next - }) - setLiveInputCapture('') + const nextEnabled = toggleTerminalLiveInput(activeHandle) + clearPendingLiveInputCommit() if (nextEnabled) { scheduleTerminalLiveInputFocus(liveInputFocusTimerRef, () => liveInputRef.current?.focus()) } else { clearTerminalLiveInputFocusTimer(liveInputFocusTimerRef) liveInputRef.current?.blur() } - }, [activeHandle, liveInputTerminalHandles]) - - const handleLiveInputChange = useCallback( - (text: string) => { - if (!activeHandle) { - setLiveInputCapture('') - liveInputRef.current?.setNativeProps({ text: '' }) - return - } - if (!liveInputTerminalHandles.has(activeHandle)) { - setLiveInputCapture('') - liveInputRef.current?.setNativeProps({ text: '' }) - return - } - const normalizedText = normalizeTerminalTextInput(text) - if (normalizedText.length > 0) { - sendLiveTerminalInput(activeHandle, normalizedText) - } - setLiveInputCapture('') - // Why: the field is only a keyboard capture surface. Clearing the - // native value prevents subsequent phone-keyboard events from replaying - // already-sent characters when React state remains the empty string. - liveInputRef.current?.setNativeProps({ text: '' }) - }, - [activeHandle, liveInputTerminalHandles, sendLiveTerminalInput] - ) - - const handleLiveInputKeyPress = useCallback( - (event: { nativeEvent: { key: string } }) => { - if (!activeHandle) { - return - } - if (!liveInputTerminalHandles.has(activeHandle)) { - return - } - const bytes = getTerminalLiveSpecialKeyBytes(event.nativeEvent.key) - if (!bytes) { - return - } - sendLiveTerminalInput(activeHandle, bytes) - setLiveInputCapture('') - liveInputRef.current?.setNativeProps({ text: '' }) - }, - [activeHandle, liveInputTerminalHandles, sendLiveTerminalInput] - ) - - const handleLiveInputSubmit = useCallback(() => { - if (!activeHandle) { - return - } - if (!liveInputTerminalHandles.has(activeHandle)) { - return - } - sendLiveTerminalInput(activeHandle, '\r') - setLiveInputCapture('') - liveInputRef.current?.setNativeProps({ text: '' }) - }, [activeHandle, liveInputTerminalHandles, sendLiveTerminalInput]) + }, [activeHandle, clearPendingLiveInputCommit, toggleTerminalLiveInput]) const allowTerminalGestureInput = useCallback( (handle: string, sequenceCount: number): boolean => { @@ -3650,11 +3527,11 @@ export default function SessionScreen() { } }, []) const startAccessoryRepeat = useCallback( - (bytes: string) => { + (input: TerminalLiveAccessoryInput) => { stopAccessoryRepeat() repeatTimeoutRef.current = setTimeout(() => { repeatIntervalRef.current = setInterval(() => { - void handleAccessoryKeyRef.current(bytes) + void handleAccessoryKeyRef.current(input) }, 45) }, 400) }, @@ -3673,11 +3550,13 @@ export default function SessionScreen() { clearToastHideTimer() clearDelayedActionTimers() clearTerminalLiveInputFocusTimer(liveInputFocusTimerRef) + clearPendingLiveInputCommit() sessionTabActionSheetRequestSeqRef.current += 1 clearSessionTabActionSheetKeyboardListener() stopAccessoryRepeat() }, [ + clearPendingLiveInputCommit, clearDelayedActionTimers, clearSessionTabActionSheetKeyboardListener, clearTerminalCache, @@ -3801,84 +3680,38 @@ export default function SessionScreen() { }) }, []) - const handlePaste = useCallback(async () => { - if (!client || !activeHandle || !canSend) { - return - } - try { - const text = await Clipboard.getStringAsync() - let payload: string | null = null - if (text.length > 0) { - const modes = ptyModesRef.current.get(activeHandle) || { - bracketedPasteMode: false, - altScreen: false, - mouseTrackingMode: 'none', - sgrMouseMode: false, - sgrMousePixelsMode: false - } - const wrap = modes.bracketedPasteMode && !modes.altScreen - // Why: strip embedded bracketed-paste markers from clipboard text so a - // malicious copy containing `\x1b[201~` can't terminate paste mode early - // and have the trailing bytes interpreted as shell commands. Matches - // xterm.js / iTerm2 behavior. - // eslint-disable-next-line no-control-regex -- intentional bracketed-paste marker stripping - const sanitized = wrap ? text.replace(/\x1b\[20[01]~/g, '') : text - payload = wrap ? `\x1b[200~${sanitized}\x1b[201~` : sanitized - } else { - const image = await Clipboard.getImageAsync({ format: 'png' }) - if (!image) { - refreshCanPaste() - return - } - const connectionId = await getActiveWorktreeConnectionId() - const base64 = await prepareMobileClipboardImageBase64(image, resizeMobileClipboardImage) - const imagePath = await saveMobileClipboardImageAsTempFile(client, base64, { - connectionId - }) - payload = buildMobileImagePastePayload(imagePath) - } - - const wrappedBytes = new TextEncoder().encode(payload).byteLength - if (wrappedBytes > 256 * 1024) { - triggerError() - // eslint-disable-next-line no-console - console.warn('[mobile-clip] paste oversized', { wrappedBytes }) - showToast('Paste too large (max 256 KiB)', 1500) - return - } - await client.sendRequest('terminal.send', { - terminal: activeHandle, - text: payload, - enter: false, - ...(deviceTokenRef.current - ? { client: { id: deviceTokenRef.current, type: 'mobile' as const } } - : {}) - }) - triggerSelection() - refreshCanPaste() - } catch (e) { - triggerError() - const err = e as { name?: string; message?: string } - const isDisconnected = connState !== 'connected' - // eslint-disable-next-line no-console - console.warn('[mobile-clip] paste failed', { name: err.name, message: err.message }) - if (isDisconnected) { - showToast('Paste failed (disconnected)', 1500) - } else if (err.message === 'Clipboard image is too large') { - showToast('Image too large to paste', 1500) - } else { - showToast('Paste failed', 1500) - } - } - }, [ + const handlePaste = useMobileTerminalPaste({ client, activeHandle, + activeHandleRef, + activeSessionTabTypeRef, canSend, connState, + connStateRef, + clientRef, + deviceTokenRef, + flushPendingLiveInputBeforeExternalSend, getActiveWorktreeConnectionId, + onError: triggerError, + onSuccess: triggerSelection, + ptyModesRef, refreshCanPaste, 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 { attachImage, isAttaching } = useMobileImageAttachment({ client, @@ -3886,6 +3719,7 @@ export default function SessionScreen() { canSend, connState, deviceTokenRef, + beforeTerminalSend: flushPendingLiveInputBeforeAttachmentSend, getActiveWorktreeConnectionId, showToast, onSuccess: triggerSelection, @@ -4737,28 +4571,27 @@ export default function SessionScreen() { ))} - [ - styles.newTerminalButton, - pressed && styles.newTerminalButtonPressed, - (creating || - creatingBrowser || - creatingMarkdown || - connState !== 'connected') && - styles.newTerminalButtonDisabled - ]} - disabled={ - creating || creatingBrowser || creatingMarkdown || connState !== 'connected' - } - onPress={() => { - setCreateError('') - setShowCreateTabDrawer(true) - }} - accessibilityLabel="New tab" - > - - + {/* Why: pinned outside the scroll strip so the new-agent button + stays reachable no matter how far the tabs scroll. */} + [ + styles.newTerminalButton, + pressed && styles.newTerminalButtonPressed, + (creating || creatingBrowser || creatingMarkdown || connState !== 'connected') && + styles.newTerminalButtonDisabled + ]} + disabled={ + creating || creatingBrowser || creatingMarkdown || connState !== 'connected' + } + onPress={() => { + setCreateError('') + setShowCreateTabDrawer(true) + }} + accessibilityLabel="New tab" + > + + )} @@ -5046,8 +4879,9 @@ export default function SessionScreen() { if (!key.repeatable) { return } - void handleAccessoryKey(key.bytes) - startAccessoryRepeat(key.bytes) + const input = createTerminalLiveAccessoryInput(key) + void handleAccessoryKey(input) + startAccessoryRepeat(input) }} onPressOut={() => { if (key.repeatable) { @@ -5058,7 +4892,7 @@ export default function SessionScreen() { if (key.repeatable) { return } - void handleAccessoryKey(key.bytes) + void handleAccessoryKey(createTerminalLiveAccessoryInput(key)) }} accessibilityLabel={key.accessibilityLabel ?? `Send ${key.label}`} > @@ -5082,7 +4916,7 @@ export default function SessionScreen() { !canSend && styles.accessoryKeyDisabled ]} disabled={!canSend} - onPress={() => void handleAccessoryKey(key.bytes)} + onPress={() => void handleAccessoryKey({ bytes: key.bytes })} onLongPress={() => { triggerMediumImpact() setDeleteKeyTarget(key) @@ -5117,10 +4951,16 @@ export default function SessionScreen() { {liveInputEnabled ? ( [ + styles.liveInputFocusTarget, + pressed && styles.liveInputFocusTargetPressed, + !canSend && styles.liveInputFocusTargetDisabled + ]} disabled={!canSend} onPress={focusLiveInput} - accessibilityLabel="Focus live terminal input" + accessibilityRole="button" + accessibilityLabel="Show keyboard for live terminal input" + accessibilityHint="Typed text is sent directly to the active terminal" > =18'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.28.1': resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.25.4': + resolution: {integrity: sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.28.1': resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm@0.25.4': + resolution: {integrity: sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.28.1': resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-x64@0.25.4': + resolution: {integrity: sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.28.1': resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.25.4': + resolution: {integrity: sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.28.1': resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.25.4': + resolution: {integrity: sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.28.1': resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.25.4': + resolution: {integrity: sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.28.1': resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.25.4': + resolution: {integrity: sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.28.1': resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.25.4': + resolution: {integrity: sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.28.1': resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.25.4': + resolution: {integrity: sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.28.1': resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.25.4': + resolution: {integrity: sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.28.1': resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.25.4': + resolution: {integrity: sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.28.1': resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.25.4': + resolution: {integrity: sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.28.1': resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.25.4': + resolution: {integrity: sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.28.1': resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.25.4': + resolution: {integrity: sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.28.1': resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.25.4': + resolution: {integrity: sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.28.1': resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.25.4': + resolution: {integrity: sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.28.1': resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/netbsd-arm64@0.25.4': + resolution: {integrity: sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-arm64@0.28.1': resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.25.4': + resolution: {integrity: sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.28.1': resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/openbsd-arm64@0.25.4': + resolution: {integrity: sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-arm64@0.28.1': resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.25.4': + resolution: {integrity: sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.28.1': resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} engines: {node: '>=18'} @@ -1283,24 +1430,48 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/sunos-x64@0.25.4': + resolution: {integrity: sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.28.1': resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.25.4': + resolution: {integrity: sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.28.1': resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.25.4': + resolution: {integrity: sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.28.1': resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.25.4': + resolution: {integrity: sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.28.1': resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} engines: {node: '>=18'} @@ -2625,6 +2796,19 @@ packages: resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} engines: {node: '>=14.6'} + '@xterm/addon-unicode11@0.10.0-beta.285': + resolution: {integrity: sha512-rfijFu7UcYpaFx5wzxvTpQbIyyq/amf2PuS9pktywcFQr4ITxRgid5EVzKLRG1vchkApNcQplWeYxGEtjiw0Cw==} + peerDependencies: + '@xterm/xterm': ^6.1.0-beta.285 + + '@xterm/addon-webgl@0.20.0-beta.284': + resolution: {integrity: sha512-tzkUiEfdpHCY8mXCbuIaP9V67QDfBJvDr9jdxs5jjxNCIQvw+NCoKD97y5sUrQhrIlr7xrDGniPgPYThQ/1FWg==} + peerDependencies: + '@xterm/xterm': ^6.1.0-beta.285 + + '@xterm/xterm@6.1.0-beta.285': + resolution: {integrity: sha512-S3K58tepMkbpWRBzOGKd0In6AVvt9QPAnNs8DJ8rPUPODYtsCYWAtINHKYtC2OpXcE5EBKM35dl+Dgv03OoE/w==} + abab@2.0.6: resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==} deprecated: Use your platform's native atob() and btoa() methods instead @@ -2653,8 +2837,8 @@ packages: resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} engines: {node: '>=0.4.0'} - acorn@8.16.0: - resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} engines: {node: '>=0.4.0'} hasBin: true @@ -3350,6 +3534,11 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} + esbuild@0.25.4: + resolution: {integrity: sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==} + engines: {node: '>=18'} + hasBin: true + esbuild@0.28.1: resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} engines: {node: '>=18'} @@ -5424,6 +5613,11 @@ packages: peerDependencies: react: ^19.2.0 + react-test-renderer@19.2.6: + resolution: {integrity: sha512-GbS6V23YduFTPiWJ5xICbKEjRcqx1Z90js/V5miqhz7qp/d6xSe9Dd6NjSQODFRdzdsqRMPW82E/sFpPRbY5Mw==} + peerDependencies: + react: ^19.2.6 + react@19.2.6: resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==} engines: {node: '>=0.10.0'} @@ -7638,81 +7832,156 @@ snapshots: tslib: 2.8.1 optional: true + '@esbuild/aix-ppc64@0.25.4': + optional: true + '@esbuild/aix-ppc64@0.28.1': optional: true + '@esbuild/android-arm64@0.25.4': + optional: true + '@esbuild/android-arm64@0.28.1': optional: true + '@esbuild/android-arm@0.25.4': + optional: true + '@esbuild/android-arm@0.28.1': optional: true + '@esbuild/android-x64@0.25.4': + optional: true + '@esbuild/android-x64@0.28.1': optional: true + '@esbuild/darwin-arm64@0.25.4': + optional: true + '@esbuild/darwin-arm64@0.28.1': optional: true + '@esbuild/darwin-x64@0.25.4': + optional: true + '@esbuild/darwin-x64@0.28.1': optional: true + '@esbuild/freebsd-arm64@0.25.4': + optional: true + '@esbuild/freebsd-arm64@0.28.1': optional: true + '@esbuild/freebsd-x64@0.25.4': + optional: true + '@esbuild/freebsd-x64@0.28.1': optional: true + '@esbuild/linux-arm64@0.25.4': + optional: true + '@esbuild/linux-arm64@0.28.1': optional: true + '@esbuild/linux-arm@0.25.4': + optional: true + '@esbuild/linux-arm@0.28.1': optional: true + '@esbuild/linux-ia32@0.25.4': + optional: true + '@esbuild/linux-ia32@0.28.1': optional: true + '@esbuild/linux-loong64@0.25.4': + optional: true + '@esbuild/linux-loong64@0.28.1': optional: true + '@esbuild/linux-mips64el@0.25.4': + optional: true + '@esbuild/linux-mips64el@0.28.1': optional: true + '@esbuild/linux-ppc64@0.25.4': + optional: true + '@esbuild/linux-ppc64@0.28.1': optional: true + '@esbuild/linux-riscv64@0.25.4': + optional: true + '@esbuild/linux-riscv64@0.28.1': optional: true + '@esbuild/linux-s390x@0.25.4': + optional: true + '@esbuild/linux-s390x@0.28.1': optional: true + '@esbuild/linux-x64@0.25.4': + optional: true + '@esbuild/linux-x64@0.28.1': optional: true + '@esbuild/netbsd-arm64@0.25.4': + optional: true + '@esbuild/netbsd-arm64@0.28.1': optional: true + '@esbuild/netbsd-x64@0.25.4': + optional: true + '@esbuild/netbsd-x64@0.28.1': optional: true + '@esbuild/openbsd-arm64@0.25.4': + optional: true + '@esbuild/openbsd-arm64@0.28.1': optional: true + '@esbuild/openbsd-x64@0.25.4': + optional: true + '@esbuild/openbsd-x64@0.28.1': optional: true '@esbuild/openharmony-arm64@0.28.1': optional: true + '@esbuild/sunos-x64@0.25.4': + optional: true + '@esbuild/sunos-x64@0.28.1': optional: true + '@esbuild/win32-arm64@0.25.4': + optional: true + '@esbuild/win32-arm64@0.28.1': optional: true + '@esbuild/win32-ia32@0.25.4': + optional: true + '@esbuild/win32-ia32@0.28.1': optional: true + '@esbuild/win32-x64@0.25.4': + optional: true + '@esbuild/win32-x64@0.28.1': optional: true @@ -7825,7 +8094,7 @@ snapshots: ws: 8.21.0 zod: 3.25.76 optionalDependencies: - expo-router: 55.0.14(ce047ddbdcb481fe003f0510f5198c7e) + expo-router: 55.0.14(e0a8113e3689d84edb7af23d00a72ffa) react-native: 0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6) transitivePeerDependencies: - '@expo/dom-webview' @@ -8093,7 +8362,7 @@ snapshots: react: 19.2.6 optionalDependencies: '@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-dom@19.2.6(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) - expo-router: 55.0.14(ce047ddbdcb481fe003f0510f5198c7e) + expo-router: 55.0.14(e0a8113e3689d84edb7af23d00a72ffa) react-dom: 19.2.6(react@19.2.6) transitivePeerDependencies: - supports-color @@ -9055,14 +9324,14 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@testing-library/react-native@13.3.3(jest@29.7.0(@types/node@25.9.3))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-test-renderer@19.2.0(react@19.2.6))(react@19.2.6)': + '@testing-library/react-native@13.3.3(jest@29.7.0(@types/node@25.9.3))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: jest-matcher-utils: 30.3.0 picocolors: 1.1.1 pretty-format: 30.3.0 react: 19.2.6 react-native: 0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6) - react-test-renderer: 19.2.0(react@19.2.6) + react-test-renderer: 19.2.6(react@19.2.6) redent: 3.0.0 optionalDependencies: jest: 29.7.0(@types/node@25.9.3) @@ -9290,13 +9559,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.9(vite@8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))': + '@vitest/mocker@4.1.9(vite@8.1.0(@types/node@25.9.3)(esbuild@0.25.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + vite: 8.1.0(@types/node@25.9.3)(esbuild@0.25.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) '@vitest/pretty-format@4.1.9': dependencies: @@ -9326,6 +9595,16 @@ snapshots: '@xmldom/xmldom@0.9.10': {} + '@xterm/addon-unicode11@0.10.0-beta.285(@xterm/xterm@6.1.0-beta.285)': + dependencies: + '@xterm/xterm': 6.1.0-beta.285 + + '@xterm/addon-webgl@0.20.0-beta.284(@xterm/xterm@6.1.0-beta.285)': + dependencies: + '@xterm/xterm': 6.1.0-beta.285 + + '@xterm/xterm@6.1.0-beta.285': {} + abab@2.0.6: {} abort-controller@3.0.0: @@ -9344,7 +9623,7 @@ snapshots: acorn-globals@7.0.1: dependencies: - acorn: 8.16.0 + acorn: 8.15.0 acorn-walk: 8.3.5 acorn-jsx@5.3.2(acorn@8.17.0): @@ -9353,9 +9632,9 @@ snapshots: acorn-walk@8.3.5: dependencies: - acorn: 8.16.0 + acorn: 8.15.0 - acorn@8.16.0: {} + acorn@8.15.0: {} acorn@8.17.0: {} @@ -10198,6 +10477,34 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + esbuild@0.25.4: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.4 + '@esbuild/android-arm': 0.25.4 + '@esbuild/android-arm64': 0.25.4 + '@esbuild/android-x64': 0.25.4 + '@esbuild/darwin-arm64': 0.25.4 + '@esbuild/darwin-x64': 0.25.4 + '@esbuild/freebsd-arm64': 0.25.4 + '@esbuild/freebsd-x64': 0.25.4 + '@esbuild/linux-arm': 0.25.4 + '@esbuild/linux-arm64': 0.25.4 + '@esbuild/linux-ia32': 0.25.4 + '@esbuild/linux-loong64': 0.25.4 + '@esbuild/linux-mips64el': 0.25.4 + '@esbuild/linux-ppc64': 0.25.4 + '@esbuild/linux-riscv64': 0.25.4 + '@esbuild/linux-s390x': 0.25.4 + '@esbuild/linux-x64': 0.25.4 + '@esbuild/netbsd-arm64': 0.25.4 + '@esbuild/netbsd-x64': 0.25.4 + '@esbuild/openbsd-arm64': 0.25.4 + '@esbuild/openbsd-x64': 0.25.4 + '@esbuild/sunos-x64': 0.25.4 + '@esbuild/win32-arm64': 0.25.4 + '@esbuild/win32-ia32': 0.25.4 + '@esbuild/win32-x64': 0.25.4 + esbuild@0.28.1: optionalDependencies: '@esbuild/aix-ppc64': 0.28.1 @@ -10651,7 +10958,7 @@ snapshots: expo: 55.0.23(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) expo-json-utils: 55.0.2 - expo-module-scripts@55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.9.3))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.0(react@19.2.6))(react@19.2.6): + expo-module-scripts@55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.9.3))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6): dependencies: '@babel/cli': 7.28.6(@babel/core@7.29.7) '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.7) @@ -10659,7 +10966,7 @@ snapshots: '@babel/preset-typescript': 7.28.5(@babel/core@7.29.7) '@expo/npm-proofread': 1.0.1 '@expo/spawn-async': 1.7.2 - '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@25.9.3))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-test-renderer@19.2.0(react@19.2.6))(react@19.2.6) + '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@25.9.3))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6) '@tsconfig/node18': 18.2.6 '@types/jest': 29.5.14 babel-plugin-dynamic-import-node: 2.3.3 @@ -10671,7 +10978,7 @@ snapshots: jest-snapshot-prettier: prettier@2.8.8 jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@25.9.3)) resolve-workspace-root: 2.0.1 - ts-jest: 29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(jest@29.7.0(@types/node@25.9.3))(typescript@5.9.3) + ts-jest: 29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(jest@29.7.0(@types/node@25.9.3))(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - '@babel/core' @@ -10734,7 +11041,7 @@ snapshots: - supports-color - typescript - expo-router@55.0.14(ce047ddbdcb481fe003f0510f5198c7e): + expo-router@55.0.14(e0a8113e3689d84edb7af23d00a72ffa): dependencies: '@expo/log-box': 55.0.12(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) '@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-dom@19.2.6(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) @@ -10771,7 +11078,7 @@ snapshots: use-latest-callback: 0.2.6(react@19.2.6) vaul: 1.1.2(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) optionalDependencies: - '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@25.9.3))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-test-renderer@19.2.0(react@19.2.6))(react@19.2.6) + '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@25.9.3))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6) react-dom: 19.2.6(react@19.2.6) react-native-gesture-handler: 2.31.2(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) react-native-reanimated: 4.3.0(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) @@ -11795,7 +12102,7 @@ snapshots: jsdom@20.0.3: dependencies: abab: 2.0.6 - acorn: 8.16.0 + acorn: 8.15.0 acorn-globals: 7.0.1 cssom: 0.5.0 cssstyle: 2.3.0 @@ -12948,6 +13255,12 @@ snapshots: react-is: 19.2.6 scheduler: 0.27.0 + react-test-renderer@19.2.6(react@19.2.6): + dependencies: + react: 19.2.6 + react-is: 19.2.6 + scheduler: 0.27.0 + react@19.2.6: {} readdirp@3.6.0: @@ -13420,7 +13733,7 @@ snapshots: terser@5.46.2: dependencies: '@jridgewell/source-map': 0.3.11 - acorn: 8.16.0 + acorn: 8.15.0 commander: 2.20.3 source-map-support: 0.5.21 @@ -13484,7 +13797,7 @@ snapshots: picomatch: 4.0.4 typescript: 5.9.3 - ts-jest@29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(jest@29.7.0(@types/node@25.9.3))(typescript@5.9.3): + ts-jest@29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(jest@29.7.0(@types/node@25.9.3))(typescript@5.9.3): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 @@ -13500,7 +13813,7 @@ snapshots: '@babel/core': 7.29.7 '@jest/types': 29.6.3 babel-jest: 29.7.0(@babel/core@7.29.7) - esbuild: 0.28.1 + esbuild: 0.25.4 tsconfig-paths@3.15.0: dependencies: @@ -13657,7 +13970,7 @@ snapshots: - '@types/react' - '@types/react-dom' - vite@8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0): + vite@8.1.0(@types/node@25.9.3)(esbuild@0.25.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -13666,16 +13979,16 @@ snapshots: tinyglobby: 0.2.17 optionalDependencies: '@types/node': 25.9.3 - esbuild: 0.28.1 + esbuild: 0.25.4 fsevents: 2.3.3 terser: 5.48.0 tsx: 4.22.4 yaml: 2.9.0 - vitest@4.1.9(@types/node@25.9.3)(jsdom@20.0.3)(vite@8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)): + vitest@4.1.9(@types/node@25.9.3)(jsdom@20.0.3)(vite@8.1.0(@types/node@25.9.3)(esbuild@0.25.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.9 - '@vitest/mocker': 4.1.9(vite@8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) + '@vitest/mocker': 4.1.9(vite@8.1.0(@types/node@25.9.3)(esbuild@0.25.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.9 '@vitest/runner': 4.1.9 '@vitest/snapshot': 4.1.9 @@ -13692,7 +14005,7 @@ snapshots: tinyexec: 1.1.2 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + vite: 8.1.0(@types/node@25.9.3)(esbuild@0.25.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.9.3 diff --git a/mobile/scripts/build-terminal-webview-engine.mjs b/mobile/scripts/build-terminal-webview-engine.mjs new file mode 100644 index 00000000000..ef6bf0dc1b7 --- /dev/null +++ b/mobile/scripts/build-terminal-webview-engine.mjs @@ -0,0 +1,108 @@ +import { readFile, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { createRequire } from 'node:module' +import * as esbuild from 'esbuild' + +const require = createRequire(import.meta.url) +const scriptDir = import.meta.dirname +const mobileRoot = path.resolve(scriptDir, '..') +const outputPath = path.join(mobileRoot, 'src', 'terminal', 'terminal-webview-engine.generated.ts') +const target = 'chrome74' + +const packages = ['@xterm/xterm', '@xterm/addon-unicode11', '@xterm/addon-webgl'] + +async function readPackageVersion(packageName) { + // Why: a package.json module specifier must use '/' — path.join emits '\' on + // Windows, yielding an unresolvable bare specifier that fails postinstall there. + const packageJsonPath = require.resolve(`${packageName}/package.json`) + const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8')) + return `${packageName}@${packageJson.version}` +} + +function htmlText(value, closingTag) { + return value.replace(new RegExp(`('preview') +export function MobileFileMarkdownPreview({ + relativePath, + content, + truncated, + byteLength, + initialLine +}: Props) { + const [mode, setMode] = useState<'preview' | 'source'>(() => (initialLine ? 'source' : 'preview')) + useEffect(() => { + setMode(initialLine ? 'source' : 'preview') + }, [initialLine, relativePath]) const previewSelected = mode === 'preview' const sourceSelected = mode === 'source' @@ -62,6 +72,7 @@ export function MobileFileMarkdownPreview({ relativePath, content, truncated, by content={content} truncated={truncated} byteLength={byteLength} + initialLine={initialLine} /> )} diff --git a/mobile/src/files/MobileFilePreviewBody.tsx b/mobile/src/files/MobileFilePreviewBody.tsx new file mode 100644 index 00000000000..75f08a329cd --- /dev/null +++ b/mobile/src/files/MobileFilePreviewBody.tsx @@ -0,0 +1,130 @@ +import { ActivityIndicator, Image, Pressable, ScrollView, Text, View } from 'react-native' +import { colors } from '../theme/mobile-theme' +import type { MobileFilePreviewResult } from './mobile-file-preview-request' +import { MobileFileMarkdownPreview } from './MobileFileMarkdownPreview' +import { MobileFilePreviewEditableSource } from './MobileFilePreviewEditableSource' +import { MobileFilePreviewSourceText } from './MobileFilePreviewSourceText' +import type { MobileFilePreviewLineColumn } from './mobile-file-preview-line-column' +import { filePreviewStyles as styles } from './mobile-file-preview-styles' + +type Props = { + preview: MobileFilePreviewResult + relativePath: string + title: string + editable: boolean + draftContent: string + saveError: string + lineColumn: MobileFilePreviewLineColumn | null + imageWidth: number + imageHeight: number + onDraftChange: (content: string) => void + onImageError: () => void + onRetry: () => void +} + +export function MobileFilePreviewBody({ preview, ...options }: Props) { + if (preview.status === 'loading') { + return ( + + + {preview.message} + + ) + } + if (preview.status === 'error' || preview.status === 'waiting') { + return ( + + {preview.message} + + Retry + + + ) + } + if (preview.status === 'empty') { + return options.editable ? ( + + ) : ( + + Empty file + + ) + } + if (preview.kind === 'image') { + return ( + + + + + + ) + } + if (preview.kind === 'markdown') { + return options.editable ? ( + + ) : ( + + ) + } + if (preview.kind === 'html') { + return options.editable ? ( + + ) : ( + + ) + } + if (options.editable) { + return + } + return ( + + ) +} + +function EditablePreviewSource(options: { + title: string + draftContent: string + saveError: string + lineColumn: MobileFilePreviewLineColumn | null + onDraftChange: (content: string) => void +}) { + return ( + + ) +} diff --git a/mobile/src/files/MobileFilePreviewEditableSource.tsx b/mobile/src/files/MobileFilePreviewEditableSource.tsx new file mode 100644 index 00000000000..38e1ce6ba64 --- /dev/null +++ b/mobile/src/files/MobileFilePreviewEditableSource.tsx @@ -0,0 +1,56 @@ +import { useEffect, useState } from 'react' +import { Text, TextInput, View } from 'react-native' +import type { MobileFilePreviewLineColumn } from './mobile-file-preview-line-column' +import { textOffsetForLineColumn } from './mobile-file-preview-line-column' +import { filePreviewStyles as styles } from './mobile-file-preview-styles' + +type Props = { + title: string + draftContent: string + lineColumn: MobileFilePreviewLineColumn | null + saveError?: string + onDraftChange: (content: string) => void +} + +export function MobileFilePreviewEditableSource({ + title, + draftContent, + lineColumn, + saveError, + onDraftChange +}: Props) { + const selectionTargetKey = lineColumn + ? `${title}:${lineColumn.line}:${lineColumn.column ?? ''}` + : '' + const [selection, setSelection] = useState<{ start: number; end: number } | null>(null) + const [revealedTargetKey, setRevealedTargetKey] = useState('') + + // Why: line/column opens should reveal once, then user cursor movement owns selection. + useEffect(() => { + if (!lineColumn || !selectionTargetKey || revealedTargetKey === selectionTargetKey) { + return + } + const offset = textOffsetForLineColumn(draftContent, lineColumn) + const initialSelection = { start: offset, end: offset } + setSelection(initialSelection) + setRevealedTargetKey(selectionTargetKey) + }, [draftContent, lineColumn, revealedTargetKey, selectionTargetKey]) + + return ( + + {saveError ? {saveError} : null} + setSelection(null)} + accessibilityLabel={`${title} editor`} + /> + + ) +} diff --git a/mobile/src/files/MobileFilePreviewScreen.test.ts b/mobile/src/files/MobileFilePreviewScreen.test.ts new file mode 100644 index 00000000000..1595454a12a --- /dev/null +++ b/mobile/src/files/MobileFilePreviewScreen.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' +import { sourceKeyForPreview } from './mobile-file-preview-source' +import { + hasUnsavedMobileTerminalArtifactDraft, + isEditableMobileTerminalArtifactPreview, + shouldKeepDirtyDraftOnPreviewLoadResult +} from './mobile-file-preview-editability' + +describe('MobileFilePreviewScreen', () => { + it('treats empty terminal artifact text previews as editable', () => { + expect(isEditableMobileTerminalArtifactPreview({ status: 'empty', kind: 'text' })).toBe(true) + expect( + isEditableMobileTerminalArtifactPreview({ + status: 'ready', + kind: 'text', + content: '', + truncated: false, + byteLength: 0 + }) + ).toBe(true) + expect( + isEditableMobileTerminalArtifactPreview({ + status: 'ready', + kind: 'image', + dataUri: 'data:image/png;base64,aW1n' + }) + ).toBe(false) + }) + + it('treats truncated terminal artifact text previews as read-only', () => { + expect( + isEditableMobileTerminalArtifactPreview({ + status: 'ready', + kind: 'text', + content: 'partial', + truncated: true, + byteLength: 1024 + }) + ).toBe(false) + }) + + it('keeps dirty terminal artifact drafts protected while preview is waiting for reconnect', () => { + const sourceKey = sourceKeyForPreview({ + source: 'terminalArtifact', + worktreeId: 'wt-1', + absolutePath: '/tmp/result.json', + grantId: 'grant-1' + }) + + expect( + hasUnsavedMobileTerminalArtifactDraft({ + source: 'terminalArtifact', + draftSourceKey: sourceKey, + previewSourceKey: sourceKey, + draftContent: '{"ok":false}', + savedContent: '{"ok":true}' + }) + ).toBe(true) + }) + + it('keeps dirty terminal artifact drafts when a reload fails or waits', () => { + expect( + shouldKeepDirtyDraftOnPreviewLoadResult(true, { + status: 'error', + message: 'Unable to reach desktop filesystem', + reconnect: true + }) + ).toBe(true) + expect( + shouldKeepDirtyDraftOnPreviewLoadResult(true, { + status: 'waiting', + message: 'Waiting for desktop...', + reconnect: true + }) + ).toBe(true) + expect( + shouldKeepDirtyDraftOnPreviewLoadResult(false, { + status: 'error', + message: 'Unable to load preview', + reconnect: false + }) + ).toBe(false) + }) +}) diff --git a/mobile/src/files/MobileFilePreviewScreen.tsx b/mobile/src/files/MobileFilePreviewScreen.tsx index 13bd7054769..0879e02ec7e 100644 --- a/mobile/src/files/MobileFilePreviewScreen.tsx +++ b/mobile/src/files/MobileFilePreviewScreen.tsx @@ -1,30 +1,30 @@ -import { useCallback, useEffect, useState } from 'react' -import { - ActivityIndicator, - Image, - Pressable, - ScrollView, - Text, - View, - useWindowDimensions -} from 'react-native' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Alert, BackHandler, Pressable, Text, View, useWindowDimensions } from 'react-native' import { SafeAreaView } from 'react-native-safe-area-context' import { useRouter } from 'expo-router' -import { ChevronLeft } from 'lucide-react-native' +import { ChevronLeft, Save } from 'lucide-react-native' import { getWorktreeLabel } from '../session/worktree-label' import { colors, spacing } from '../theme/mobile-theme' import { useForceReconnect, useHostClient } from '../transport/client-context' import { loadMobileFilePreview, previewError, + saveMobileTerminalArtifactPreview, + type MobileFilePreviewSource, type MobileFilePreviewResult } from './mobile-file-preview-request' -import { MobileFileMarkdownPreview } from './MobileFileMarkdownPreview' -import { MobileFilePreviewSourceText } from './MobileFilePreviewSourceText' +import { MobileFilePreviewBody } from './MobileFilePreviewBody' import { displayNameFromPreviewPath, type MobileFilePreviewRouteState } from './mobile-file-preview-route' +import { previewSourceFromRoute, sourceKeyForPreview } from './mobile-file-preview-source' +import { normalizeMobileFilePreviewLineColumn } from './mobile-file-preview-line-column' +import { + hasUnsavedMobileTerminalArtifactDraft, + isEditableMobileTerminalArtifactPreview, + shouldKeepDirtyDraftOnPreviewLoadResult +} from './mobile-file-preview-editability' import { filePreviewStyles as styles } from './mobile-file-preview-styles' type Props = { @@ -39,30 +39,116 @@ export function MobileFilePreviewScreen({ route }: Props) { const [preview, setPreview] = useState(() => route.ok ? { status: 'loading', message: 'Loading preview...' } : previewError(route.message) ) + const [draftContent, setDraftContent] = useState('') + const [savedContent, setSavedContent] = useState('') + const [saveError, setSaveError] = useState('') + const [saving, setSaving] = useState(false) + const draftContentRef = useRef(draftContent) + const savedContentRef = useRef(savedContent) + const draftSourceKeyRef = useRef(null) const { width, height } = useWindowDimensions() + const routePreviewSource = useMemo( + () => (previewParams ? previewSourceFromRoute(previewParams) : null), + [previewParams] + ) + const [previewSource, setPreviewSource] = useState( + routePreviewSource + ) + const previewSourceKey = useMemo(() => sourceKeyForPreview(previewSource), [previewSource]) + const routePreviewSourceKey = useMemo( + () => sourceKeyForPreview(routePreviewSource), + [routePreviewSource] + ) + const previewSourceKeyRef = useRef(previewSourceKey) + const lineColumn = useMemo( + () => + previewParams + ? normalizeMobileFilePreviewLineColumn(previewParams.line, previewParams.column) + : null, + [previewParams] + ) + + useEffect(() => { + setPreviewSource(routePreviewSource) + draftSourceKeyRef.current = null + }, [routePreviewSource]) + + useEffect(() => { + previewSourceKeyRef.current = previewSourceKey + }, [previewSourceKey]) + + useEffect(() => { + draftContentRef.current = draftContent + }, [draftContent]) + + useEffect(() => { + savedContentRef.current = savedContent + }, [savedContent]) const loadPreview = useCallback(async () => { - if (!previewParams) { + const loadSourceKey = previewSourceKey + if (!previewParams || !previewSource || loadSourceKey !== routePreviewSourceKey) { setPreview(previewError(route.ok ? 'Unable to load preview' : route.message)) return } + const preserveDirtyDraft = + draftSourceKeyRef.current === previewSourceKey && + draftContentRef.current !== savedContentRef.current if (!client || connState !== 'connected') { + if (preserveDirtyDraft) { + setSaveError('Waiting for desktop...') + return + } setPreview({ status: 'waiting', message: 'Waiting for desktop...', reconnect: true }) return } - setPreview({ status: 'loading', message: 'Loading preview...' }) + if (!preserveDirtyDraft) { + setPreview({ status: 'loading', message: 'Loading preview...' }) + } + setSaveError('') try { - const result = await loadMobileFilePreview( - client, - previewParams.worktreeId, - previewParams.relativePath - ) + const result = await loadMobileFilePreview(client, previewSource, undefined, { + onTerminalArtifactSourceRefreshed: setPreviewSource, + refreshGrant: true + }) + if (previewSourceKeyRef.current !== loadSourceKey) { + return + } + if (shouldKeepDirtyDraftOnPreviewLoadResult(preserveDirtyDraft, result)) { + setSaveError(result.message) + return + } + const loadedContent = + result.status === 'ready' && result.kind !== 'image' + ? result.content + : result.status === 'empty' + ? '' + : null + if (loadedContent !== null) { + if (!preserveDirtyDraft) { + setDraftContent(loadedContent) + setSavedContent(loadedContent) + } + draftSourceKeyRef.current = previewSourceKey + } setPreview(result) } catch (err) { const message = err instanceof Error ? err.message : 'Unable to load preview' + if (preserveDirtyDraft) { + setSaveError(message) + return + } setPreview(previewError(message)) } - }, [client, connState, previewParams, route]) + }, [ + client, + connState, + previewParams, + previewSource, + previewSourceKey, + route, + routePreviewSourceKey + ]) useEffect(() => { void loadPreview() @@ -84,12 +170,70 @@ export function MobileFilePreviewScreen({ route }: Props) { void loadPreview() }, [connState, forceReconnect, loadPreview, preview, previewParams]) - const title = previewParams?.name ?? displayNameFromPreviewPath(previewParams?.relativePath ?? '') + const displayPath = + previewParams?.source === 'terminalArtifact' + ? (previewParams.absolutePath ?? '') + : (previewParams?.relativePath ?? '') + const title = previewParams?.name ?? displayNameFromPreviewPath(displayPath) const worktreeLabel = getWorktreeLabel( previewParams?.worktreeName, previewParams?.worktreeId ?? '' ) - const meta = previewParams ? `${worktreeLabel} - ${previewParams.relativePath}` : 'Preview' + const meta = previewParams ? `${worktreeLabel} - ${displayPath}` : 'Preview' + const isEditableTerminalArtifact = + previewSource?.source === 'terminalArtifact' && isEditableMobileTerminalArtifactPreview(preview) + const canSaveArtifact = + isEditableTerminalArtifact && + draftSourceKeyRef.current === previewSourceKey && + draftContent !== savedContent + const hasUnsavedTerminalArtifactDraft = hasUnsavedMobileTerminalArtifactDraft({ + source: previewSource?.source, + draftSourceKey: draftSourceKeyRef.current, + previewSourceKey, + draftContent, + savedContent + }) + + const saveArtifact = useCallback(async () => { + if (!client || previewSource?.source !== 'terminalArtifact' || !canSaveArtifact || saving) { + return + } + setSaving(true) + setSaveError('') + try { + const result = await saveMobileTerminalArtifactPreview(client, previewSource, draftContent, { + baseContent: savedContent, + onTerminalArtifactSourceRefreshed: setPreviewSource + }) + if (result.status === 'saved') { + setSavedContent(draftContent) + } else { + setSaveError(saveErrorMessageFromPreviewResult(result)) + } + } catch (err) { + const message = err instanceof Error ? err.message : 'Unable to save file' + setSaveError(message) + } finally { + setSaving(false) + } + }, [canSaveArtifact, client, draftContent, previewSource, savedContent, saving]) + + const requestBack = useCallback(() => { + if (!hasUnsavedTerminalArtifactDraft) { + router.back() + return true + } + Alert.alert('Discard changes?', 'Unsaved edits will be lost.', [ + { text: 'Stay', style: 'cancel' }, + { text: 'Discard', style: 'destructive', onPress: () => router.back() } + ]) + return true + }, [hasUnsavedTerminalArtifactDraft, router]) + + useEffect(() => { + const subscription = BackHandler.addEventListener('hardwareBackPress', requestBack) + return () => subscription.remove() + }, [requestBack]) return ( @@ -97,7 +241,7 @@ export function MobileFilePreviewScreen({ route }: Props) { [styles.backButton, pressed && styles.backButtonPressed]} - onPress={() => router.back()} + onPress={requestBack} hitSlop={8} accessibilityLabel="Back to files" > @@ -111,110 +255,40 @@ export function MobileFilePreviewScreen({ route }: Props) { {meta} + {isEditableTerminalArtifact ? ( + void saveArtifact()} + disabled={!canSaveArtifact || saving} + accessibilityLabel="Save terminal artifact" + > + + + ) : null} - {renderPreviewBody(preview, { - relativePath: previewParams?.relativePath ?? '', - title: title || 'File', - imageWidth: Math.max(1, width - spacing.md * 2), - imageHeight: Math.max(240, height - 160), - onImageError: () => - setPreview({ status: 'error', message: 'Unable to load preview', reconnect: false }), - onRetry: retry - })} + + setPreview({ status: 'error', message: 'Unable to load preview', reconnect: false }) + } + onRetry={retry} + /> ) } -function renderPreviewBody( - preview: MobileFilePreviewResult, - options: { - relativePath: string - title: string - imageWidth: number - imageHeight: number - onImageError: () => void - onRetry: () => void - } -) { - if (preview.status === 'loading') { - return ( - - - {preview.message} - - ) - } - - if (preview.status === 'error' || preview.status === 'waiting') { - return ( - - {preview.message} - - Retry - - - ) - } - - if (preview.status === 'empty') { - return ( - - Empty file - - ) - } - - if (preview.kind === 'image') { - return ( - - - - - - ) - } - - if (preview.kind === 'markdown') { - return ( - - ) - } - - if (preview.kind === 'html') { - return ( - - ) - } - - return ( - - ) +function saveErrorMessageFromPreviewResult(result: MobileFilePreviewResult): string { + return result.status === 'error' || result.status === 'waiting' + ? result.message + : 'Unable to save file' } diff --git a/mobile/src/files/MobileFilePreviewSourceText.tsx b/mobile/src/files/MobileFilePreviewSourceText.tsx index 408870d01b8..01ca75d9c87 100644 --- a/mobile/src/files/MobileFilePreviewSourceText.tsx +++ b/mobile/src/files/MobileFilePreviewSourceText.tsx @@ -1,7 +1,8 @@ -import { useMemo } from 'react' +import { useEffect, useMemo, useRef } from 'react' import { ScrollView, Text } from 'react-native' import { MobileSyntaxSegments } from '../components/MobileSyntaxSegments' import { formatPreviewByteLength } from './mobile-file-preview-request' +import { scrollOffsetForPreviewLine } from './mobile-file-preview-line-column' import { buildMobileFilePreviewSyntax } from './mobile-file-preview-syntax' import { filePreviewStyles as styles } from './mobile-file-preview-styles' @@ -9,19 +10,44 @@ export function MobileFilePreviewSourceText({ relativePath, content, truncated, - byteLength + byteLength, + initialLine }: { relativePath: string content: string truncated?: boolean byteLength?: number + initialLine?: number }) { + const scrollRef = useRef(null) + const revealedRef = useRef(false) const syntax = useMemo( () => buildMobileFilePreviewSyntax(relativePath, content), [content, relativePath] ) + + useEffect(() => { + revealedRef.current = false + }, [content, initialLine, relativePath]) + + const revealInitialLine = () => { + if (!initialLine || revealedRef.current) { + return + } + revealedRef.current = true + scrollRef.current?.scrollTo({ + y: scrollOffsetForPreviewLine(initialLine), + animated: false + }) + } + return ( - + {truncated ? ( ) : null} diff --git a/mobile/src/files/mobile-file-preview-editability.ts b/mobile/src/files/mobile-file-preview-editability.ts new file mode 100644 index 00000000000..ff0fa18d3cb --- /dev/null +++ b/mobile/src/files/mobile-file-preview-editability.ts @@ -0,0 +1,38 @@ +import type { + MobileFilePreviewResult, + MobileFilePreviewSource +} from './mobile-file-preview-request' + +export function isEditableMobileTerminalArtifactPreview(preview: MobileFilePreviewResult): boolean { + return ( + (preview.status === 'ready' && preview.kind !== 'image' && !preview.truncated) || + preview.status === 'empty' + ) +} + +export function hasUnsavedMobileTerminalArtifactDraft({ + source, + draftSourceKey, + previewSourceKey, + draftContent, + savedContent +}: { + source?: MobileFilePreviewSource['source'] + draftSourceKey: string | null + previewSourceKey: string | null + draftContent: string + savedContent: string +}): boolean { + return ( + source === 'terminalArtifact' && + draftSourceKey === previewSourceKey && + draftContent !== savedContent + ) +} + +export function shouldKeepDirtyDraftOnPreviewLoadResult( + preserveDirtyDraft: boolean, + result: MobileFilePreviewResult +): result is Extract { + return preserveDirtyDraft && (result.status === 'error' || result.status === 'waiting') +} diff --git a/mobile/src/files/mobile-file-preview-line-column.test.ts b/mobile/src/files/mobile-file-preview-line-column.test.ts new file mode 100644 index 00000000000..460d8c8219f --- /dev/null +++ b/mobile/src/files/mobile-file-preview-line-column.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { + normalizeMobileFilePreviewLineColumn, + scrollOffsetForPreviewLine, + textOffsetForLineColumn +} from './mobile-file-preview-line-column' + +describe('mobile-file-preview-line-column', () => { + it('normalizes positive route params and rejects invalid lines', () => { + expect(normalizeMobileFilePreviewLineColumn('12', '3')).toEqual({ line: 12, column: 3 }) + expect(normalizeMobileFilePreviewLineColumn('12', undefined)).toEqual({ + line: 12, + column: null + }) + expect(normalizeMobileFilePreviewLineColumn('0', '3')).toBeNull() + expect(normalizeMobileFilePreviewLineColumn('12x', '3')).toBeNull() + expect(normalizeMobileFilePreviewLineColumn(undefined, '3')).toBeNull() + }) + + it('converts one-based line and column values to a text offset', () => { + const content = 'alpha\nbravo\ncharlie' + + expect(textOffsetForLineColumn(content, { line: 1, column: 1 })).toBe(0) + expect(textOffsetForLineColumn(content, { line: 2, column: 3 })).toBe(8) + expect(textOffsetForLineColumn(content, { line: 3, column: null })).toBe(12) + }) + + it('caps out-of-range line and column values safely', () => { + const content = 'alpha\nbravo' + + expect(textOffsetForLineColumn(content, { line: 99, column: 1 })).toBe(content.length) + expect(textOffsetForLineColumn(content, { line: 1, column: 99 })).toBe(5) + }) + + it('maps line numbers to the mono preview scroll offset', () => { + expect(scrollOffsetForPreviewLine(1)).toBe(0) + expect(scrollOffsetForPreviewLine(4)).toBe(57) + }) +}) diff --git a/mobile/src/files/mobile-file-preview-line-column.ts b/mobile/src/files/mobile-file-preview-line-column.ts new file mode 100644 index 00000000000..171d106a703 --- /dev/null +++ b/mobile/src/files/mobile-file-preview-line-column.ts @@ -0,0 +1,51 @@ +export type MobileFilePreviewLineColumn = { + line: number + column: number | null +} + +const MOBILE_FILE_PREVIEW_TEXT_LINE_HEIGHT = 19 + +export function normalizeMobileFilePreviewLineColumn( + line: string | undefined, + column: string | undefined +): MobileFilePreviewLineColumn | null { + const normalizedLine = parsePositiveInteger(line) + if (normalizedLine === null) { + return null + } + return { + line: normalizedLine, + column: parsePositiveInteger(column) + } +} + +export function textOffsetForLineColumn( + content: string, + target: MobileFilePreviewLineColumn +): number { + let lineStart = 0 + for (let currentLine = 1; currentLine < target.line; currentLine += 1) { + const nextBreak = content.indexOf('\n', lineStart) + if (nextBreak === -1) { + return content.length + } + lineStart = nextBreak + 1 + } + + const lineEnd = content.indexOf('\n', lineStart) + const cappedLineEnd = lineEnd === -1 ? content.length : lineEnd + const columnOffset = Math.max(0, (target.column ?? 1) - 1) + return Math.min(cappedLineEnd, lineStart + columnOffset) +} + +export function scrollOffsetForPreviewLine(line: number): number { + return Math.max(0, line - 1) * MOBILE_FILE_PREVIEW_TEXT_LINE_HEIGHT +} + +function parsePositiveInteger(value: string | undefined): number | null { + if (!value || !/^\d+$/.test(value)) { + return null + } + const parsed = Number.parseInt(value, 10) + return Number.isSafeInteger(parsed) && parsed >= 1 ? parsed : null +} diff --git a/mobile/src/files/mobile-file-preview-request.test.ts b/mobile/src/files/mobile-file-preview-request.test.ts index d2690111311..f7f77fabd19 100644 --- a/mobile/src/files/mobile-file-preview-request.test.ts +++ b/mobile/src/files/mobile-file-preview-request.test.ts @@ -4,7 +4,8 @@ import { createMobileFilePreviewRequest, formatPreviewByteLength, loadMobileFilePreview, - normalizeMobileFilePreviewResponse + normalizeMobileFilePreviewResponse, + saveMobileTerminalArtifactPreview } from './mobile-file-preview-request' function ok(result: unknown): RpcSuccess { @@ -21,6 +22,12 @@ function clientWith(response: RpcResponse) { } } +function clientWithResponses(responses: RpcResponse[]) { + return { + sendRequest: vi.fn(async () => responses.shift()!) + } +} + describe('mobile-file-preview-request', () => { it('selects readPreview for raster images and read for text-like files', () => { expect(createMobileFilePreviewRequest('wt-1', 'assets/logo.png')).toEqual({ @@ -54,6 +61,471 @@ describe('mobile-file-preview-request', () => { expect(client.sendRequest).not.toHaveBeenCalledWith('files.open', expect.anything()) }) + it('loads terminal artifacts through grant-scoped artifact RPCs', async () => { + const client = clientWith(ok({ content: '{"ok":true}', truncated: false, byteLength: 11 })) + + await expect( + loadMobileFilePreview(client, { + source: 'terminalArtifact', + worktreeId: 'wt-1', + absolutePath: '/tmp/result.json', + grantId: 'grant-1' + }) + ).resolves.toMatchObject({ status: 'ready', kind: 'text', content: '{"ok":true}' }) + + expect(client.sendRequest).toHaveBeenCalledWith('files.readTerminalArtifact', { + worktree: 'id:wt-1', + absolutePath: '/tmp/result.json', + grantId: 'grant-1' + }) + expect(client.sendRequest).not.toHaveBeenCalledWith('files.read', expect.anything()) + }) + + it('refreshes an expired terminal artifact grant and retries the read once', async () => { + const client = clientWithResponses([ + fail('terminal_file_grant_expired'), + ok({ + worktree: 'wt-1', + relativePath: null, + absolutePath: '/tmp/result.json', + exists: true, + isDirectory: false, + openTarget: { + kind: 'absolute-file', + absolutePath: '/tmp/result.json', + grantId: 'grant-2' + } + }), + ok({ content: '{"ok":true}', truncated: false, byteLength: 11 }) + ]) + const onTerminalArtifactSourceRefreshed = vi.fn() + + await expect( + loadMobileFilePreview( + client, + { + source: 'terminalArtifact', + worktreeId: 'wt-1', + absolutePath: '/tmp/result.json', + grantId: 'grant-1', + terminalHandle: 'term-1', + pathText: 'result.json', + cwd: '/tmp/run' + }, + undefined, + { onTerminalArtifactSourceRefreshed } + ) + ).resolves.toMatchObject({ status: 'ready', kind: 'text', content: '{"ok":true}' }) + + expect(client.sendRequest).toHaveBeenNthCalledWith(1, 'files.readTerminalArtifact', { + worktree: 'id:wt-1', + absolutePath: '/tmp/result.json', + grantId: 'grant-1' + }) + expect(client.sendRequest).toHaveBeenNthCalledWith(2, 'files.resolveTerminalPath', { + worktree: 'id:wt-1', + pathText: 'result.json', + cwd: '/tmp/run', + terminal: 'term-1' + }) + expect(client.sendRequest).toHaveBeenNthCalledWith(3, 'files.readTerminalArtifact', { + worktree: 'id:wt-1', + absolutePath: '/tmp/result.json', + grantId: 'grant-2' + }) + expect(onTerminalArtifactSourceRefreshed).toHaveBeenCalledWith({ + source: 'terminalArtifact', + worktreeId: 'wt-1', + absolutePath: '/tmp/result.json', + grantId: 'grant-2', + terminalHandle: 'term-1', + pathText: 'result.json', + cwd: '/tmp/run' + }) + }) + + it('refreshes a stale terminal artifact grant and retries the read once', async () => { + const client = clientWithResponses([ + fail('terminal_file_grant_stale'), + ok({ + worktree: 'wt-1', + relativePath: null, + absolutePath: '/tmp/result.json', + exists: true, + isDirectory: false, + openTarget: { + kind: 'absolute-file', + absolutePath: '/tmp/result.json', + grantId: 'grant-2' + } + }), + ok({ content: '{"ok":true}', truncated: false, byteLength: 11 }) + ]) + + await expect( + loadMobileFilePreview(client, { + source: 'terminalArtifact', + worktreeId: 'wt-1', + absolutePath: '/tmp/result.json', + grantId: 'grant-1', + terminalHandle: 'term-1' + }) + ).resolves.toMatchObject({ status: 'ready', kind: 'text', content: '{"ok":true}' }) + + expect(client.sendRequest).toHaveBeenNthCalledWith(2, 'files.resolveTerminalPath', { + worktree: 'id:wt-1', + pathText: '/tmp/result.json', + terminal: 'term-1' + }) + }) + + it.each([ + ['terminal_file_grant_expired'], + ['terminal_file_grant_mismatch'], + ['terminal_file_grant_stale'] + ])( + 'does not refresh a %s terminal artifact grant when disabled for a dirty editor', + async (error) => { + const client = clientWithResponses([fail(error)]) + + await expect( + loadMobileFilePreview( + client, + { + source: 'terminalArtifact', + worktreeId: 'wt-1', + absolutePath: '/tmp/result.json', + grantId: 'grant-1' + }, + undefined, + { refreshGrant: false } + ) + ).resolves.toEqual({ + status: 'error', + message: 'Reload preview before saving', + reconnect: false + }) + + expect(client.sendRequest).toHaveBeenCalledTimes(1) + expect(client.sendRequest).toHaveBeenCalledWith('files.readTerminalArtifact', { + worktree: 'id:wt-1', + absolutePath: '/tmp/result.json', + grantId: 'grant-1' + }) + } + ) + + it('saves terminal artifacts through the same exact-path grant', async () => { + const client = clientWith(ok({ ok: true })) + + await expect( + saveMobileTerminalArtifactPreview( + client, + { + source: 'terminalArtifact', + worktreeId: 'wt-1', + absolutePath: '/tmp/result.json', + grantId: 'grant-1', + terminalHandle: 'term-1', + pathText: 'result.json', + cwd: '/tmp/run' + }, + '{"ok":false}' + ) + ).resolves.toEqual({ status: 'saved' }) + + expect(client.sendRequest).toHaveBeenCalledWith('files.writeTerminalArtifact', { + worktree: 'id:wt-1', + absolutePath: '/tmp/result.json', + grantId: 'grant-1', + content: '{"ok":false}' + }) + }) + + it('does not refresh and retry a failed terminal artifact save without a base content check', async () => { + const client = clientWithResponses([fail('terminal_file_grant_mismatch')]) + + await expect( + saveMobileTerminalArtifactPreview( + client, + { + source: 'terminalArtifact', + worktreeId: 'wt-1', + absolutePath: '/tmp/result.json', + grantId: 'grant-1', + terminalHandle: 'term-1', + pathText: 'result.json', + cwd: '/tmp/run' + }, + '{"ok":false}' + ) + ).resolves.toEqual({ + status: 'error', + message: 'Reload preview before saving', + reconnect: false + }) + + expect(client.sendRequest).toHaveBeenCalledTimes(1) + expect(client.sendRequest).toHaveBeenCalledWith('files.writeTerminalArtifact', { + worktree: 'id:wt-1', + absolutePath: '/tmp/result.json', + grantId: 'grant-1', + content: '{"ok":false}' + }) + }) + + it('refreshes an expired terminal artifact grant and retries save when the file still matches the base content', async () => { + const client = clientWithResponses([ + fail('terminal_file_grant_expired'), + ok({ + worktree: 'wt-1', + relativePath: null, + absolutePath: '/tmp/result.json', + exists: true, + isDirectory: false, + openTarget: { + kind: 'absolute-file', + absolutePath: '/tmp/result.json', + grantId: 'grant-2' + } + }), + ok({ content: '{"ok":true}', truncated: false, byteLength: 11 }), + ok({ ok: true }) + ]) + const onTerminalArtifactSourceRefreshed = vi.fn() + + await expect( + saveMobileTerminalArtifactPreview( + client, + { + source: 'terminalArtifact', + worktreeId: 'wt-1', + absolutePath: '/tmp/result.json', + grantId: 'grant-1', + terminalHandle: 'term-1', + pathText: 'result.json', + cwd: '/tmp/run' + }, + '{"ok":false}', + { baseContent: '{"ok":true}', onTerminalArtifactSourceRefreshed } + ) + ).resolves.toEqual({ status: 'saved' }) + + expect(client.sendRequest).toHaveBeenNthCalledWith(1, 'files.readTerminalArtifact', { + worktree: 'id:wt-1', + absolutePath: '/tmp/result.json', + grantId: 'grant-1' + }) + expect(client.sendRequest).toHaveBeenNthCalledWith(2, 'files.resolveTerminalPath', { + worktree: 'id:wt-1', + pathText: 'result.json', + cwd: '/tmp/run', + terminal: 'term-1' + }) + expect(client.sendRequest).toHaveBeenNthCalledWith(3, 'files.readTerminalArtifact', { + worktree: 'id:wt-1', + absolutePath: '/tmp/result.json', + grantId: 'grant-2' + }) + expect(client.sendRequest).toHaveBeenNthCalledWith(4, 'files.writeTerminalArtifact', { + worktree: 'id:wt-1', + absolutePath: '/tmp/result.json', + grantId: 'grant-2', + content: '{"ok":false}' + }) + expect(onTerminalArtifactSourceRefreshed).toHaveBeenCalledWith({ + source: 'terminalArtifact', + worktreeId: 'wt-1', + absolutePath: '/tmp/result.json', + grantId: 'grant-2', + terminalHandle: 'term-1', + pathText: 'result.json', + cwd: '/tmp/run' + }) + }) + + it('keeps the dirty draft when a refreshed terminal artifact save finds changed remote content', async () => { + const client = clientWithResponses([ + fail('terminal_file_grant_stale'), + ok({ + worktree: 'wt-1', + relativePath: null, + absolutePath: '/tmp/result.json', + exists: true, + isDirectory: false, + openTarget: { + kind: 'absolute-file', + absolutePath: '/tmp/result.json', + grantId: 'grant-2' + } + }), + ok({ content: '{"ok":"changed"}', truncated: false, byteLength: 16 }) + ]) + const onTerminalArtifactSourceRefreshed = vi.fn() + + await expect( + saveMobileTerminalArtifactPreview( + client, + { + source: 'terminalArtifact', + worktreeId: 'wt-1', + absolutePath: '/tmp/result.json', + grantId: 'grant-1', + terminalHandle: 'term-1' + }, + '{"ok":false}', + { baseContent: '{"ok":true}', onTerminalArtifactSourceRefreshed } + ) + ).resolves.toEqual({ + status: 'error', + message: 'File changed on desktop. Reload preview before saving', + reconnect: false + }) + + expect(client.sendRequest).toHaveBeenCalledTimes(3) + expect(client.sendRequest).not.toHaveBeenCalledWith('files.writeTerminalArtifact', { + worktree: 'id:wt-1', + absolutePath: '/tmp/result.json', + grantId: 'grant-2', + content: '{"ok":false}' + }) + expect(onTerminalArtifactSourceRefreshed).not.toHaveBeenCalled() + }) + + it('reports a failed refreshed artifact read instead of treating it as changed desktop content', async () => { + const client = clientWithResponses([ + fail('terminal_file_grant_stale'), + ok({ + worktree: 'wt-1', + relativePath: null, + absolutePath: '/tmp/result.json', + exists: true, + isDirectory: false, + openTarget: { + kind: 'absolute-file', + absolutePath: '/tmp/result.json', + grantId: 'grant-2' + } + }), + fail('provider unavailable') + ]) + + await expect( + saveMobileTerminalArtifactPreview( + client, + { + source: 'terminalArtifact', + worktreeId: 'wt-1', + absolutePath: '/tmp/result.json', + grantId: 'grant-1', + terminalHandle: 'term-1' + }, + '{"ok":false}', + { baseContent: '{"ok":true}' } + ) + ).resolves.toEqual({ + status: 'error', + message: 'Unable to reach the desktop filesystem', + reconnect: true + }) + + expect(client.sendRequest).toHaveBeenCalledTimes(3) + expect(client.sendRequest).not.toHaveBeenCalledWith('files.writeTerminalArtifact', { + worktree: 'id:wt-1', + absolutePath: '/tmp/result.json', + grantId: 'grant-2', + content: '{"ok":false}' + }) + }) + + it('reports a malformed refreshed artifact read instead of treating it as changed desktop content', async () => { + const client = clientWithResponses([ + fail('terminal_file_grant_stale'), + ok({ + worktree: 'wt-1', + relativePath: null, + absolutePath: '/tmp/result.json', + exists: true, + isDirectory: false, + openTarget: { + kind: 'absolute-file', + absolutePath: '/tmp/result.json', + grantId: 'grant-2' + } + }), + ok({ truncated: false, byteLength: 11 }) + ]) + + await expect( + saveMobileTerminalArtifactPreview( + client, + { + source: 'terminalArtifact', + worktreeId: 'wt-1', + absolutePath: '/tmp/result.json', + grantId: 'grant-1', + terminalHandle: 'term-1' + }, + '{"ok":false}', + { baseContent: '{"ok":true}' } + ) + ).resolves.toEqual({ + status: 'error', + message: 'Unable to load preview', + reconnect: false + }) + + expect(client.sendRequest).toHaveBeenCalledTimes(3) + expect(client.sendRequest).not.toHaveBeenCalledWith('files.writeTerminalArtifact', { + worktree: 'id:wt-1', + absolutePath: '/tmp/result.json', + grantId: 'grant-2', + content: '{"ok":false}' + }) + }) + + it('does not retry a dirty save when grant refresh resolves to a different artifact', async () => { + const client = clientWithResponses([ + fail('terminal_file_grant_expired'), + ok({ + worktree: 'wt-1', + relativePath: null, + absolutePath: '/tmp/b.json', + exists: true, + isDirectory: false, + openTarget: { + kind: 'absolute-file', + absolutePath: '/tmp/b.json', + grantId: 'grant-2' + } + }) + ]) + const onTerminalArtifactSourceRefreshed = vi.fn() + + await expect( + saveMobileTerminalArtifactPreview( + client, + { + source: 'terminalArtifact', + worktreeId: 'wt-1', + absolutePath: '/tmp/a.json', + grantId: 'grant-1', + terminalHandle: 'term-1', + pathText: '/tmp/link.json' + }, + '{"ok":false}', + { baseContent: '{"ok":true}', onTerminalArtifactSourceRefreshed } + ) + ).resolves.toEqual({ + status: 'error', + message: 'Reload preview before saving', + reconnect: false + }) + + expect(client.sendRequest).toHaveBeenCalledTimes(2) + expect(onTerminalArtifactSourceRefreshed).not.toHaveBeenCalled() + }) + it.each([ ['missing isBinary', { content: 'aW1hZ2U=', isImage: true, mimeType: 'image/png' }], ['missing isImage', { content: 'aW1hZ2U=', isBinary: true, mimeType: 'image/png' }], @@ -116,6 +588,7 @@ describe('mobile-file-preview-request', () => { true ], ['provider unavailable', 'Unable to reach the desktop filesystem', true], + ['terminal_file_grant_stale', 'Reload preview before saving', false], ['permission denied', 'Unable to load preview', false] ])('maps preview failure %s', (message, expected, reconnect) => { expect(normalizeMobileFilePreviewResponse('src/app.ts', fail(message))).toEqual({ diff --git a/mobile/src/files/mobile-file-preview-request.ts b/mobile/src/files/mobile-file-preview-request.ts index d7bdc4d3b65..ff6cd755c50 100644 --- a/mobile/src/files/mobile-file-preview-request.ts +++ b/mobile/src/files/mobile-file-preview-request.ts @@ -1,195 +1,256 @@ import { classifyMobileArtifact } from '../session/mobile-artifact-kind' -import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types' +import type { RpcFailure, RpcResponse } from '../transport/types' import type { RpcClient } from '../transport/rpc-client' -import { isMarkdownPath } from './file-tree' +import { + normalizeMobileFilePreviewResponse, + previewError, + type MobileFilePreviewResult +} from './mobile-file-preview-response' +import { + refreshTerminalArtifactSourceAfterGrantFailure, + type MobileTerminalArtifactPreviewSource, + type TerminalArtifactRetryOptions +} from './mobile-terminal-artifact-grant-refresh' + +export { + formatPreviewByteLength, + normalizeMobileFilePreviewResponse, + previewError +} from './mobile-file-preview-response' +export type { + MobileFilePreviewResult, + MobileFilePreviewTextKind +} from './mobile-file-preview-response' export type MobileFilePreviewReadMethod = 'files.read' | 'files.readPreview' +export type MobileTerminalArtifactPreviewReadMethod = + | 'files.readTerminalArtifact' + | 'files.readTerminalArtifactPreview' + +export type MobileFilePreviewSource = + | { + source: 'worktree' + worktreeId: string + relativePath: string + } + | MobileTerminalArtifactPreviewSource export type MobileFilePreviewRequest = { - method: MobileFilePreviewReadMethod + method: MobileFilePreviewReadMethod | MobileTerminalArtifactPreviewReadMethod params: { worktree: string - relativePath: string + relativePath?: string + absolutePath?: string + grantId?: string } } -export type MobileFilePreviewTextKind = 'html' | 'markdown' | 'text' - -export type MobileFilePreviewResult = - | { - status: 'loading' - message: string - } - | { - status: 'waiting' - message: string - reconnect: true - } - | { - status: 'ready' - kind: 'image' - dataUri: string - } - | { - status: 'ready' - kind: MobileFilePreviewTextKind - content: string - truncated: boolean - byteLength: number - } - | { - status: 'empty' - kind: MobileFilePreviewTextKind - } - | { - status: 'error' - message: string - reconnect: boolean - } - type MobileFilePreviewClient = Pick +type TerminalArtifactSource = MobileTerminalArtifactPreviewSource +type TerminalArtifactSaveOptions = TerminalArtifactRetryOptions & { + baseContent?: string +} export function createMobileFilePreviewRequest( - worktreeId: string, - relativePath: string + worktreeIdOrSource: string | MobileFilePreviewSource, + relativePath?: string ): MobileFilePreviewRequest { + const source = + typeof worktreeIdOrSource === 'string' + ? { source: 'worktree' as const, worktreeId: worktreeIdOrSource, relativePath: relativePath! } + : worktreeIdOrSource + if (source.source === 'terminalArtifact') { + const method = + classifyMobileArtifact(source.absolutePath) === 'image' + ? 'files.readTerminalArtifactPreview' + : 'files.readTerminalArtifact' + return { + method, + params: { + worktree: `id:${source.worktreeId}`, + absolutePath: source.absolutePath, + grantId: source.grantId + } + } + } return { - method: classifyMobileArtifact(relativePath) === 'image' ? 'files.readPreview' : 'files.read', + method: + classifyMobileArtifact(source.relativePath) === 'image' ? 'files.readPreview' : 'files.read', params: { - worktree: `id:${worktreeId}`, - relativePath + worktree: `id:${source.worktreeId}`, + relativePath: source.relativePath } } } export async function loadMobileFilePreview( client: MobileFilePreviewClient, - worktreeId: string, - relativePath: string + worktreeIdOrSource: string | MobileFilePreviewSource, + relativePath?: string, + options: TerminalArtifactRetryOptions = {} ): Promise { - const request = createMobileFilePreviewRequest(worktreeId, relativePath) - const response = await client.sendRequest(request.method, request.params) - return normalizeMobileFilePreviewResponse(relativePath, response) + let source = worktreeIdOrSource + let request = createMobileFilePreviewRequest(source, relativePath) + let response = await client.sendRequest(request.method, request.params) + if (!response.ok && typeof source !== 'string' && source.source === 'terminalArtifact') { + const refreshed = await refreshTerminalArtifactSourceAfterGrantFailure( + client, + source, + response, + options + ) + if (refreshed) { + source = refreshed + options.onTerminalArtifactSourceRefreshed?.(refreshed) + request = createMobileFilePreviewRequest(source, relativePath) + response = await client.sendRequest(request.method, request.params) + } + } + const previewPath = typeof source === 'string' ? relativePath! : previewPathForSource(source) + return normalizeMobileFilePreviewResponse(previewPath, response) } -export function normalizeMobileFilePreviewResponse( - relativePath: string, - response: RpcResponse -): MobileFilePreviewResult { +export async function saveMobileTerminalArtifactPreview( + client: MobileFilePreviewClient, + source: TerminalArtifactSource, + content: string, + options: TerminalArtifactSaveOptions = {} +): Promise { + let writeSource = source + if (typeof options.baseContent === 'string') { + const verified = await verifyTerminalArtifactBaseContent( + client, + writeSource, + options.baseContent, + options + ) + if (verified.status === 'error') { + return verified.error + } + writeSource = verified.source + if (verified.refreshed) { + options.onTerminalArtifactSourceRefreshed?.(verified.source) + } + } + let response = await writeTerminalArtifactPreview(client, writeSource, content) + if (response.ok) { + return { status: 'saved' } + } + + if (typeof options.baseContent !== 'string') { + return previewError( + (response as RpcFailure).error.message || (response as RpcFailure).error.code + ) + } + const refreshed = await refreshTerminalArtifactSourceAfterGrantFailure( + client, + writeSource, + response, + options + ) + if (!refreshed) { + return previewError( + (response as RpcFailure).error.message || (response as RpcFailure).error.code + ) + } + const verified = await verifyTerminalArtifactBaseContent(client, refreshed, options.baseContent, { + refreshGrant: false + }) + if (verified.status === 'error') { + return verified.error + } + options.onTerminalArtifactSourceRefreshed?.(refreshed) + writeSource = verified.source + response = await writeTerminalArtifactPreview(client, writeSource, content) if (!response.ok) { return previewError( (response as RpcFailure).error.message || (response as RpcFailure).error.code ) } - - const result = (response as RpcSuccess).result - if (classifyMobileArtifact(relativePath) === 'image') { - return normalizeImagePreviewResult(result) - } - return normalizeTextPreviewResult(relativePath, result) + return { status: 'saved' } } -export function previewError(message: string): MobileFilePreviewResult { - const normalized = message.toLowerCase() - if (normalized === 'binary_file' || normalized.includes('binary_file')) { - return { status: 'error', message: 'Binary preview unavailable', reconnect: false } +async function verifyTerminalArtifactBaseContent( + client: MobileFilePreviewClient, + source: TerminalArtifactSource, + baseContent: string, + options: TerminalArtifactRetryOptions +): Promise< + | { status: 'ok'; source: TerminalArtifactSource; refreshed: boolean } + | { status: 'error'; error: MobileFilePreviewResult } +> { + let readSource = source + let request = createMobileFilePreviewRequest(readSource) + let response = await client.sendRequest(request.method, request.params) + let refreshed = false + if (!response.ok) { + const nextSource = await refreshTerminalArtifactSourceAfterGrantFailure( + client, + readSource, + response, + options + ) + if (!nextSource) { + return { + status: 'error', + error: previewError( + (response as RpcFailure).error.message || (response as RpcFailure).error.code + ) + } + } + readSource = nextSource + refreshed = true + request = createMobileFilePreviewRequest(readSource) + response = await client.sendRequest(request.method, request.params) } - if (normalized === 'file_too_large' || normalized.includes('file_too_large')) { - return { status: 'error', message: 'File too large for mobile preview', reconnect: false } + if (!response.ok) { + return { + status: 'error', + error: previewError( + (response as RpcFailure).error.message || (response as RpcFailure).error.code + ) + } } - if ( - normalized.includes('remote connection dropped') || - normalized.includes('provider unavailable') || - normalized.includes('disconnected') - ) { - return { status: 'error', message: 'Unable to reach the desktop filesystem', reconnect: true } + const latest = normalizeMobileFilePreviewResponse(readSource.absolutePath, response) + if (latest.status === 'error' || latest.status === 'waiting') { + return { status: 'error', error: latest } } - if ( - normalized.includes('enoent') || - normalized.includes('no such file') || - normalized.includes('not found') || - normalized.includes('does not exist') - ) { - return { status: 'error', message: 'File not found', reconnect: false } + if (!terminalArtifactPreviewMatchesBase(latest, baseContent)) { + return { + status: 'error', + error: { + status: 'error', + message: 'File changed on desktop. Reload preview before saving', + reconnect: false + } + } } - return { status: 'error', message: 'Unable to load preview', reconnect: false } + return { status: 'ok', source: readSource, refreshed } } -export function formatPreviewByteLength(byteLength: number): string { - if (!Number.isFinite(byteLength) || byteLength < 0) { - return 'unknown size' - } - if (byteLength < 1024) { - return `${byteLength} B` - } - if (byteLength < 1024 * 1024) { - return `${Math.round(byteLength / 1024)} KB` - } - return `${(byteLength / (1024 * 1024)).toFixed(1)} MB` +function writeTerminalArtifactPreview( + client: MobileFilePreviewClient, + source: TerminalArtifactSource, + content: string +): Promise { + return client.sendRequest('files.writeTerminalArtifact', { + worktree: `id:${source.worktreeId}`, + absolutePath: source.absolutePath, + grantId: source.grantId, + content + }) } -function normalizeImagePreviewResult(result: unknown): MobileFilePreviewResult { - if (!result || typeof result !== 'object') { - return previewError('binary_file') - } - const preview = result as { - content?: unknown - isBinary?: unknown - isImage?: unknown - mimeType?: unknown - } - if ( - preview.isBinary !== true || - preview.isImage !== true || - typeof preview.mimeType !== 'string' || - preview.mimeType.length === 0 || - typeof preview.content !== 'string' || - preview.content.length === 0 - ) { - return previewError('binary_file') - } - return { - status: 'ready', - kind: 'image', - dataUri: `data:${preview.mimeType};base64,${preview.content}` +function terminalArtifactPreviewMatchesBase( + preview: MobileFilePreviewResult, + baseContent: string +): boolean { + if (preview.status === 'empty') { + return baseContent.length === 0 } + return preview.status === 'ready' && preview.kind !== 'image' && preview.content === baseContent } -function normalizeTextPreviewResult( - relativePath: string, - result: unknown -): MobileFilePreviewResult { - if (!result || typeof result !== 'object') { - return previewError('Unable to load preview') - } - const preview = result as { - content?: unknown - truncated?: unknown - byteLength?: unknown - isBinary?: unknown - } - if (preview.isBinary === true) { - return previewError('binary_file') - } - if (typeof preview.content !== 'string') { - return previewError('Unable to load preview') - } - const kind = textKindForPreviewPath(relativePath) - if (preview.content.length === 0) { - return { status: 'empty', kind } - } - return { - status: 'ready', - kind, - content: preview.content, - truncated: preview.truncated === true, - byteLength: typeof preview.byteLength === 'number' ? preview.byteLength : preview.content.length - } -} - -function textKindForPreviewPath(relativePath: string): MobileFilePreviewTextKind { - if (classifyMobileArtifact(relativePath) === 'html') { - return 'html' - } - return isMarkdownPath(relativePath) ? 'markdown' : 'text' +function previewPathForSource(source: MobileFilePreviewSource): string { + return source.source === 'terminalArtifact' ? source.absolutePath : source.relativePath } diff --git a/mobile/src/files/mobile-file-preview-response.ts b/mobile/src/files/mobile-file-preview-response.ts new file mode 100644 index 00000000000..0b7b1b23be8 --- /dev/null +++ b/mobile/src/files/mobile-file-preview-response.ts @@ -0,0 +1,164 @@ +import { classifyMobileArtifact } from '../session/mobile-artifact-kind' +import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types' +import { isMarkdownPath } from './file-tree' +import { isTerminalArtifactGrantError } from './terminal-artifact-grant-error' + +export type MobileFilePreviewTextKind = 'html' | 'markdown' | 'text' + +export type MobileFilePreviewResult = + | { + status: 'loading' + message: string + } + | { + status: 'waiting' + message: string + reconnect: true + } + | { + status: 'ready' + kind: 'image' + dataUri: string + } + | { + status: 'ready' + kind: MobileFilePreviewTextKind + content: string + truncated: boolean + byteLength: number + } + | { + status: 'empty' + kind: MobileFilePreviewTextKind + } + | { + status: 'error' + message: string + reconnect: boolean + } + +export function normalizeMobileFilePreviewResponse( + relativePath: string, + response: RpcResponse +): MobileFilePreviewResult { + if (!response.ok) { + return previewError( + (response as RpcFailure).error.message || (response as RpcFailure).error.code + ) + } + + const result = (response as RpcSuccess).result + if (classifyMobileArtifact(relativePath) === 'image') { + return normalizeImagePreviewResult(result) + } + return normalizeTextPreviewResult(relativePath, result) +} + +export function previewError(message: string): MobileFilePreviewResult { + const normalized = message.toLowerCase() + if (normalized === 'binary_file' || normalized.includes('binary_file')) { + return { status: 'error', message: 'Binary preview unavailable', reconnect: false } + } + if (normalized === 'file_too_large' || normalized.includes('file_too_large')) { + return { status: 'error', message: 'File too large for mobile preview', reconnect: false } + } + if (isTerminalArtifactGrantError(normalized)) { + return { status: 'error', message: 'Reload preview before saving', reconnect: false } + } + if ( + normalized.includes('remote connection dropped') || + normalized.includes('provider unavailable') || + normalized.includes('disconnected') || + normalized.includes('reconnect the ssh target') + ) { + return { status: 'error', message: 'Unable to reach the desktop filesystem', reconnect: true } + } + if ( + normalized.includes('enoent') || + normalized.includes('no such file') || + normalized.includes('not found') || + normalized.includes('does not exist') + ) { + return { status: 'error', message: 'File not found', reconnect: false } + } + return { status: 'error', message: 'Unable to load preview', reconnect: false } +} + +export function formatPreviewByteLength(byteLength: number): string { + if (!Number.isFinite(byteLength) || byteLength < 0) { + return 'unknown size' + } + if (byteLength < 1024) { + return `${byteLength} B` + } + if (byteLength < 1024 * 1024) { + return `${Math.round(byteLength / 1024)} KB` + } + return `${(byteLength / (1024 * 1024)).toFixed(1)} MB` +} + +function normalizeImagePreviewResult(result: unknown): MobileFilePreviewResult { + if (!result || typeof result !== 'object') { + return previewError('binary_file') + } + const preview = result as { + content?: unknown + isBinary?: unknown + isImage?: unknown + mimeType?: unknown + } + if ( + preview.isBinary !== true || + preview.isImage !== true || + typeof preview.mimeType !== 'string' || + preview.mimeType.length === 0 || + typeof preview.content !== 'string' || + preview.content.length === 0 + ) { + return previewError('binary_file') + } + return { + status: 'ready', + kind: 'image', + dataUri: `data:${preview.mimeType};base64,${preview.content}` + } +} + +function normalizeTextPreviewResult( + relativePath: string, + result: unknown +): MobileFilePreviewResult { + if (!result || typeof result !== 'object') { + return previewError('Unable to load preview') + } + const preview = result as { + content?: unknown + truncated?: unknown + byteLength?: unknown + isBinary?: unknown + } + if (preview.isBinary === true) { + return previewError('binary_file') + } + if (typeof preview.content !== 'string') { + return previewError('Unable to load preview') + } + const kind = textKindForPreviewPath(relativePath) + if (preview.content.length === 0) { + return { status: 'empty', kind } + } + return { + status: 'ready', + kind, + content: preview.content, + truncated: preview.truncated === true, + byteLength: typeof preview.byteLength === 'number' ? preview.byteLength : preview.content.length + } +} + +function textKindForPreviewPath(relativePath: string): MobileFilePreviewTextKind { + if (classifyMobileArtifact(relativePath) === 'html') { + return 'html' + } + return isMarkdownPath(relativePath) ? 'markdown' : 'text' +} diff --git a/mobile/src/files/mobile-file-preview-route.test.ts b/mobile/src/files/mobile-file-preview-route.test.ts index b4cab941805..ac1888ed46f 100644 --- a/mobile/src/files/mobile-file-preview-route.test.ts +++ b/mobile/src/files/mobile-file-preview-route.test.ts @@ -22,12 +22,70 @@ describe('mobile-file-preview-route', () => { hostId: 'host-1', worktreeId: 'wt-1', relativePath, + source: 'worktree', + line: undefined, + column: undefined, name: 'note.md', worktreeName: 'Orca' } }) }) + it('normalizes worktree preview line and column params', () => { + const route = normalizeMobileFilePreviewRouteParams({ + hostId: 'host-1', + worktreeId: 'wt-1', + relativePath: 'src/app.ts', + line: '120', + column: '7' + }) + + expect(route).toEqual({ + ok: true, + params: { + hostId: 'host-1', + worktreeId: 'wt-1', + relativePath: 'src/app.ts', + source: 'worktree', + line: '120', + column: '7', + name: undefined, + worktreeName: undefined + } + }) + }) + + it('normalizes terminal artifact params without requiring a relative path', () => { + const route = normalizeMobileFilePreviewRouteParams({ + hostId: 'host-1', + worktreeId: 'wt-1', + source: 'terminalArtifact', + absolutePath: '/tmp/result.json', + grantId: 'grant-1', + terminal: 'term-1', + pathText: 'result.json', + cwd: '/tmp/run', + line: '12', + column: '3' + }) + + expect(route).toEqual({ + ok: true, + params: { + hostId: 'host-1', + worktreeId: 'wt-1', + source: 'terminalArtifact', + absolutePath: '/tmp/result.json', + grantId: 'grant-1', + terminal: 'term-1', + pathText: 'result.json', + cwd: '/tmp/run', + line: '12', + column: '3' + } + }) + }) + it('rejects missing, empty, or array-valued required params', () => { expect(normalizeMobileFilePreviewRouteParams({ hostId: 'h', worktreeId: 'w' })).toEqual({ ok: false, diff --git a/mobile/src/files/mobile-file-preview-route.ts b/mobile/src/files/mobile-file-preview-route.ts index b758c1214cf..bbc3d0e8f88 100644 --- a/mobile/src/files/mobile-file-preview-route.ts +++ b/mobile/src/files/mobile-file-preview-route.ts @@ -3,7 +3,15 @@ export type MobileFilePreviewParamValue = string | string[] | undefined export type MobileFilePreviewRouteParams = { hostId: string worktreeId: string - relativePath: string + relativePath?: string + source?: 'worktree' | 'terminalArtifact' + absolutePath?: string + grantId?: string + terminal?: string + pathText?: string + cwd?: string + line?: string + column?: string name?: string worktreeName?: string } @@ -21,6 +29,14 @@ type RawPreviewRouteParams = { hostId?: MobileFilePreviewParamValue worktreeId?: MobileFilePreviewParamValue relativePath?: MobileFilePreviewParamValue + source?: MobileFilePreviewParamValue + absolutePath?: MobileFilePreviewParamValue + grantId?: MobileFilePreviewParamValue + terminal?: MobileFilePreviewParamValue + pathText?: MobileFilePreviewParamValue + cwd?: MobileFilePreviewParamValue + line?: MobileFilePreviewParamValue + column?: MobileFilePreviewParamValue name?: MobileFilePreviewParamValue worktreeName?: MobileFilePreviewParamValue } @@ -39,7 +55,35 @@ export function normalizeMobileFilePreviewRouteParams( const hostId = singleParam(params.hostId) const worktreeId = singleParam(params.worktreeId) const relativePath = singleParam(params.relativePath) - if (!hostId || !worktreeId || !relativePath) { + const source = singleParam(params.source) + const absolutePath = singleParam(params.absolutePath) + const grantId = singleParam(params.grantId) + if (!hostId || !worktreeId) { + return { ok: false, message: 'Unable to load preview' } + } + if (source === 'terminalArtifact') { + if (!absolutePath || !grantId) { + return { ok: false, message: 'Unable to load preview' } + } + return { + ok: true, + params: { + hostId, + worktreeId, + source, + absolutePath, + grantId, + terminal: optionalSingleParam(params.terminal), + pathText: optionalSingleParam(params.pathText), + cwd: optionalSingleParam(params.cwd), + line: optionalSingleParam(params.line), + column: optionalSingleParam(params.column), + name: optionalSingleParam(params.name), + worktreeName: optionalSingleParam(params.worktreeName) + } + } + } + if (!relativePath) { return { ok: false, message: 'Unable to load preview' } } return { @@ -48,6 +92,9 @@ export function normalizeMobileFilePreviewRouteParams( hostId, worktreeId, relativePath, + source: 'worktree', + line: optionalSingleParam(params.line), + column: optionalSingleParam(params.column), name: optionalSingleParam(params.name), worktreeName: optionalSingleParam(params.worktreeName) } diff --git a/mobile/src/files/mobile-file-preview-source.test.ts b/mobile/src/files/mobile-file-preview-source.test.ts new file mode 100644 index 00000000000..b5fec782cfc --- /dev/null +++ b/mobile/src/files/mobile-file-preview-source.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { sourceKeyForPreview } from './mobile-file-preview-source' + +describe('mobile-file-preview-source', () => { + it('uses structured preview keys so colons in paths cannot collide', () => { + const first = sourceKeyForPreview({ + source: 'terminalArtifact', + worktreeId: 'wt:1', + absolutePath: '/tmp/a', + grantId: 'grant-1', + terminalHandle: 'b:c' + }) + const second = sourceKeyForPreview({ + source: 'terminalArtifact', + worktreeId: 'wt', + absolutePath: '1:/tmp/a:b', + grantId: 'grant-2', + terminalHandle: 'c' + }) + + expect(first).toBe(JSON.stringify(['terminal', 'wt:1', '/tmp/a', 'b:c'])) + expect(second).toBe(JSON.stringify(['terminal', 'wt', '1:/tmp/a:b', 'c'])) + expect(first).not.toBe(second) + expect( + sourceKeyForPreview({ source: 'worktree', worktreeId: 'wt:1', relativePath: 'a:b.ts' }) + ).toBe(JSON.stringify(['worktree', 'wt:1', 'a:b.ts'])) + }) +}) diff --git a/mobile/src/files/mobile-file-preview-source.ts b/mobile/src/files/mobile-file-preview-source.ts new file mode 100644 index 00000000000..b1b23757a6e --- /dev/null +++ b/mobile/src/files/mobile-file-preview-source.ts @@ -0,0 +1,39 @@ +import type { MobileFilePreviewRouteParams } from './mobile-file-preview-route' +import type { MobileFilePreviewSource } from './mobile-file-preview-request' + +export function previewSourceFromRoute( + params: MobileFilePreviewRouteParams +): MobileFilePreviewSource | null { + if (params.source === 'terminalArtifact') { + if (!params.absolutePath || !params.grantId) { + return null + } + return { + source: 'terminalArtifact', + worktreeId: params.worktreeId, + absolutePath: params.absolutePath, + grantId: params.grantId, + ...(params.terminal ? { terminalHandle: params.terminal } : {}), + ...(params.pathText ? { pathText: params.pathText } : {}), + ...(params.cwd ? { cwd: params.cwd } : {}) + } + } + if (!params.relativePath) { + return null + } + return { source: 'worktree', worktreeId: params.worktreeId, relativePath: params.relativePath } +} + +export function sourceKeyForPreview(source: MobileFilePreviewSource | null): string | null { + if (!source) { + return null + } + return source.source === 'terminalArtifact' + ? JSON.stringify([ + 'terminal', + source.worktreeId, + source.absolutePath, + source.terminalHandle ?? '' + ]) + : JSON.stringify(['worktree', source.worktreeId, source.relativePath]) +} diff --git a/mobile/src/files/mobile-file-preview-styles.ts b/mobile/src/files/mobile-file-preview-styles.ts index 0e7c2c5db22..5b0df61caa5 100644 --- a/mobile/src/files/mobile-file-preview-styles.ts +++ b/mobile/src/files/mobile-file-preview-styles.ts @@ -73,6 +73,17 @@ export const filePreviewStyles = StyleSheet.create({ fontSize: typography.bodySize, fontWeight: '600' }, + saveButton: { + width: 36, + height: 36, + alignItems: 'center', + justifyContent: 'center', + borderRadius: radii.button, + backgroundColor: colors.bgRaised + }, + saveButtonDisabled: { + opacity: 0.42 + }, scroll: { flex: 1, backgroundColor: colors.editorSurface @@ -136,5 +147,23 @@ export const filePreviewStyles = StyleSheet.create({ }, image: { backgroundColor: colors.editorSurface + }, + editContainer: { + flex: 1, + backgroundColor: colors.editorSurface, + padding: spacing.md + }, + saveErrorText: { + marginBottom: spacing.sm, + color: colors.statusRed, + fontSize: typography.metaSize + }, + editInput: { + flex: 1, + color: colors.textPrimary, + fontFamily: typography.monoFamily, + fontSize: 13, + lineHeight: 19, + padding: 0 } }) diff --git a/mobile/src/files/mobile-terminal-artifact-grant-refresh.ts b/mobile/src/files/mobile-terminal-artifact-grant-refresh.ts new file mode 100644 index 00000000000..a8864119be4 --- /dev/null +++ b/mobile/src/files/mobile-terminal-artifact-grant-refresh.ts @@ -0,0 +1,88 @@ +import type { RpcClient } from '../transport/rpc-client' +import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types' +import { isTerminalArtifactGrantError } from './terminal-artifact-grant-error' + +type MobileFilePreviewClient = Pick + +export type MobileTerminalArtifactPreviewSource = { + source: 'terminalArtifact' + worktreeId: string + absolutePath: string + grantId: string + terminalHandle?: string + pathText?: string + cwd?: string +} + +export type TerminalArtifactRetryOptions = { + onTerminalArtifactSourceRefreshed?: (source: MobileTerminalArtifactPreviewSource) => void + refreshGrant?: boolean +} + +export async function refreshTerminalArtifactSourceAfterGrantFailure( + client: MobileFilePreviewClient, + source: MobileTerminalArtifactPreviewSource, + response: RpcResponse, + options: TerminalArtifactRetryOptions = {} +): Promise { + if (response.ok || !isTerminalArtifactGrantFailure(response, options)) { + return null + } + const refreshed = await client.sendRequest('files.resolveTerminalPath', { + worktree: `id:${source.worktreeId}`, + pathText: source.pathText ?? source.absolutePath, + ...(source.cwd ? { cwd: source.cwd } : {}), + ...(source.terminalHandle ? { terminal: source.terminalHandle } : {}) + }) + if (!refreshed.ok) { + return null + } + const result = (refreshed as RpcSuccess).result + if (!isTerminalArtifactResolution(result)) { + return null + } + if (result.openTarget.absolutePath !== source.absolutePath) { + return null + } + return { + source: 'terminalArtifact', + worktreeId: source.worktreeId, + absolutePath: result.openTarget.absolutePath, + grantId: result.openTarget.grantId, + ...(source.terminalHandle ? { terminalHandle: source.terminalHandle } : {}), + ...(source.pathText ? { pathText: source.pathText } : {}), + ...(source.cwd ? { cwd: source.cwd } : {}) + } +} + +function isTerminalArtifactGrantFailure( + response: RpcFailure, + options: TerminalArtifactRetryOptions +): boolean { + if (options.refreshGrant === false) { + return false + } + return isTerminalArtifactGrantError(`${response.error.code} ${response.error.message}`) +} + +function isTerminalArtifactResolution(result: unknown): result is { + exists: true + isDirectory: false + openTarget: { kind: 'absolute-file'; absolutePath: string; grantId: string } +} { + if (!result || typeof result !== 'object') { + return false + } + const resolution = result as { + exists?: unknown + isDirectory?: unknown + openTarget?: { kind?: unknown; absolutePath?: unknown; grantId?: unknown } + } + return ( + resolution.exists === true && + resolution.isDirectory === false && + resolution.openTarget?.kind === 'absolute-file' && + typeof resolution.openTarget.absolutePath === 'string' && + typeof resolution.openTarget.grantId === 'string' + ) +} diff --git a/mobile/src/files/terminal-artifact-grant-error.ts b/mobile/src/files/terminal-artifact-grant-error.ts new file mode 100644 index 00000000000..25a33fb8402 --- /dev/null +++ b/mobile/src/files/terminal-artifact-grant-error.ts @@ -0,0 +1,11 @@ +// Why: the runtime rejects stale terminal-artifact grants with these codes, but +// the RPC mux flattens error shapes to code+message strings, so mobile matches +// by substring. Single source so grant refresh and preview errors can't drift. +export function isTerminalArtifactGrantError(text: string): boolean { + const normalized = text.toLowerCase() + return ( + normalized.includes('terminal_file_grant_expired') || + normalized.includes('terminal_file_grant_mismatch') || + normalized.includes('terminal_file_grant_stale') + ) +} diff --git a/mobile/src/mobile-release/prepare-android-release-script.test.ts b/mobile/src/mobile-release/prepare-android-release-script.test.ts new file mode 100644 index 00000000000..668b4689bbf --- /dev/null +++ b/mobile/src/mobile-release/prepare-android-release-script.test.ts @@ -0,0 +1,92 @@ +import { execFileSync, spawnSync } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' + +const scriptPath = fileURLToPath( + new URL('../../scripts/prepare-android-release.mjs', import.meta.url) +) + +const appConfig = { + expo: { + version: '0.0.22', + android: { + versionCode: 4 + } + } +} + +let tempDirs: string[] = [] + +function createAppConfig() { + const dir = mkdtempSync(join(tmpdir(), 'orca-android-release-')) + tempDirs.push(dir) + const configPath = join(dir, 'app.json') + const contents = `${JSON.stringify(appConfig, null, 2)}\n` + writeFileSync(configPath, contents) + return { configPath, contents } +} + +describe('prepare Android release script', () => { + afterEach(() => { + for (const dir of tempDirs) { + rmSync(dir, { force: true, recursive: true }) + } + tempDirs = [] + }) + + it('uses committed Android release identity without mutating app config', () => { + const { configPath, contents } = createAppConfig() + + const output = execFileSync(process.execPath, [scriptPath], { + encoding: 'utf8', + env: { + ...process.env, + MOBILE_APP_CONFIG_PATH: configPath, + MOBILE_ANDROID_PUBLISH_RELEASE: 'true' + } + }) + + expect(output).toContain('Prepared Orca Mobile Android 0.0.22 (4)') + expect(output).toContain('Release tag: mobile-android-v0.0.22') + expect(readFileSync(configPath, 'utf8')).toBe(contents) + }) + + it('rejects release-only Android versionCode bumps', () => { + const { configPath } = createAppConfig() + + const result = spawnSync(process.execPath, [scriptPath], { + encoding: 'utf8', + env: { + ...process.env, + MOBILE_APP_CONFIG_PATH: configPath, + MOBILE_ANDROID_BUMP_VERSION_CODE: 'true' + } + }) + + expect(result.status).toBe(1) + expect(result.stderr).toContain( + 'Android versionCode changes must be committed in mobile/app.json before release' + ) + }) + + it('rejects release versions that do not match committed app config', () => { + const { configPath } = createAppConfig() + + const result = spawnSync(process.execPath, [scriptPath], { + encoding: 'utf8', + env: { + ...process.env, + MOBILE_APP_CONFIG_PATH: configPath, + MOBILE_ANDROID_RELEASE_VERSION: '0.0.23' + } + }) + + expect(result.status).toBe(1) + expect(result.stderr).toContain( + 'MOBILE_ANDROID_RELEASE_VERSION must match the committed mobile app version' + ) + }) +}) diff --git a/mobile/src/session/MobileTerminalLiveInputStatus.tsx b/mobile/src/session/MobileTerminalLiveInputStatus.tsx index bde542ab342..dc135c3eb60 100644 --- a/mobile/src/session/MobileTerminalLiveInputStatus.tsx +++ b/mobile/src/session/MobileTerminalLiveInputStatus.tsx @@ -27,9 +27,11 @@ export function MobileTerminalLiveInputStatus({ ? 'Tap mic to stop' : dictation.isProcessing ? 'Transcribing on desktop' - : isAttaching - ? 'Uploading image to host' - : 'Keyboard goes directly to terminal' + : dictation.isStarting + ? 'Preparing microphone' + : isAttaching + ? 'Uploading image to host' + : 'Tap to show keyboard' return ( diff --git a/mobile/src/session/mobile-image-attachment.test.ts b/mobile/src/session/mobile-image-attachment.test.ts index 3a94348aab4..1bf3df59e74 100644 --- a/mobile/src/session/mobile-image-attachment.test.ts +++ b/mobile/src/session/mobile-image-attachment.test.ts @@ -118,4 +118,30 @@ describe('attachMobileImageToTerminal', () => { const sendCall = client.calls.find((c) => c.method === 'terminal.send') expect(sendCall?.params).not.toHaveProperty('client') }) + + it('waits for pending live input before sending the image payload', async () => { + const client = clientWithResponses([ + { + id: 'start', + ok: false, + error: { code: 'method_not_found', message: 'no' }, + _meta: { runtimeId: 'r' } + }, + ok('save', '/tmp/pending.png') + ]) + const beforeTerminalSend = vi.fn(async () => false) + + const sent = await attachMobileImageToTerminal('library', { + client, + terminal: 'term-pending', + deviceToken: null, + getConnectionId: async () => null, + pickImage: vi.fn().mockResolvedValue({ base64: 'DDDD' }), + beforeTerminalSend + }) + + expect(sent).toBe(false) + expect(beforeTerminalSend).toHaveBeenCalledWith('term-pending') + expect(client.calls.some((call) => call.method === 'terminal.send')).toBe(false) + }) }) diff --git a/mobile/src/session/mobile-image-attachment.ts b/mobile/src/session/mobile-image-attachment.ts index 507b58eb671..b7d14d9a04d 100644 --- a/mobile/src/session/mobile-image-attachment.ts +++ b/mobile/src/session/mobile-image-attachment.ts @@ -16,6 +16,7 @@ export type AttachMobileImageDeps = { // start — lets the UI show a sending spinner only for the transfer, not the // (potentially long) time the picker is open. readonly onUploadStart?: () => void + readonly beforeTerminalSend?: (terminal: string) => Promise } // Uploads a picked image to the host and pastes the resulting file path into the @@ -30,7 +31,8 @@ export async function attachMobileImageToTerminal( deviceToken, getConnectionId, pickImage, - onUploadStart + onUploadStart, + beforeTerminalSend }: AttachMobileImageDeps ): Promise { const picked = await pickImage(source) @@ -45,6 +47,9 @@ export async function attachMobileImageToTerminal( // Why: a generated image path is terminal image injection, so it's always // bracketed (matching desktop paste) regardless of terminal mode. const payload = buildMobileImagePastePayload(imagePath) + if (beforeTerminalSend && !(await beforeTerminalSend(terminal))) { + return false + } await client.sendRequest('terminal.send', { terminal, text: payload, diff --git a/mobile/src/session/mobile-terminal-file-tap-open.test.ts b/mobile/src/session/mobile-terminal-file-tap-open.test.ts new file mode 100644 index 00000000000..fec1b5b663c --- /dev/null +++ b/mobile/src/session/mobile-terminal-file-tap-open.test.ts @@ -0,0 +1,432 @@ +import { describe, expect, it, vi } from 'vitest' +import { openMobileTerminalFileTap } from './mobile-terminal-file-tap-open' + +function ok(result: unknown) { + return { ok: true, result, _meta: { runtimeId: 'runtime-1' } } +} + +function createClient(responses: unknown[]) { + return { + sendRequest: vi.fn(async () => responses.shift()) + } +} + +function activeTerminalState(activated: boolean) { + return { + activated, + activationSeq: 1, + latestActivationSeq: 1, + sourceTerminalHandle: 'terminal-1', + activeTerminalHandle: 'terminal-1', + activeTabType: 'terminal' + } +} + +describe('openMobileTerminalFileTap', () => { + it('opens absolute terminal artifacts through the grant-backed preview route', async () => { + const client = createClient([ + ok({ + worktree: 'wt-1', + relativePath: null, + absolutePath: '/tmp/result.json', + exists: true, + isDirectory: false, + openTarget: { + kind: 'absolute-file', + provider: 'local', + absolutePath: '/tmp/result.json', + grantId: 'grant-1' + } + }) + ]) + const pushPreviewRoute = vi.fn() + const triggerOpenFeedback = vi.fn() + + openMobileTerminalFileTap({ + client, + hostId: 'host-1', + worktreeId: 'wt-1', + pathText: '/tmp/result.json', + terminalHandle: 'terminal-1', + line: 12, + column: 3, + pushPreviewRoute, + openBrowser: vi.fn(), + triggerOpenFeedback, + fetchSessionTabs: vi.fn(), + getSessionTabs: () => [], + getActiveSessionTabId: () => null, + getActivationState: activeTerminalState, + switchSessionTab: vi.fn(), + scheduleDelayedAction: vi.fn() + }) + await Promise.resolve() + + expect(client.sendRequest).toHaveBeenCalledWith( + 'files.resolveTerminalPath', + { worktree: 'id:wt-1', pathText: '/tmp/result.json', terminal: 'terminal-1' }, + { timeoutMs: 10_000 } + ) + expect(pushPreviewRoute).toHaveBeenCalledWith({ + pathname: '/h/[hostId]/files/preview/[worktreeId]', + params: expect.objectContaining({ + hostId: 'host-1', + worktreeId: 'wt-1', + source: 'terminalArtifact', + absolutePath: '/tmp/result.json', + grantId: 'grant-1', + pathText: '/tmp/result.json', + terminal: 'terminal-1', + line: '12', + column: '3' + }) + }) + expect(triggerOpenFeedback).toHaveBeenCalledTimes(1) + expect(client.sendRequest).not.toHaveBeenCalledWith('files.open', expect.anything()) + }) + + it('preserves the worktree-contained files.open flow', async () => { + const client = createClient([ + ok({ + worktree: 'wt-1', + relativePath: 'src/index.ts', + absolutePath: '/repo/src/index.ts', + exists: true, + isDirectory: false, + openTarget: { + kind: 'worktree-file', + provider: 'local', + relativePath: 'src/index.ts', + absolutePath: '/repo/src/index.ts' + } + }), + ok({ opened: true }) + ]) + const scheduleDelayedAction = vi.fn((callback: () => void) => callback()) + const openedTab = { id: 'tab-2', relativePath: 'src/index.ts' } + const switchSessionTab = vi.fn() + + openMobileTerminalFileTap({ + client, + hostId: 'host-1', + worktreeId: 'wt-1', + pathText: 'src/index.ts', + line: null, + column: null, + pushPreviewRoute: vi.fn(), + openBrowser: vi.fn(), + triggerOpenFeedback: vi.fn(), + fetchSessionTabs: vi.fn(), + getSessionTabs: () => [openedTab], + getActiveSessionTabId: () => 'terminal-tab', + getActivationState: activeTerminalState, + switchSessionTab, + scheduleDelayedAction + }) + await Promise.resolve() + await Promise.resolve() + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(client.sendRequest).toHaveBeenCalledWith( + 'files.open', + { worktree: 'id:wt-1', relativePath: 'src/index.ts' }, + { timeoutMs: 15_000 } + ) + expect(switchSessionTab).toHaveBeenCalledWith(openedTab) + }) + + it('opens worktree-contained line references through the preview route', async () => { + const client = createClient([ + ok({ + worktree: 'wt-1', + relativePath: 'src/index.ts', + absolutePath: '/repo/src/index.ts', + exists: true, + isDirectory: false, + openTarget: { + kind: 'worktree-file', + provider: 'local', + relativePath: 'src/index.ts', + absolutePath: '/repo/src/index.ts' + } + }) + ]) + const pushPreviewRoute = vi.fn() + const triggerOpenFeedback = vi.fn() + + openMobileTerminalFileTap({ + client, + hostId: 'host-1', + worktreeId: 'wt-1', + worktreeName: 'Orca', + pathText: 'src/index.ts:120:7', + line: 120, + column: 7, + pushPreviewRoute, + openBrowser: vi.fn(), + triggerOpenFeedback, + fetchSessionTabs: vi.fn(), + getSessionTabs: () => [], + getActiveSessionTabId: () => null, + getActivationState: activeTerminalState, + switchSessionTab: vi.fn(), + scheduleDelayedAction: vi.fn() + }) + await Promise.resolve() + + expect(pushPreviewRoute).toHaveBeenCalledWith({ + pathname: '/h/[hostId]/files/preview/[worktreeId]', + params: expect.objectContaining({ + hostId: 'host-1', + worktreeId: 'wt-1', + source: 'worktree', + relativePath: 'src/index.ts', + line: '120', + column: '7', + worktreeName: 'Orca' + }) + }) + expect(triggerOpenFeedback).toHaveBeenCalledTimes(1) + expect(client.sendRequest).not.toHaveBeenCalledWith('files.open', expect.anything()) + }) + + it('encodes worktree HTML paths before opening a browser tab', async () => { + const client = createClient([ + ok({ + worktree: 'wt-1', + relativePath: 'public/report #1?.html', + absolutePath: '/repo/public/report #1?.html', + exists: true, + isDirectory: false, + openTarget: { + kind: 'worktree-file', + provider: 'local', + relativePath: 'public/report #1?.html', + absolutePath: '/repo/public/report #1?.html' + } + }) + ]) + const openBrowser = vi.fn() + + openMobileTerminalFileTap({ + client, + hostId: 'host-1', + worktreeId: 'wt-1', + pathText: 'public/report #1?.html', + line: null, + column: null, + pushPreviewRoute: vi.fn(), + openBrowser, + triggerOpenFeedback: vi.fn(), + fetchSessionTabs: vi.fn(), + getSessionTabs: () => [], + getActiveSessionTabId: () => null, + getActivationState: activeTerminalState, + switchSessionTab: vi.fn(), + scheduleDelayedAction: vi.fn() + }) + await Promise.resolve() + + expect(openBrowser).toHaveBeenCalledWith('file:///repo/public/report%20%231%3F.html') + expect(client.sendRequest).not.toHaveBeenCalledWith('files.open', expect.anything()) + }) + + it('passes the terminal cwd when resolving relative taps', async () => { + const client = createClient([ + ok({ + worktree: 'wt-1', + relativePath: 'src/index.ts', + absolutePath: '/repo/src/index.ts', + exists: true, + isDirectory: false, + openTarget: { + kind: 'worktree-file', + provider: 'local', + relativePath: 'src/index.ts', + absolutePath: '/repo/src/index.ts' + } + }), + ok({ opened: true }) + ]) + + openMobileTerminalFileTap({ + client, + hostId: 'host-1', + worktreeId: 'wt-1', + pathText: 'index.ts', + terminalHandle: 'term-1', + cwd: '/repo/src', + line: null, + column: null, + pushPreviewRoute: vi.fn(), + openBrowser: vi.fn(), + triggerOpenFeedback: vi.fn(), + fetchSessionTabs: vi.fn(), + getSessionTabs: () => [], + getActiveSessionTabId: () => null, + getActivationState: activeTerminalState, + switchSessionTab: vi.fn(), + scheduleDelayedAction: vi.fn() + }) + await Promise.resolve() + + expect(client.sendRequest).toHaveBeenCalledWith( + 'files.resolveTerminalPath', + { worktree: 'id:wt-1', pathText: 'index.ts', terminal: 'term-1', cwd: '/repo/src' }, + { timeoutMs: 10_000 } + ) + }) + + it('does not open SSH worktree HTML paths as local browser file URLs', async () => { + const client = createClient([ + ok({ + worktree: 'wt-1', + relativePath: 'report.html', + absolutePath: '/home/me/repo/report.html', + exists: true, + isDirectory: false, + openTarget: { + kind: 'worktree-file', + provider: 'ssh', + relativePath: 'report.html', + absolutePath: '/home/me/repo/report.html' + } + }), + ok({ opened: true }) + ]) + const openBrowser = vi.fn() + + openMobileTerminalFileTap({ + client, + hostId: 'host-1', + worktreeId: 'wt-1', + pathText: 'report.html', + line: null, + column: null, + pushPreviewRoute: vi.fn(), + openBrowser, + triggerOpenFeedback: vi.fn(), + fetchSessionTabs: vi.fn(), + getSessionTabs: () => [], + getActiveSessionTabId: () => null, + getActivationState: activeTerminalState, + switchSessionTab: vi.fn(), + scheduleDelayedAction: vi.fn() + }) + await Promise.resolve() + await Promise.resolve() + + expect(openBrowser).not.toHaveBeenCalled() + expect(client.sendRequest).toHaveBeenCalledWith( + 'files.open', + { worktree: 'id:wt-1', relativePath: 'report.html' }, + { timeoutMs: 15_000 } + ) + }) + + it('does not navigate an absolute artifact after the user leaves the source terminal', async () => { + let resolveRequest: (value: unknown) => void = () => {} + const client = { + sendRequest: vi.fn( + () => + new Promise((resolve) => { + resolveRequest = resolve + }) + ) + } + let activeTerminalHandle: string | null = 'terminal-1' + const pushPreviewRoute = vi.fn() + + openMobileTerminalFileTap({ + client, + hostId: 'host-1', + worktreeId: 'wt-1', + pathText: '/tmp/result.json', + terminalHandle: 'terminal-1', + line: null, + column: null, + pushPreviewRoute, + openBrowser: vi.fn(), + triggerOpenFeedback: vi.fn(), + fetchSessionTabs: vi.fn(), + getSessionTabs: () => [], + getActiveSessionTabId: () => null, + getActivationState: (activated) => ({ + ...activeTerminalState(activated), + activeTerminalHandle + }), + switchSessionTab: vi.fn(), + scheduleDelayedAction: vi.fn() + }) + + activeTerminalHandle = 'terminal-2' + resolveRequest( + ok({ + worktree: 'wt-1', + relativePath: null, + absolutePath: '/tmp/result.json', + exists: true, + isDirectory: false, + openTarget: { + kind: 'absolute-file', + provider: 'local', + absolutePath: '/tmp/result.json', + grantId: 'grant-1' + } + }) + ) + await Promise.resolve() + await Promise.resolve() + + expect(pushPreviewRoute).not.toHaveBeenCalled() + }) + + it('does not activate a worktree file tab after a newer tap supersedes it', async () => { + const client = createClient([ + ok({ + worktree: 'wt-1', + relativePath: 'src/index.ts', + absolutePath: '/repo/src/index.ts', + exists: true, + isDirectory: false, + openTarget: { + kind: 'worktree-file', + provider: 'local', + relativePath: 'src/index.ts', + absolutePath: '/repo/src/index.ts' + } + }), + ok({ opened: true }) + ]) + const callbacks: (() => void)[] = [] + const openedTab = { id: 'tab-2', relativePath: 'src/index.ts' } + const switchSessionTab = vi.fn() + + openMobileTerminalFileTap({ + client, + hostId: 'host-1', + worktreeId: 'wt-1', + pathText: 'src/index.ts', + line: null, + column: null, + pushPreviewRoute: vi.fn(), + openBrowser: vi.fn(), + triggerOpenFeedback: vi.fn(), + fetchSessionTabs: vi.fn(), + getSessionTabs: () => [openedTab], + getActiveSessionTabId: () => 'terminal-tab', + getActivationState: (activated) => ({ + ...activeTerminalState(activated), + latestActivationSeq: 2 + }), + switchSessionTab, + scheduleDelayedAction: (callback) => callbacks.push(callback) + }) + await Promise.resolve() + await Promise.resolve() + callbacks.forEach((callback) => callback()) + await Promise.resolve() + + expect(switchSessionTab).not.toHaveBeenCalled() + }) +}) diff --git a/mobile/src/session/mobile-terminal-file-tap-open.ts b/mobile/src/session/mobile-terminal-file-tap-open.ts new file mode 100644 index 00000000000..8606467ee80 --- /dev/null +++ b/mobile/src/session/mobile-terminal-file-tap-open.ts @@ -0,0 +1,173 @@ +import type { RuntimeTerminalPathResolution } from '../../../src/shared/runtime-types' +import { filesystemPathToFileUri } from '../../../src/shared/file-uri-path' +import { createMobileFilePreviewHref } from '../files/mobile-file-preview-route' +import { classifyMobileArtifact } from './mobile-artifact-kind' +import type { RpcClient } from '../transport/rpc-client' +import type { RpcSuccess } from '../transport/types' +import { shouldActivateOpenedMobileSessionTab } from './opened-mobile-session-tab' + +type TerminalFileTapSessionTab = { + id: string + relativePath?: string +} + +type OpenMobileTerminalFileTapOptions = { + client: Pick + hostId: string + worktreeId: string + worktreeName?: string + terminalHandle?: string | null + pathText: string + cwd?: string | null + line: number | null + column: number | null + pushPreviewRoute: (href: ReturnType) => void + openBrowser: (url: string) => void + triggerOpenFeedback: () => void + fetchSessionTabs: () => Promise + getSessionTabs: () => readonly T[] + getActiveSessionTabId: () => string | null + getActivationState: (activated: boolean) => { + activated: boolean + activationSeq: number + latestActivationSeq: number + sourceTerminalHandle: string + activeTerminalHandle: string | null + activeTabType: string | null + } + switchSessionTab: (tab: T) => void + scheduleDelayedAction: (callback: () => void, delayMs: number) => unknown +} + +export function openMobileTerminalFileTap( + options: OpenMobileTerminalFileTapOptions +): void { + void openMobileTerminalFileTapAsync(options).catch(() => { + // Terminal file taps are best-effort: a failed host resolution should leave + // terminal focus/input untouched, matching the existing silent miss behavior. + }) +} + +async function openMobileTerminalFileTapAsync( + options: OpenMobileTerminalFileTapOptions +): Promise { + const worktree = `id:${options.worktreeId}` + const response = await options.client.sendRequest( + 'files.resolveTerminalPath', + { + worktree, + pathText: options.pathText, + ...(options.terminalHandle && options.terminalHandle.trim().length > 0 + ? { terminal: options.terminalHandle } + : {}), + ...(options.cwd && options.cwd.trim().length > 0 ? { cwd: options.cwd } : {}) + }, + { timeoutMs: 10_000 } + ) + if (!response.ok) { + return + } + const resolved = (response as RpcSuccess).result as RuntimeTerminalPathResolution + if (!resolved.exists || resolved.isDirectory) { + return + } + if (!shouldActivateOpenedMobileSessionTab(options.getActivationState(false))) { + return + } + + if (resolved.openTarget?.kind === 'absolute-file') { + options.triggerOpenFeedback() + options.pushPreviewRoute( + createMobileFilePreviewHref({ + hostId: options.hostId, + worktreeId: options.worktreeId, + source: 'terminalArtifact', + absolutePath: resolved.openTarget.absolutePath, + grantId: resolved.openTarget.grantId, + pathText: options.pathText, + ...(options.cwd && options.cwd.trim().length > 0 ? { cwd: options.cwd } : {}), + ...(options.terminalHandle && options.terminalHandle.trim().length > 0 + ? { terminal: options.terminalHandle } + : {}), + name: displayNameFromPath(resolved.openTarget.absolutePath), + ...(options.line !== null ? { line: String(options.line) } : {}), + ...(options.column !== null ? { column: String(options.column) } : {}), + ...(options.worktreeName ? { worktreeName: options.worktreeName } : {}) + }) + ) + return + } + + const openedPath = + resolved.openTarget?.kind === 'worktree-file' + ? resolved.openTarget.relativePath + : resolved.relativePath + if (!openedPath) { + return + } + options.triggerOpenFeedback() + if (options.line !== null || options.column !== null) { + options.pushPreviewRoute( + createMobileFilePreviewHref({ + hostId: options.hostId, + worktreeId: options.worktreeId, + source: 'worktree', + relativePath: openedPath, + name: displayNameFromPath(openedPath), + ...(options.line !== null ? { line: String(options.line) } : {}), + ...(options.column !== null ? { column: String(options.column) } : {}), + ...(options.worktreeName ? { worktreeName: options.worktreeName } : {}) + }) + ) + return + } + if ( + classifyMobileArtifact(openedPath) === 'html' && + resolved.openTarget?.kind === 'worktree-file' && + resolved.openTarget.provider === 'local' + ) { + options.openBrowser(filesystemPathToFileUri(resolved.openTarget.absolutePath)) + return + } + const openResponse = await options.client.sendRequest( + 'files.open', + { worktree, relativePath: openedPath }, + { timeoutMs: 15_000 } + ) + if (!openResponse.ok) { + return + } + scheduleOpenedWorktreeTabActivation(options, openedPath) +} + +function scheduleOpenedWorktreeTabActivation( + options: OpenMobileTerminalFileTapOptions, + openedPath: string +): void { + let activated = false + const activateOpenedTab = async (): Promise => { + if (!shouldActivateOpenedMobileSessionTab(options.getActivationState(activated))) { + return + } + await options.fetchSessionTabs() + if (!shouldActivateOpenedMobileSessionTab(options.getActivationState(activated))) { + return + } + const opened = options.getSessionTabs().find((tab) => tab.relativePath === openedPath) + if (!opened) { + return + } + if (options.getActiveSessionTabId() !== opened.id) { + options.switchSessionTab(opened) + } + activated = true + } + + options.scheduleDelayedAction(() => void activateOpenedTab(), 300) + options.scheduleDelayedAction(() => void activateOpenedTab(), 900) + options.scheduleDelayedAction(() => void activateOpenedTab(), 1800) +} + +function displayNameFromPath(path: string): string | undefined { + return path.split(/[\\/]/).filter(Boolean).at(-1) +} diff --git a/mobile/src/session/use-mobile-image-attachment.ts b/mobile/src/session/use-mobile-image-attachment.ts index 3894928f4f5..417dd8d699b 100644 --- a/mobile/src/session/use-mobile-image-attachment.ts +++ b/mobile/src/session/use-mobile-image-attachment.ts @@ -24,6 +24,7 @@ type UseMobileImageAttachmentArgs = { readonly showToast: ShowToast readonly onSuccess: () => void readonly onError: () => void + readonly beforeTerminalSend?: (terminal: string) => Promise } type MobileImageAttachment = { @@ -46,7 +47,8 @@ export function useMobileImageAttachment({ getActiveWorktreeConnectionId, showToast, onSuccess, - onError + onError, + beforeTerminalSend }: UseMobileImageAttachmentArgs): MobileImageAttachment { const [isAttaching, setIsAttaching] = useState(false) const attachImage = useCallback( @@ -61,7 +63,8 @@ export function useMobileImageAttachment({ deviceToken: deviceTokenRef.current, getConnectionId: getActiveWorktreeConnectionId, pickImage: pickMobileImage, - onUploadStart: () => setIsAttaching(true) + onUploadStart: () => setIsAttaching(true), + beforeTerminalSend }) // Cancelled picker: no error, no toast. if (sent) { @@ -88,6 +91,7 @@ export function useMobileImageAttachment({ }, [ activeHandle, + beforeTerminalSend, canSend, client, connState, diff --git a/mobile/src/session/use-mobile-terminal-paste.ts b/mobile/src/session/use-mobile-terminal-paste.ts new file mode 100644 index 00000000000..e3148be67f0 --- /dev/null +++ b/mobile/src/session/use-mobile-terminal-paste.ts @@ -0,0 +1,200 @@ +import { useCallback, type RefObject } from 'react' +import * as Clipboard from 'expo-clipboard' +import { File as FsFile, Paths } from 'expo-file-system' +import { ImageManipulator, SaveFormat } from 'expo-image-manipulator' +import type { TerminalModes } from '../terminal/TerminalWebView' +import type { RpcClient } from '../transport/rpc-client' +import type { ConnectionState } from '../transport/types' +import { + buildMobileImagePastePayload, + prepareMobileClipboardImageBase64, + saveMobileClipboardImageAsTempFile, + type MobileClipboardImageResizer +} from './mobile-clipboard-image' + +const CLIPBOARD_IMAGE_DATA_URL_PREFIX_RE = /^data:image\/[a-z0-9.+-]+;base64,/i + +// Why: clipboard images are re-encoded as lossless PNG, so high-res screenshots and +// photos can exceed the upload byte budget; resize the raster down to fit before upload. +// The iOS ImageManipulator loader cannot decode large base64 data URIs, so use a file. +const resizeMobileClipboardImage: MobileClipboardImageResizer = async (source, target) => { + const base64 = source.replace(CLIPBOARD_IMAGE_DATA_URL_PREFIX_RE, '') + const file = new FsFile(Paths.cache, `orca-clip-resize-${Date.now()}.png`) + let context: ReturnType | null = null + let rendered: Awaited< + ReturnType['renderAsync']> + > | null = null + let resultUri: string | null = null + try { + file.create({ overwrite: true }) + file.write(base64, { encoding: 'base64' }) + context = ImageManipulator.manipulate(file.uri) + context.resize({ width: target.width, height: target.height }) + rendered = await context.renderAsync() + const result = await rendered.saveAsync({ format: SaveFormat.PNG, base64: true }) + resultUri = result.uri + // Why: empty base64 would pass the downstream base64 check and upload a corrupt + // image, so fail loudly here instead of silently sending an invalid payload. + if (!result.base64) { + throw new Error('Failed to encode resized clipboard image') + } + return { data: result.base64, width: result.width, height: result.height } + } finally { + rendered?.release() + context?.release() + if (resultUri) { + try { + new FsFile(resultUri).delete() + } catch { + // Best-effort cleanup; ImageManipulator saves into cache for every retry. + } + } + try { + file.delete() + } catch { + // Best-effort cleanup; the OS reclaims the cache directory regardless. + } + } +} + +function buildMobileTerminalClipboardTextPayload( + text: string, + modes: TerminalModes | undefined +): string { + const wrap = modes?.bracketedPasteMode === true && !modes.altScreen + // Why: strip embedded bracketed-paste markers so copied text cannot terminate + // paste mode early and turn trailing bytes into shell commands. + // eslint-disable-next-line no-control-regex -- intentional bracketed-paste marker stripping + const sanitized = wrap ? text.replace(/\x1b\[20[01]~/g, '') : text + return wrap ? `\x1b[200~${sanitized}\x1b[201~` : sanitized +} + +type UseMobileTerminalPasteOptions = { + readonly activeHandle: string | null + readonly activeHandleRef: RefObject + readonly activeSessionTabTypeRef: RefObject + readonly canSend: boolean + readonly client: RpcClient | null + readonly clientRef: RefObject + readonly connState: ConnectionState + readonly connStateRef: RefObject + readonly deviceTokenRef: RefObject + readonly flushPendingLiveInputBeforeExternalSend: (handle: string) => Promise + readonly getActiveWorktreeConnectionId: () => Promise + readonly onError: () => void + readonly onSuccess: () => void + readonly ptyModesRef: RefObject> + readonly refreshCanPaste: () => void + readonly showToast: (message: string, durationMs?: number) => void +} + +export function useMobileTerminalPaste({ + activeHandle, + activeHandleRef, + activeSessionTabTypeRef, + canSend, + client, + clientRef, + connState, + connStateRef, + deviceTokenRef, + flushPendingLiveInputBeforeExternalSend, + getActiveWorktreeConnectionId, + onError, + onSuccess, + ptyModesRef, + refreshCanPaste, + showToast +}: UseMobileTerminalPasteOptions): () => Promise { + return useCallback(async () => { + if (!client || !activeHandle || !canSend) { + return + } + const targetHandle = activeHandle + try { + const text = await Clipboard.getStringAsync() + let payload: string | null = null + if (text.length > 0) { + payload = buildMobileTerminalClipboardTextPayload( + text, + ptyModesRef.current.get(targetHandle) + ) + } else { + const image = await Clipboard.getImageAsync({ format: 'png' }) + if (!image) { + refreshCanPaste() + return + } + const connectionId = await getActiveWorktreeConnectionId() + const base64 = await prepareMobileClipboardImageBase64(image, resizeMobileClipboardImage) + const imagePath = await saveMobileClipboardImageAsTempFile(client, base64, { + connectionId + }) + payload = buildMobileImagePastePayload(imagePath) + } + + const wrappedBytes = new TextEncoder().encode(payload).byteLength + if (wrappedBytes > 256 * 1024) { + onError() + // eslint-disable-next-line no-console + console.warn('[mobile-clip] paste oversized', { wrappedBytes }) + showToast('Paste too large (max 256 KiB)', 1500) + return + } + // Why: paste lives in the accessory row and must not overtake pending IME text. + const flushedPendingInput = await flushPendingLiveInputBeforeExternalSend(targetHandle) + if (!flushedPendingInput) { + return + } + const currentClient = clientRef.current + if ( + !currentClient || + connStateRef.current !== 'connected' || + targetHandle !== activeHandleRef.current || + activeSessionTabTypeRef.current !== 'terminal' + ) { + return + } + await currentClient.sendRequest('terminal.send', { + terminal: targetHandle, + text: payload, + enter: false, + ...(deviceTokenRef.current + ? { client: { id: deviceTokenRef.current, type: 'mobile' as const } } + : {}) + }) + onSuccess() + refreshCanPaste() + } catch (e) { + onError() + const err = e as { name?: string; message?: string } + const isDisconnected = connState !== 'connected' + // eslint-disable-next-line no-console + console.warn('[mobile-clip] paste failed', { name: err.name, message: err.message }) + if (isDisconnected) { + showToast('Paste failed (disconnected)', 1500) + } else if (err.message === 'Clipboard image is too large') { + showToast('Image too large to paste', 1500) + } else { + showToast('Paste failed', 1500) + } + } + }, [ + activeHandle, + activeHandleRef, + activeSessionTabTypeRef, + canSend, + client, + clientRef, + connState, + connStateRef, + deviceTokenRef, + flushPendingLiveInputBeforeExternalSend, + getActiveWorktreeConnectionId, + onError, + onSuccess, + ptyModesRef, + refreshCanPaste, + showToast + ]) +} diff --git a/mobile/src/session/use-terminal-live-input-mode-preference.test.ts b/mobile/src/session/use-terminal-live-input-mode-preference.test.ts new file mode 100644 index 00000000000..d95fab23698 --- /dev/null +++ b/mobile/src/session/use-terminal-live-input-mode-preference.test.ts @@ -0,0 +1,138 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + readDisabledTerminalLiveInputHandlesPreference, + saveDisabledTerminalLiveInputHandles, + type DisabledTerminalLiveInputHandlesPreference +} from '../storage/preferences' +import { useTerminalLiveInputModePreference } from './use-terminal-live-input-mode-preference' + +vi.mock('../storage/preferences', () => ({ + readDisabledTerminalLiveInputHandlesPreference: vi.fn(), + saveDisabledTerminalLiveInputHandles: vi.fn() +})) + +type TerminalLiveInputModePreferenceHarness = { + readonly current: ReturnType + readonly unmount: () => void +} + +type Deferred = { + readonly promise: Promise + readonly resolve: (value: T) => void +} + +function createDeferred(): Deferred { + let resolve: ((value: T) => void) | null = null + const promise = new Promise((innerResolve) => { + resolve = innerResolve + }) + if (!resolve) { + throw new Error('deferred resolver was not initialized') + } + return { promise, resolve } +} + +function suppressReactTestRendererDeprecationWarning(): () => void { + const originalConsoleError = console.error + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + const firstArg = args[0] + if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) { + return + } + originalConsoleError(...args) + }) + return () => consoleErrorSpy.mockRestore() +} + +function createTerminalLiveInputModePreferenceHarness(): TerminalLiveInputModePreferenceHarness { + let current: ReturnType | null = null + let renderer: ReactTestRenderer | null = null + + function Harness(): null { + current = useTerminalLiveInputModePreference({ + hostId: 'host-1', + worktreeId: 'worktree-1' + }) + return null + } + + const restoreConsoleError = suppressReactTestRendererDeprecationWarning() + try { + act(() => { + renderer = create(createElement(Harness)) + }) + } finally { + restoreConsoleError() + } + if (!current || !renderer) { + throw new Error('terminal live input mode preference hook did not render') + } + + return { + get current() { + if (!current) { + throw new Error('terminal live input mode preference hook is not mounted') + } + return current + }, + unmount: () => { + act(() => renderer?.unmount()) + } + } +} + +describe('terminal live input mode preference hook', () => { + beforeEach(() => { + vi.mocked(readDisabledTerminalLiveInputHandlesPreference).mockReset() + vi.mocked(saveDisabledTerminalLiveInputHandles).mockReset() + vi.mocked(saveDisabledTerminalLiveInputHandles).mockResolvedValue() + }) + + it('merges pre-hydration edits with loaded disabled handles', async () => { + const load = createDeferred() + vi.mocked(readDisabledTerminalLiveInputHandlesPreference).mockReturnValue(load.promise) + const harness = createTerminalLiveInputModePreferenceHarness() + + act(() => { + harness.current.defaultTerminalHandlesToLiveInput(['pty-1', 'pty-2']) + }) + act(() => { + expect(harness.current.toggleTerminalLiveInput('pty-1')).toBe(true) + }) + + await act(async () => { + load.resolve({ handles: new Set(['pty-2']), loaded: true }) + await load.promise + }) + + expect([...harness.current.liveInputTerminalHandles]).toEqual(['pty-1']) + expect(saveDisabledTerminalLiveInputHandles).toHaveBeenCalledTimes(1) + expect(saveDisabledTerminalLiveInputHandles).toHaveBeenCalledWith( + 'host-1', + 'worktree-1', + new Set(['pty-2']) + ) + harness.unmount() + }) + + it('does not persist fallback-empty storage reads during clean hydration', async () => { + const load = createDeferred() + vi.mocked(readDisabledTerminalLiveInputHandlesPreference).mockReturnValue(load.promise) + const harness = createTerminalLiveInputModePreferenceHarness() + + act(() => { + harness.current.defaultTerminalHandlesToLiveInput(['pty-1']) + }) + + await act(async () => { + load.resolve({ handles: new Set(), loaded: false }) + await load.promise + }) + + expect([...harness.current.liveInputTerminalHandles]).toEqual(['pty-1']) + expect(saveDisabledTerminalLiveInputHandles).not.toHaveBeenCalled() + harness.unmount() + }) +}) diff --git a/mobile/src/session/use-terminal-live-input-mode-preference.ts b/mobile/src/session/use-terminal-live-input-mode-preference.ts new file mode 100644 index 00000000000..dd59449b816 --- /dev/null +++ b/mobile/src/session/use-terminal-live-input-mode-preference.ts @@ -0,0 +1,212 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { + readDisabledTerminalLiveInputHandlesPreference, + saveDisabledTerminalLiveInputHandles +} from '../storage/preferences' +import { + applyDisabledTerminalLiveInputHandles, + defaultTerminalLiveInputHandles, + filterTerminalLiveInputDefaultCandidates, + pruneTerminalLiveInputHandles +} from '../terminal/terminal-live-input' + +type UseTerminalLiveInputModePreferenceOptions = { + readonly hostId: string + readonly worktreeId: string +} + +export function useTerminalLiveInputModePreference({ + hostId, + worktreeId +}: UseTerminalLiveInputModePreferenceOptions) { + const [liveInputTerminalHandles, setLiveInputTerminalHandles] = useState>( + () => new Set() + ) + const liveInputTerminalHandlesRef = useRef>(new Set()) + const defaultedLiveInputTerminalHandlesRef = useRef>(new Set()) + const disabledLiveInputTerminalHandlesRef = useRef>(new Set()) + const disabledLiveInputHydratedRef = useRef(false) + const pendingDisabledLiveInputHydrationEditsRef = useRef>(new Map()) + const pendingLiveInputDefaultHandlesRef = useRef>(new Set()) + + const defaultTerminalHandlesToLiveInput = useCallback((handles: readonly string[]) => { + // Why: terminal discovery (tab snapshots, list poll, create) can arrive + // before the async persisted-disabled load on worktree re-entry. + if (!disabledLiveInputHydratedRef.current) { + for (const handle of handles) { + pendingLiveInputDefaultHandlesRef.current.add(handle) + } + return + } + const defaultableHandles = filterTerminalLiveInputDefaultCandidates( + handles, + disabledLiveInputTerminalHandlesRef.current + ) + const result = defaultTerminalLiveInputHandles( + liveInputTerminalHandlesRef.current, + defaultedLiveInputTerminalHandlesRef.current, + defaultableHandles + ) + if (!result.changed) { + return + } + const nextEnabledHandles = new Set(result.enabledHandles) + const nextDefaultedHandles = new Set(result.defaultedHandles) + liveInputTerminalHandlesRef.current = nextEnabledHandles + defaultedLiveInputTerminalHandlesRef.current = nextDefaultedHandles + setLiveInputTerminalHandles(nextEnabledHandles) + }, []) + + const persistDisabledLiveInputHandles = useCallback(() => { + void saveDisabledTerminalLiveInputHandles( + hostId, + worktreeId, + disabledLiveInputTerminalHandlesRef.current + ).catch(() => {}) + }, [hostId, worktreeId]) + + const pruneTerminalHandlesFromLiveInput = useCallback( + (liveHandles: ReadonlySet) => { + const result = pruneTerminalLiveInputHandles( + liveInputTerminalHandlesRef.current, + defaultedLiveInputTerminalHandlesRef.current, + liveHandles + ) + let prunedDisabledHandles = false + for (const handle of disabledLiveInputTerminalHandlesRef.current) { + if (liveHandles.has(handle)) { + continue + } + disabledLiveInputTerminalHandlesRef.current.delete(handle) + if (!disabledLiveInputHydratedRef.current) { + pendingDisabledLiveInputHydrationEditsRef.current.set(handle, false) + } + prunedDisabledHandles = true + } + if (prunedDisabledHandles && disabledLiveInputHydratedRef.current) { + persistDisabledLiveInputHandles() + } + if (!result.changed) { + return + } + const nextEnabledHandles = new Set(result.enabledHandles) + const nextDefaultedHandles = new Set(result.defaultedHandles) + liveInputTerminalHandlesRef.current = nextEnabledHandles + defaultedLiveInputTerminalHandlesRef.current = nextDefaultedHandles + setLiveInputTerminalHandles(nextEnabledHandles) + }, + [persistDisabledLiveInputHandles] + ) + + const clearTerminalLiveInputDefault = useCallback( + (handle: string) => { + const liveHandles = new Set([ + ...liveInputTerminalHandlesRef.current, + ...defaultedLiveInputTerminalHandlesRef.current + ]) + liveHandles.delete(handle) + if (!disabledLiveInputHydratedRef.current) { + pendingDisabledLiveInputHydrationEditsRef.current.set(handle, false) + } + if (disabledLiveInputTerminalHandlesRef.current.delete(handle)) { + if (disabledLiveInputHydratedRef.current) { + persistDisabledLiveInputHandles() + } + } + pruneTerminalHandlesFromLiveInput(liveHandles) + }, + [persistDisabledLiveInputHandles, pruneTerminalHandlesFromLiveInput] + ) + + const toggleTerminalLiveInput = useCallback( + (handle: string): boolean => { + const nextEnabled = !liveInputTerminalHandlesRef.current.has(handle) + if (nextEnabled) { + disabledLiveInputTerminalHandlesRef.current.delete(handle) + } else { + disabledLiveInputTerminalHandlesRef.current.add(handle) + } + // Why: pre-hydration edits must patch the loaded set per handle; replacing + // the loaded set would erase other persisted opt-outs for this worktree. + if (!disabledLiveInputHydratedRef.current) { + pendingDisabledLiveInputHydrationEditsRef.current.set(handle, !nextEnabled) + } + // Why: only persist after hydration; an earlier write would use the + // reset-empty ref and overwrite other handles' opt-outs for the worktree. + if (disabledLiveInputHydratedRef.current) { + persistDisabledLiveInputHandles() + } + setLiveInputTerminalHandles((prev) => { + const next = new Set(prev) + if (nextEnabled) { + next.add(handle) + } else { + next.delete(handle) + } + liveInputTerminalHandlesRef.current = next + return next + }) + return nextEnabled + }, + [persistDisabledLiveInputHandles] + ) + + useEffect(() => { + liveInputTerminalHandlesRef.current = new Set() + defaultedLiveInputTerminalHandlesRef.current = new Set() + disabledLiveInputTerminalHandlesRef.current = new Set() + disabledLiveInputHydratedRef.current = false + pendingDisabledLiveInputHydrationEditsRef.current = new Map() + pendingLiveInputDefaultHandlesRef.current = new Set() + setLiveInputTerminalHandles(new Set()) + + let disposed = false + // Why: load the persisted opt-outs first so defaulting logic (which can + // fire immediately from subscriptions) respects prior user choices. + void readDisabledTerminalLiveInputHandlesPreference(hostId, worktreeId).then((preference) => { + if (disposed) { + return + } + const pendingEdits = pendingDisabledLiveInputHydrationEditsRef.current + const hydratedDisabledHandles = new Set(preference.handles) + for (const [handle, disabled] of pendingEdits) { + if (disabled) { + hydratedDisabledHandles.add(handle) + } else { + hydratedDisabledHandles.delete(handle) + } + } + disabledLiveInputTerminalHandlesRef.current = hydratedDisabledHandles + disabledLiveInputHydratedRef.current = true + pendingDisabledLiveInputHydrationEditsRef.current = new Map() + if (preference.loaded && pendingEdits.size > 0) { + persistDisabledLiveInputHandles() + } + const result = applyDisabledTerminalLiveInputHandles( + liveInputTerminalHandlesRef.current, + defaultedLiveInputTerminalHandlesRef.current, + hydratedDisabledHandles + ) + const nextEnabledHandles = new Set(result.enabledHandles) + const nextDefaultedHandles = new Set(result.defaultedHandles) + liveInputTerminalHandlesRef.current = nextEnabledHandles + defaultedLiveInputTerminalHandlesRef.current = nextDefaultedHandles + setLiveInputTerminalHandles(nextEnabledHandles) + const pendingDefaultHandles = [...pendingLiveInputDefaultHandlesRef.current] + pendingLiveInputDefaultHandlesRef.current.clear() + defaultTerminalHandlesToLiveInput(pendingDefaultHandles) + }) + return () => { + disposed = true + } + }, [defaultTerminalHandlesToLiveInput, hostId, persistDisabledLiveInputHandles, worktreeId]) + + return { + clearTerminalLiveInputDefault, + defaultTerminalHandlesToLiveInput, + liveInputTerminalHandles, + liveInputTerminalHandlesRef, + pruneTerminalHandlesFromLiveInput, + toggleTerminalLiveInput + } +} diff --git a/mobile/src/storage/preferences.test.ts b/mobile/src/storage/preferences.test.ts index c0f8596900a..1e5aee95de1 100644 --- a/mobile/src/storage/preferences.test.ts +++ b/mobile/src/storage/preferences.test.ts @@ -8,9 +8,12 @@ import { HOST_SIDEBAR_MIN_WIDTH, clampHostDockWidth, clampHostSidebarWidth, + loadDisabledTerminalLiveInputHandles, loadHostSidebarWidth, loadTerminalAutocompleteEnabled, loadTerminalLinkOpenMode, + readDisabledTerminalLiveInputHandlesPreference, + saveDisabledTerminalLiveInputHandles, saveHostSidebarWidth, saveTerminalAutocompleteEnabled, saveTerminalLinkOpenMode @@ -63,6 +66,62 @@ describe('terminal autocomplete preference', () => { }) }) +describe('terminal live input disabled handles preference', () => { + beforeEach(() => { + vi.mocked(AsyncStorage.getItem).mockReset() + vi.mocked(AsyncStorage.setItem).mockReset() + }) + + it('defaults to no disabled handles when unset', async () => { + vi.mocked(AsyncStorage.getItem).mockResolvedValue(null) + + await expect(loadDisabledTerminalLiveInputHandles('host-1', 'worktree-1')).resolves.toEqual( + new Set() + ) + await expect( + readDisabledTerminalLiveInputHandlesPreference('host-1', 'worktree-1') + ).resolves.toEqual({ handles: new Set(), loaded: true }) + }) + + it('loads only string terminal handles from storage', async () => { + vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(['pty-1', 42, 'pty-2'])) + + await expect(loadDisabledTerminalLiveInputHandles('host-1', 'worktree-1')).resolves.toEqual( + new Set(['pty-1', 'pty-2']) + ) + }) + + it('falls back to no disabled handles for invalid or unreadable storage', async () => { + vi.mocked(AsyncStorage.getItem).mockResolvedValue('not-json') + + await expect(loadDisabledTerminalLiveInputHandles('host-1', 'worktree-1')).resolves.toEqual( + new Set() + ) + + vi.mocked(AsyncStorage.getItem).mockRejectedValue(new Error('storage unavailable')) + + await expect(loadDisabledTerminalLiveInputHandles('host-1', 'worktree-1')).resolves.toEqual( + new Set() + ) + await expect( + readDisabledTerminalLiveInputHandlesPreference('host-1', 'worktree-1') + ).resolves.toEqual({ handles: new Set(), loaded: false }) + }) + + it('persists disabled handles per host and worktree', async () => { + await saveDisabledTerminalLiveInputHandles( + 'host/one', + 'folder:C:\\repo', + new Set(['pty-2', 'pty-1']) + ) + + expect(AsyncStorage.setItem).toHaveBeenCalledWith( + 'orca:terminalLiveInputDisabled:host%2Fone:folder%3AC%3A%5Crepo', + JSON.stringify(['pty-2', 'pty-1']) + ) + }) +}) + describe('host sidebar width preference', () => { beforeEach(() => { vi.mocked(AsyncStorage.getItem).mockReset() diff --git a/mobile/src/storage/preferences.ts b/mobile/src/storage/preferences.ts index 5151397a7f0..d5b0db3a6e7 100644 --- a/mobile/src/storage/preferences.ts +++ b/mobile/src/storage/preferences.ts @@ -73,6 +73,53 @@ export async function saveTerminalAutocompleteEnabled(enabled: boolean): Promise await AsyncStorage.setItem(AUTOCOMPLETE_KEY, String(enabled)) } +const TERMINAL_LIVE_INPUT_DISABLED_PREFIX = 'orca:terminalLiveInputDisabled:' + +export type DisabledTerminalLiveInputHandlesPreference = { + readonly handles: Set + readonly loaded: boolean +} + +function terminalLiveInputDisabledKey(hostId: string, worktreeId: string): string { + return `${TERMINAL_LIVE_INPUT_DISABLED_PREFIX}${encodeURIComponent(hostId)}:${encodeURIComponent( + worktreeId + )}` +} + +export async function readDisabledTerminalLiveInputHandlesPreference( + hostId: string, + worktreeId: string +): Promise { + try { + const raw = await AsyncStorage.getItem(terminalLiveInputDisabledKey(hostId, worktreeId)) + if (!raw) { + return { handles: new Set(), loaded: true } + } + return { handles: new Set(stringArray(JSON.parse(raw))), loaded: true } + } catch { + return { handles: new Set(), loaded: false } + } +} + +export async function loadDisabledTerminalLiveInputHandles( + hostId: string, + worktreeId: string +): Promise> { + const preference = await readDisabledTerminalLiveInputHandlesPreference(hostId, worktreeId) + return preference.handles +} + +export async function saveDisabledTerminalLiveInputHandles( + hostId: string, + worktreeId: string, + handles: ReadonlySet +): Promise { + await AsyncStorage.setItem( + terminalLiveInputDisabledKey(hostId, worktreeId), + JSON.stringify([...handles]) + ) +} + const SIDEBAR_WIDTH_KEY = 'orca:hostSidebarWidth' // Bounds for the draggable host worktree-list sidebar on tablet/foldable diff --git a/mobile/src/terminal/TerminalWebView.tsx b/mobile/src/terminal/TerminalWebView.tsx index a964ac0a9f6..1798fedd1d8 100644 --- a/mobile/src/terminal/TerminalWebView.tsx +++ b/mobile/src/terminal/TerminalWebView.tsx @@ -1,76 +1,30 @@ import { useRef, useCallback, forwardRef, useImperativeHandle, useEffect, useMemo } from 'react' -import { StyleSheet, type StyleProp, type ViewStyle } from 'react-native' +import { View, type StyleProp, type ViewStyle } from 'react-native' import { WebView } from 'react-native-webview' import type { WebViewMessageEvent } from 'react-native-webview' -import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types' -import { colors } from '../theme/mobile-theme' import type { TerminalOscLinkRange } from './terminal-osc-link-ranges' -import { XTERM_HTML } from './terminal-webview-html' +import type { + MobileTerminalTheme, + TerminalSelectionEvents, + TerminalWebViewHandle +} from './terminal-webview-contract' +import { + TerminalWebViewEngineErrorOverlay, + useTerminalWebViewEngineErrorState +} from './terminal-webview-engine-error-state' +import { TERMINAL_WEBVIEW_FRAME_STYLES } from './terminal-webview-frame-styles' +import { useTerminalWebReadyWatchdog } from './terminal-webview-ready-watchdog' +import { XTERM_WEBVIEW_SOURCE } from './terminal-webview-html' import type { TerminalWebViewCommand } from './terminal-webview-messages' import { createTerminalWebViewPendingMessages } from './terminal-webview-pending-messages' -type TerminalMouseTrackingMode = 'none' | 'x10' | 'vt200' | 'drag' | 'any' -type TerminalOscLinks = TerminalOscLinkRange[] - -export type TerminalModes = { - bracketedPasteMode: boolean - altScreen: boolean - mouseTrackingMode: TerminalMouseTrackingMode - sgrMouseMode: boolean - sgrMousePixelsMode: boolean -} - -export type TerminalKeyboardAvoidanceMetrics = { - cursorY: number - rows: number - altScreen: boolean -} - -export type MobileTerminalTheme = RuntimeMobileTerminalTheme - -export type TerminalSelectionEvents = { - onSelectionMode?: (active: boolean) => void - onSelectionCopy?: (text: string) => void - onSelectionEvicted?: () => void - onModesChanged?: (modes: TerminalModes) => void - onKeyboardAvoidanceMetrics?: (metrics: TerminalKeyboardAvoidanceMetrics) => void - onHaptic?: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void - onTerminalInput?: (bytes: string) => void - onTerminalTap?: () => void - // Tap landed on a detected file path; RN resolves + opens it. - onFileTap?: (pathText: string, line: number | null, column: number | null) => void - // WebView-detected URL tap; RN chooses the mobile routing destination. - onOpenUrl?: (url: string) => void - // Why: pinch-to-zoom in the terminal snaps to a text-size preset and reports it - // here so the app persists it and keeps Settings + other panes in sync. - onTextScaleChange?: (scale: number) => void -} - -export type TerminalWebViewHandle = { - write: (data: string) => void - init: ( - cols: number, - rows: number, - initialData?: string, - preserveScroll?: boolean, - oscLinks?: TerminalOscLinks - ) => void - resize: (cols: number, rows: number) => void - // Why: reflow the local xterm buffer (scrollback included) to a new width - // after a server-side PTY reflow, so older wrapped lines rewrap to match the - // latest output. No-op on the alternate screen. - reflow: (cols: number, rows: number) => void - clear: () => void - measureFitDimensions: (containerHeight?: number) => Promise<{ cols: number; rows: number } | null> - resetZoom: () => void - cancelSelect: () => void - doSelectAll: () => void - // Why: lets callers await the WebView-side `init` rAF chain (term.open - // → renderService population → first paint) so a follow-up measure - // doesn't race ahead and find term=null or cellWidth=0. Resolves on - // the next 'ready' notify after the most recent init. - awaitReady: () => Promise -} +export type { + MobileTerminalTheme, + TerminalKeyboardAvoidanceMetrics, + TerminalModes, + TerminalSelectionEvents, + TerminalWebViewHandle +} from './terminal-webview-contract' type Props = { style?: StyleProp @@ -79,18 +33,16 @@ type Props = { // scale; raw xterm fontSize can't drive apparent size because the fit cancels it. textScale?: number onWebReady?: () => void + onEngineError?: (message: string) => void } & TerminalSelectionEvents -// Why: WebView treats source identity as page identity on some platforms; keep -// parent/session re-renders from reloading xterm and forcing fresh snapshots. -const XTERM_WEBVIEW_SOURCE = { html: XTERM_HTML } - export const TerminalWebView = forwardRef(function TerminalWebView( { style, terminalTheme, textScale = 1, onWebReady, + onEngineError, onSelectionMode, onSelectionCopy, onSelectionEvicted, @@ -119,6 +71,12 @@ export const TerminalWebView = forwardRef(function // race ahead of term.open() / renderService population. const readyPromiseRef = useRef | null>(null) const readyResolveRef = useRef<(() => void) | null>(null) + const { clearEngineError, engineError, reportEngineError, reportNativeEngineError } = + useTerminalWebViewEngineErrorState(onEngineError) + const { armWebReadyWatchdog, clearWebReadyWatchdog } = useTerminalWebReadyWatchdog( + isWebReadyRef, + reportEngineError + ) const sendToWebView = useCallback((msg: TerminalWebViewCommand) => { messageIdRef.current += 1 @@ -151,6 +109,8 @@ export const TerminalWebView = forwardRef(function if (msg.type === 'web-ready') { isWebReadyRef.current = true + clearWebReadyWatchdog() + clearEngineError() onWebReady?.() flushPendingMessages() } else if (msg.type === 'ready') { @@ -175,6 +135,9 @@ export const TerminalWebView = forwardRef(function const tag = typeof msg.tag === 'string' ? msg.tag : '[fit]' // eslint-disable-next-line no-console console.log(tag, msg.payload) + } else if (msg.type === 'error') { + const message = typeof msg.message === 'string' ? msg.message : 'Unknown terminal error' + reportEngineError(message, msg.fatal !== false) } else if (msg.type === 'set-select-mode') { onSelectionMode?.(!!msg.enabled) } else if (msg.type === 'selection') { @@ -246,6 +209,9 @@ export const TerminalWebView = forwardRef(function }, [ flushPendingMessages, + clearEngineError, + clearWebReadyWatchdog, + reportEngineError, onWebReady, onSelectionMode, onSelectionCopy, @@ -263,10 +229,16 @@ export const TerminalWebView = forwardRef(function const handleLoadStart = useCallback(() => { isWebReadyRef.current = false + armWebReadyWatchdog() // Why: messages queued for a previous WebView generation are stale after a reload; // dropping them avoids replaying terminal chunks before the next init snapshot. pendingMessages.clear() - }, [pendingMessages]) + }, [armWebReadyWatchdog, pendingMessages]) + + const handleReload = useCallback(() => { + clearEngineError() + webViewRef.current?.reload() + }, [clearEngineError]) useEffect(() => { postMessage({ type: 'set-theme', terminalTheme }) @@ -289,7 +261,7 @@ export const TerminalWebView = forwardRef(function rows: number, initialData?: string, preserveScroll?: boolean, - oscLinks?: TerminalOscLinks + oscLinks?: TerminalOscLinkRange[] ) { // Why: arm a fresh ready promise BEFORE posting init. The WebView // resolves it via the 'ready' notify at the end of its rAF chain. @@ -395,29 +367,35 @@ export const TerminalWebView = forwardRef(function ) return ( - + + reportNativeEngineError('Terminal WebView load failed', event)} + onHttpError={(event) => reportNativeEngineError('Terminal WebView HTTP error', event)} + onRenderProcessGone={(event) => + reportNativeEngineError('Terminal WebView render process ended', event) + } + onContentProcessDidTerminate={(event) => + reportNativeEngineError('Terminal WebView content process ended', event) + } + /> + {engineError ? ( + + ) : null} + ) }) - -const styles = StyleSheet.create({ - webview: { - flex: 1, - backgroundColor: colors.terminalBg - } -}) diff --git a/mobile/src/terminal/terminal-file-url-tap.ts b/mobile/src/terminal/terminal-file-url-tap.ts new file mode 100644 index 00000000000..b27260b61cb --- /dev/null +++ b/mobile/src/terminal/terminal-file-url-tap.ts @@ -0,0 +1,95 @@ +import type { TappedFilePath } from './terminal-path-tap' +import { parsePathWithOptionalLineColumn } from './terminal-path-tap' + +export function resolveTerminalFileUrlTap(uri: string): TappedFilePath | null { + let parsed: URL + try { + parsed = new URL(uri) + } catch { + return null + } + const filePath = terminalFileUriToPathText(parsed) + if (!filePath) { + return null + } + const hashTarget = parseFileUrlLineHash(parsed.hash) + if (hashTarget) { + return { pathText: filePath, line: hashTarget.line, column: hashTarget.column } + } + if (/%3a/i.test(parsed.pathname)) { + return { pathText: filePath, line: null, column: null } + } + return ( + parseFilePathTrailingLineTarget(filePath) ?? { pathText: filePath, line: null, column: null } + ) +} + +export function resolveTerminalOscFileTap(uri: string): TappedFilePath | null { + return resolveTerminalFileUrlTap(uri) ?? parseOscPathLikeTarget(uri) +} + +function terminalFileUriToPathText(url: URL): string | null { + if (url.protocol !== 'file:') { + return null + } + let decodedPath: string + try { + decodedPath = decodeURIComponent(url.pathname) + } catch { + return null + } + if (url.hostname && !isLocalFileUriHostname(url.hostname)) { + return `//${url.hostname}${decodedPath}` + } + if (/^\/[A-Za-z]:\//.test(decodedPath)) { + return decodedPath.slice(1) + } + return decodedPath || null +} + +function isLocalFileUriHostname(hostname: string): boolean { + const normalized = hostname.toLowerCase() + return ( + normalized === 'localhost' || + normalized === '127.0.0.1' || + normalized === '::1' || + normalized === '[::1]' + ) +} + +function parseOscPathLikeTarget(value: string): TappedFilePath | null { + if ( + !/^(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/]|(?=[A-Za-z0-9._-]*\.[A-Za-z0-9]))/.test( + value + ) + ) { + return null + } + return parsePathWithOptionalLineColumn(value) +} + +function parseFileUrlLineHash(hash: string): { line: number; column: number | null } | null { + const match = /^#?L(\d+)(?:C(\d+))?$/i.exec(hash) + if (!match) { + return null + } + const line = Number.parseInt(match[1]!, 10) + const column = match[2] ? Number.parseInt(match[2], 10) : null + if (line < 1 || (column !== null && column < 1)) { + return null + } + return { line, column } +} + +function parseFilePathTrailingLineTarget(filePath: string): TappedFilePath | null { + const match = /^(.*?)(?::(\d+))(?::(\d+))?$/.exec(filePath) + if (!match || !match[1] || match[1].endsWith('/') || match[1].endsWith('\\')) { + return null + } + const line = Number.parseInt(match[2]!, 10) + const column = match[3] ? Number.parseInt(match[3], 10) : null + if (line < 1 || (column !== null && column < 1)) { + return null + } + return { pathText: match[1], line, column } +} diff --git a/mobile/src/terminal/terminal-live-accessory-input.ts b/mobile/src/terminal/terminal-live-accessory-input.ts new file mode 100644 index 00000000000..5f483ebc0cf --- /dev/null +++ b/mobile/src/terminal/terminal-live-accessory-input.ts @@ -0,0 +1,21 @@ +import type { TerminalLiveAccessoryLocalEdit } from './terminal-live-text-commit' + +export type TerminalLiveAccessoryInput = { + readonly bytes: string + readonly localEdit?: TerminalLiveAccessoryLocalEdit +} + +type TerminalLiveAccessoryKey = { + readonly bytes: string + readonly id: string +} + +export function createTerminalLiveAccessoryInput( + key: TerminalLiveAccessoryKey +): TerminalLiveAccessoryInput { + if (key.id === 'backspace' || key.id === 'delete') { + return { bytes: key.bytes, localEdit: key.id } + } + + return { bytes: key.bytes } +} diff --git a/mobile/src/terminal/terminal-live-accessory-raw-send-target.test.ts b/mobile/src/terminal/terminal-live-accessory-raw-send-target.test.ts new file mode 100644 index 00000000000..5e484c354bc --- /dev/null +++ b/mobile/src/terminal/terminal-live-accessory-raw-send-target.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import { getTerminalLiveAccessoryRawSendTarget } from './terminal-live-accessory-raw-send-target' + +describe('terminal live accessory raw send target', () => { + it('Given the original terminal is still active When raw fallback resumes Then returns that handle', () => { + // Given + const targetHandle = 'terminal-a' + + // When + const sendTarget = getTerminalLiveAccessoryRawSendTarget({ + targetHandle, + activeHandle: targetHandle, + activeSessionTabType: 'terminal' + }) + + // Then + expect(sendTarget).toBe(targetHandle) + }) + + it('Given the active terminal changed while waiting When raw fallback resumes Then suppresses the send', () => { + // Given + const targetHandle = 'terminal-a' + + // When + const sendTarget = getTerminalLiveAccessoryRawSendTarget({ + targetHandle, + activeHandle: 'terminal-b', + activeSessionTabType: 'terminal' + }) + + // Then + expect(sendTarget).toBeNull() + }) + + it('Given the target is not an active terminal tab When raw fallback resumes Then suppresses the send', () => { + // Given + const targetHandle = 'terminal-a' + + // When + const inactiveTabTarget = getTerminalLiveAccessoryRawSendTarget({ + targetHandle, + activeHandle: targetHandle, + activeSessionTabType: 'browser' + }) + + // Then + expect(inactiveTabTarget).toBeNull() + }) +}) diff --git a/mobile/src/terminal/terminal-live-accessory-raw-send-target.ts b/mobile/src/terminal/terminal-live-accessory-raw-send-target.ts new file mode 100644 index 00000000000..f87138d210e --- /dev/null +++ b/mobile/src/terminal/terminal-live-accessory-raw-send-target.ts @@ -0,0 +1,17 @@ +type TerminalLiveAccessoryRawSendTargetInput = { + readonly targetHandle: string + readonly activeHandle: string | null + readonly activeSessionTabType: TTabType | null +} + +export function getTerminalLiveAccessoryRawSendTarget({ + targetHandle, + activeHandle, + activeSessionTabType +}: TerminalLiveAccessoryRawSendTargetInput): string | null { + if (targetHandle !== activeHandle || activeSessionTabType !== 'terminal') { + return null + } + + return targetHandle +} diff --git a/mobile/src/terminal/terminal-live-control-send-order.test.ts b/mobile/src/terminal/terminal-live-control-send-order.test.ts new file mode 100644 index 00000000000..8f1797c7ca0 --- /dev/null +++ b/mobile/src/terminal/terminal-live-control-send-order.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from 'vitest' +import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order' + +describe('terminal live control send order', () => { + it('Given a failed pending text flush When control bytes follow Then skips the control bytes', async () => { + // Given + const events: string[] = [] + const flushPendingText = vi.fn(async () => { + events.push('flush') + return false + }) + const sendControlBytes = vi.fn(async () => { + events.push('control') + return true + }) + + // When + const result = await sendTerminalLiveControlAfterPendingFlush( + flushPendingText, + sendControlBytes + ) + + // Then + expect(result).toBe(false) + expect(sendControlBytes).not.toHaveBeenCalled() + expect(events).toEqual(['flush']) + }) + + it('Given a successful pending text flush When control bytes follow Then sends them afterward', async () => { + // Given + const events: string[] = [] + const flushPendingText = vi.fn(async () => { + events.push('flush') + return true + }) + const sendControlBytes = vi.fn(async () => { + events.push('control') + return true + }) + + // When + const result = await sendTerminalLiveControlAfterPendingFlush( + flushPendingText, + sendControlBytes + ) + + // Then + expect(result).toBe(true) + expect(events).toEqual(['flush', 'control']) + }) + + it('Given a failed control byte send When pending text flushed Then reports failure', async () => { + // Given + const events: string[] = [] + const flushPendingText = vi.fn(async () => { + events.push('flush') + return true + }) + const sendControlBytes = vi.fn(async () => { + events.push('control') + return false + }) + + // When + const result = await sendTerminalLiveControlAfterPendingFlush( + flushPendingText, + sendControlBytes + ) + + // Then + expect(result).toBe(false) + expect(events).toEqual(['flush', 'control']) + }) +}) diff --git a/mobile/src/terminal/terminal-live-control-send-order.ts b/mobile/src/terminal/terminal-live-control-send-order.ts new file mode 100644 index 00000000000..185b49ee48e --- /dev/null +++ b/mobile/src/terminal/terminal-live-control-send-order.ts @@ -0,0 +1,12 @@ +export type TerminalLiveAsyncSendStep = () => Promise + +export async function sendTerminalLiveControlAfterPendingFlush( + flushPendingText: TerminalLiveAsyncSendStep, + sendControlBytes: TerminalLiveAsyncSendStep +): Promise { + const flushed = await flushPendingText() + if (!flushed) { + return false + } + return sendControlBytes() +} diff --git a/mobile/src/terminal/terminal-live-hangul-mirror.test.ts b/mobile/src/terminal/terminal-live-hangul-mirror.test.ts new file mode 100644 index 00000000000..b1a6669506c --- /dev/null +++ b/mobile/src/terminal/terminal-live-hangul-mirror.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it } from 'vitest' +import { + buildTerminalLiveMirrorPayload, + computeTerminalLiveMirrorStep, + isTerminalLiveHangulCodePoint, + type TerminalLiveMirrorStep +} from './terminal-live-hangul-mirror' + +type MirrorRun = { + readonly payloads: readonly string[] + readonly sentText: string + readonly heldText: string +} + +function runMirrorSequence( + fieldStates: readonly string[], + options: { readonly commitAtEnd: boolean } = { commitAtEnd: false } +): MirrorRun { + const payloads: string[] = [] + let sentText = '' + let heldText = '' + for (const fieldText of fieldStates) { + const step = computeTerminalLiveMirrorStep(sentText, fieldText, { commitHeld: false }) + const payload = buildTerminalLiveMirrorPayload(step) + if (payload.length > 0) { + payloads.push(payload) + } + sentText = step.nextSentText + heldText = step.heldText + } + if (options.commitAtEnd) { + const lastField = sentText + heldText + const step = computeTerminalLiveMirrorStep(sentText, lastField, { commitHeld: true }) + const payload = buildTerminalLiveMirrorPayload(step) + if (payload.length > 0) { + payloads.push(payload) + } + sentText = step.nextSentText + heldText = step.heldText + } + return { payloads, sentText, heldText } +} + +describe('terminal live hangul mirror', () => { + it('Given single-syllable composition When steps run Then leaks no jamo and commits only the final syllable', () => { + // Given / When + const run = runMirrorSequence(['ㅎ', '하', '한'], { commitAtEnd: true }) + + // Then + expect(run.payloads).toEqual(['한']) + expect(run.sentText).toBe('한') + expect(run.heldText).toBe('') + }) + + it('Given multi-syllable composition When a new syllable starts Then streams the stable prefix without erases', () => { + // Given / When + const run = runMirrorSequence(['ㅎ', '하', '한', '한ㄱ', '한그', '한글'], { commitAtEnd: true }) + + // Then + expect(run.payloads).toEqual(['한', '글']) + expect(run.sentText).toBe('한글') + }) + + it('Given dubeolsik resplit 간→가나 When steps run Then never sends the intermediate syllable', () => { + // Given / When + const run = runMirrorSequence(['ㄱ', '가', '간', '가나'], { commitAtEnd: true }) + + // Then + expect(run.payloads).toEqual(['가', '나']) + expect(run.sentText).toBe('가나') + }) + + it('Given a timer-committed syllable When composition continues Then erases and recommits via DEL correction', () => { + // Given: '하' was committed by the settle timer + const commit = computeTerminalLiveMirrorStep('', '하', { commitHeld: true }) + expect(buildTerminalLiveMirrorPayload(commit)).toBe('하') + expect(commit.nextSentText).toBe('하') + + // When: user keeps composing '하' → '한' + const correction = computeTerminalLiveMirrorStep(commit.nextSentText, '한', { + commitHeld: false + }) + + // Then: one DEL erases the stale syllable; the new one is held again + expect(buildTerminalLiveMirrorPayload(correction)).toBe('\x7f') + expect(correction.nextSentText).toBe('') + expect(correction.heldText).toBe('한') + + const recommit = computeTerminalLiveMirrorStep('', '한', { commitHeld: true }) + expect(buildTerminalLiveMirrorPayload(recommit)).toBe('한') + }) + + it('Given pure ASCII typing When steps run Then mirrors immediately with no held text', () => { + // Given / When + const run = runMirrorSequence(['a', 'ab', 'abc']) + + // Then + expect(run.payloads).toEqual(['a', 'b', 'c']) + expect(run.heldText).toBe('') + }) + + it('Given a trailing space after Hangul When the step runs Then the space commits the held syllable', () => { + // Given: '한글' typed, '한' streamed, '글' held + const beforeSpace = runMirrorSequence(['ㅎ', '하', '한', '한ㄱ', '한그', '한글']) + expect(beforeSpace.sentText).toBe('한') + expect(beforeSpace.heldText).toBe('글') + + // When + const step = computeTerminalLiveMirrorStep(beforeSpace.sentText, '한글 ', { + commitHeld: false + }) + + // Then + expect(buildTerminalLiveMirrorPayload(step)).toBe('글 ') + expect(step.heldText).toBe('') + expect(step.nextSentText).toBe('한글 ') + }) + + it('Given a trailing ASCII letter after Hangul When the step runs Then Hangul is committed with the letter', () => { + // Given + const held = computeTerminalLiveMirrorStep('', '한', { commitHeld: false }) + expect(held.heldText).toBe('한') + + // When + const step = computeTerminalLiveMirrorStep(held.nextSentText, '한a', { commitHeld: false }) + + // Then + expect(buildTerminalLiveMirrorPayload(step)).toBe('한a') + expect(step.heldText).toBe('') + }) + + it('Given sent text When the user deletes everything Then erases with one DEL per code point', () => { + // Given / When + const step = computeTerminalLiveMirrorStep('한글a', '', { commitHeld: false }) + + // Then + expect(step).toEqual({ + eraseCount: 3, + appendText: '', + nextSentText: '', + heldText: '' + }) + expect(buildTerminalLiveMirrorPayload(step)).toBe('\x7f\x7f\x7f') + }) + + it('Given empty field and empty sent text When committing Then produces a zero step', () => { + // Given / When + const step = computeTerminalLiveMirrorStep('', '', { commitHeld: true }) + + // Then + expect(buildTerminalLiveMirrorPayload(step)).toBe('') + expect(step).toEqual({ + eraseCount: 0, + appendText: '', + nextSentText: '', + heldText: '' + }) + }) + + it('Given non-Hangul IME text When the step runs Then it mirrors immediately without holding', () => { + // Given / When + const chinese = computeTerminalLiveMirrorStep('', '你好', { commitHeld: false }) + const vietnamese = computeTerminalLiveMirrorStep('', 'tiếng', { commitHeld: false }) + + // Then + expect(buildTerminalLiveMirrorPayload(chinese)).toBe('你好') + expect(chinese.heldText).toBe('') + expect(buildTerminalLiveMirrorPayload(vietnamese)).toBe('tiếng') + expect(vietnamese.heldText).toBe('') + }) + + it('Given Hangul code point ranges When checked Then jamo and syllables match and ASCII does not', () => { + expect(isTerminalLiveHangulCodePoint('ㅎ'.codePointAt(0) ?? 0)).toBe(true) + expect(isTerminalLiveHangulCodePoint('한'.codePointAt(0) ?? 0)).toBe(true) + expect(isTerminalLiveHangulCodePoint('a'.codePointAt(0) ?? 0)).toBe(false) + expect(isTerminalLiveHangulCodePoint('あ'.codePointAt(0) ?? 0)).toBe(false) + }) +}) diff --git a/mobile/src/terminal/terminal-live-hangul-mirror.ts b/mobile/src/terminal/terminal-live-hangul-mirror.ts new file mode 100644 index 00000000000..170d1779ffd --- /dev/null +++ b/mobile/src/terminal/terminal-live-hangul-mirror.ts @@ -0,0 +1,61 @@ +// Why: paused composition should still reach the PTY quickly; corrections make +// a premature commit safe, so this can be short without leaking jamo forever. +export const TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS = 300 + +const TERMINAL_DEL_BYTE = '\x7f' + +export function isTerminalLiveHangulCodePoint(codePoint: number): boolean { + return ( + (codePoint >= 0x1100 && codePoint <= 0x11ff) || + (codePoint >= 0x3130 && codePoint <= 0x318f) || + (codePoint >= 0xa960 && codePoint <= 0xa97f) || + (codePoint >= 0xac00 && codePoint <= 0xd7af) + ) +} + +export type TerminalLiveMirrorStep = { + readonly eraseCount: number + readonly appendText: string + readonly nextSentText: string + readonly heldText: string +} + +// Why: React Native exposes no composition events, but Hangul composition only +// mutates the trailing syllable. Holding just that code point keeps the PTY +// echo live while preedit jamo never leak; DEL corrections repair any commit +// that later turns out to be premature. +export function computeTerminalLiveMirrorStep( + sentText: string, + fieldText: string, + options: { readonly commitHeld: boolean } +): TerminalLiveMirrorStep { + const fieldCodePoints = Array.from(fieldText) + const lastCodePoint = fieldCodePoints.at(-1) + const holdLast = + !options.commitHeld && + lastCodePoint !== undefined && + isTerminalLiveHangulCodePoint(lastCodePoint.codePointAt(0) ?? 0) + const heldText = holdLast && lastCodePoint !== undefined ? lastCodePoint : '' + const targetCodePoints = holdLast ? fieldCodePoints.slice(0, -1) : fieldCodePoints + const sentCodePoints = Array.from(sentText) + + let commonPrefixLength = 0 + while ( + commonPrefixLength < sentCodePoints.length && + commonPrefixLength < targetCodePoints.length && + sentCodePoints[commonPrefixLength] === targetCodePoints[commonPrefixLength] + ) { + commonPrefixLength += 1 + } + + return { + eraseCount: sentCodePoints.length - commonPrefixLength, + appendText: targetCodePoints.slice(commonPrefixLength).join(''), + nextSentText: targetCodePoints.join(''), + heldText + } +} + +export function buildTerminalLiveMirrorPayload(step: TerminalLiveMirrorStep): string { + return TERMINAL_DEL_BYTE.repeat(step.eraseCount) + step.appendText +} diff --git a/mobile/src/terminal/terminal-live-input-affordance.test.ts b/mobile/src/terminal/terminal-live-input-affordance.test.ts new file mode 100644 index 00000000000..1ceab18ab60 --- /dev/null +++ b/mobile/src/terminal/terminal-live-input-affordance.test.ts @@ -0,0 +1,49 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' + +const sessionRouteSource = readFileSync( + new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url), + 'utf8' +) +const liveInputStatusSource = readFileSync( + new URL('../session/MobileTerminalLiveInputStatus.tsx', import.meta.url), + 'utf8' +) +const commandInputStylesSource = readFileSync( + new URL('../../app/h/[hostId]/session/mobile-session-command-input-styles.ts', import.meta.url), + 'utf8' +) + +function liveInputBarBlock(): string { + const start = sessionRouteSource.indexOf('{liveInputEnabled ? (') + expect(start).toBeGreaterThanOrEqual(0) + const end = sessionRouteSource.indexOf(') : (', start) + expect(end).toBeGreaterThan(start) + return sessionRouteSource.slice(start, end) +} + +describe('terminal live input affordance', () => { + it('keeps the live status row wired as the keyboard focus control', () => { + const block = liveInputBarBlock() + + expect(block).toContain('onPress={focusLiveInput}') + expect(block).toContain('accessibilityRole="button"') + expect(block).toContain('accessibilityLabel="Show keyboard for live terminal input"') + expect(block).toContain( + 'accessibilityHint="Typed text is sent directly to the active terminal"' + ) + expect(block).toContain('pressed && styles.liveInputFocusTargetPressed') + expect(block).toContain('!canSend && styles.liveInputFocusTargetDisabled') + expect(block).toContain('showSoftInputOnFocus') + expect(sessionRouteSource).toContain('focusTerminalLiveInputTarget(liveInputRef.current') + expect(sessionRouteSource).toContain('keyboardHeight') + expect(sessionRouteSource).toContain('scheduleTerminalLiveInputFocus(liveInputFocusTimerRef') + }) + + it('makes the live keyboard target visible instead of status-only chrome', () => { + expect(liveInputStatusSource).toContain("'Tap to show keyboard'") + expect(commandInputStylesSource).toContain('backgroundColor: colors.bgRaised') + expect(commandInputStylesSource).toContain('borderWidth: 1') + expect(commandInputStylesSource).toContain('liveInputFocusTargetPressed') + }) +}) diff --git a/mobile/src/terminal/terminal-live-input-sender.ts b/mobile/src/terminal/terminal-live-input-sender.ts new file mode 100644 index 00000000000..c3538d1f723 --- /dev/null +++ b/mobile/src/terminal/terminal-live-input-sender.ts @@ -0,0 +1 @@ +export type TerminalLiveInputSender = (handle: string, bytes: string) => Promise diff --git a/mobile/src/terminal/terminal-live-input.test.ts b/mobile/src/terminal/terminal-live-input.test.ts index db3e96236bc..daca212d4a8 100644 --- a/mobile/src/terminal/terminal-live-input.test.ts +++ b/mobile/src/terminal/terminal-live-input.test.ts @@ -1,12 +1,16 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { TERMINAL_LIVE_INPUT_MAX_BYTES, + applyDisabledTerminalLiveInputHandles, clearTerminalLiveInputFocusTimer, defaultTerminalLiveInputHandles, + filterTerminalLiveInputDefaultCandidates, + focusTerminalLiveInputTarget, getTerminalLiveSpecialKeyBytes, isTerminalLiveInputWithinByteLimit, pruneTerminalLiveInputHandles, scheduleTerminalLiveInputFocus, + type TerminalLiveInputFocusTarget, type TerminalLiveInputFocusTimerRef } from './terminal-live-input' @@ -14,6 +18,14 @@ function createTimerRef(): TerminalLiveInputFocusTimerRef { return { current: null } } +function createFocusTarget(isFocused: () => boolean): TerminalLiveInputFocusTarget { + return { + blur: vi.fn(), + focus: vi.fn(), + isFocused + } +} + describe('terminal live input', () => { afterEach(() => { vi.useRealTimers() @@ -101,6 +113,35 @@ describe('terminal live input', () => { expect(result.defaultedHandles).toBe(defaulted) }) + it('does not default persisted buffered-mode handles back to live input on reentry', () => { + const defaultableHandles = filterTerminalLiveInputDefaultCandidates( + ['pty-1', 'pty-2'], + new Set(['pty-1']) + ) + + const result = defaultTerminalLiveInputHandles( + new Set(), + new Set(['pty-1']), + defaultableHandles + ) + + expect(defaultableHandles).toEqual(['pty-2']) + expect([...result.enabledHandles]).toEqual(['pty-2']) + expect([...result.defaultedHandles]).toEqual(['pty-1', 'pty-2']) + }) + + it('reconciles persisted buffered-mode handles with currently enabled live input', () => { + const result = applyDisabledTerminalLiveInputHandles( + new Set(['pty-1', 'pty-2']), + new Set(['pty-2']), + new Set(['pty-1']) + ) + + expect(result.changed).toBe(true) + expect([...result.enabledHandles]).toEqual(['pty-2']) + expect([...result.defaultedHandles]).toEqual(['pty-2', 'pty-1']) + }) + it('prunes terminal handles that disappear from session snapshots', () => { const result = pruneTerminalLiveInputHandles( new Set(['pty-1', 'pty-stale']), @@ -150,4 +191,37 @@ describe('terminal live input', () => { expect(focus).not.toHaveBeenCalled() expect(timerRef.current).toBeNull() }) + + it('refocuses an already-focused capture input when the keyboard is closed', () => { + const input = createFocusTarget(() => true) + const refocus = vi.fn() + + focusTerminalLiveInputTarget(input, { keyboardHeight: 0, refocus }) + + expect(input.blur).toHaveBeenCalledTimes(1) + expect(input.focus).not.toHaveBeenCalled() + expect(refocus).toHaveBeenCalledTimes(1) + }) + + it('focuses the capture input directly when the keyboard is open', () => { + const input = createFocusTarget(() => true) + const refocus = vi.fn() + + focusTerminalLiveInputTarget(input, { keyboardHeight: 240, refocus }) + + expect(input.blur).not.toHaveBeenCalled() + expect(input.focus).toHaveBeenCalledTimes(1) + expect(refocus).not.toHaveBeenCalled() + }) + + it('focuses the capture input directly when it is not already focused', () => { + const input = createFocusTarget(() => false) + const refocus = vi.fn() + + focusTerminalLiveInputTarget(input, { keyboardHeight: 0, refocus }) + + expect(input.blur).not.toHaveBeenCalled() + expect(input.focus).toHaveBeenCalledTimes(1) + expect(refocus).not.toHaveBeenCalled() + }) }) diff --git a/mobile/src/terminal/terminal-live-input.ts b/mobile/src/terminal/terminal-live-input.ts index dd5f8e6a709..321226c0c7d 100644 --- a/mobile/src/terminal/terminal-live-input.ts +++ b/mobile/src/terminal/terminal-live-input.ts @@ -66,6 +66,17 @@ export type TerminalLiveInputFocusTimerRef = { current: ReturnType | null } +export type TerminalLiveInputFocusTarget = { + readonly focus: () => void + readonly blur: () => void + readonly isFocused?: () => boolean +} + +type FocusTerminalLiveInputTargetOptions = { + readonly keyboardHeight: number + readonly refocus: () => void +} + export type TerminalLiveInputDefaultResult = { enabledHandles: ReadonlySet defaultedHandles: ReadonlySet @@ -118,6 +129,48 @@ export function defaultTerminalLiveInputHandles( } } +export function filterTerminalLiveInputDefaultCandidates( + terminalHandles: readonly string[], + disabledHandles: ReadonlySet +): string[] { + return terminalHandles.filter((handle) => !disabledHandles.has(handle)) +} + +export function applyDisabledTerminalLiveInputHandles( + enabledHandles: ReadonlySet, + defaultedHandles: ReadonlySet, + disabledHandles: ReadonlySet +): TerminalLiveInputDefaultResult { + let nextEnabledHandles: Set | null = null + let nextDefaultedHandles: Set | null = null + + for (const handle of enabledHandles) { + if (!disabledHandles.has(handle)) { + continue + } + nextEnabledHandles ??= new Set(enabledHandles) + nextEnabledHandles.delete(handle) + } + + for (const handle of disabledHandles) { + if (defaultedHandles.has(handle)) { + continue + } + nextDefaultedHandles ??= new Set(defaultedHandles) + nextDefaultedHandles.add(handle) + } + + if (!nextEnabledHandles && !nextDefaultedHandles) { + return { enabledHandles, defaultedHandles, changed: false } + } + + return { + enabledHandles: nextEnabledHandles ?? enabledHandles, + defaultedHandles: nextDefaultedHandles ?? defaultedHandles, + changed: true + } +} + export function pruneTerminalLiveInputHandles( enabledHandles: ReadonlySet, defaultedHandles: ReadonlySet, @@ -175,4 +228,23 @@ export function scheduleTerminalLiveInputFocus( }, delayMs) } +export function focusTerminalLiveInputTarget( + input: TerminalLiveInputFocusTarget | null, + { keyboardHeight, refocus }: FocusTerminalLiveInputTargetOptions +): void { + if (!input) { + return + } + + if (keyboardHeight <= 0 && input.isFocused?.()) { + // Why: Android can keep a hidden TextInput focused after the IME is dismissed; + // focus() is then a no-op, so force a new focus session to reopen the keyboard. + input.blur() + refocus() + return + } + + input.focus() +} + export { TERMINAL_LIVE_INPUT_MAX_BYTES } diff --git a/mobile/src/terminal/terminal-live-pending-flush-state.test.ts b/mobile/src/terminal/terminal-live-pending-flush-state.test.ts new file mode 100644 index 00000000000..88918c00681 --- /dev/null +++ b/mobile/src/terminal/terminal-live-pending-flush-state.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest' +import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order' +import { + queueTerminalLiveMirrorSend, + waitForTerminalLivePendingFlush, + type TerminalLivePendingFlushState +} from './terminal-live-pending-flush-state' + +describe('terminal live pending flush state', () => { + it('Given no in-flight flush When waiting for the barrier Then allows control input', async () => { + // Given + const state: TerminalLivePendingFlushState = { current: null } + + // When / Then + await expect(waitForTerminalLivePendingFlush(state)).resolves.toBe(true) + }) + + it('Given an in-flight flush When control input waits Then control is held until flush succeeds', async () => { + // Given + const events: string[] = [] + let resolveFlush: (value: boolean) => void = () => {} + const flushPromise = new Promise((resolve) => { + resolveFlush = resolve + }) + const state: TerminalLivePendingFlushState = { current: flushPromise } + + // When + const controlSend = sendTerminalLiveControlAfterPendingFlush( + () => waitForTerminalLivePendingFlush(state), + async () => { + events.push('control') + return true + } + ) + await Promise.resolve() + + // Then + expect(events).toEqual([]) + resolveFlush(true) + await expect(controlSend).resolves.toBe(true) + expect(events).toEqual(['control']) + }) + + it('Given an in-flight flush fails When control input waits Then control is skipped', async () => { + // Given + const events: string[] = [] + let resolveFlush: (value: boolean) => void = () => {} + const flushPromise = new Promise((resolve) => { + resolveFlush = resolve + }) + const state: TerminalLivePendingFlushState = { current: flushPromise } + + // When + const controlSend = sendTerminalLiveControlAfterPendingFlush( + () => waitForTerminalLivePendingFlush(state), + async () => { + events.push('control') + return true + } + ) + resolveFlush(false) + + // Then + await expect(controlSend).resolves.toBe(false) + expect(events).toEqual([]) + }) +}) + +describe('terminal live mirror send queue', () => { + it('Given a failed previous send When a mirror send queues Then it still runs in order', async () => { + // Given + const state: TerminalLivePendingFlushState = { current: null } + const order: string[] = [] + const first = queueTerminalLiveMirrorSend(state, async () => { + order.push('first') + return false + }) + + // When + const second = queueTerminalLiveMirrorSend(state, async () => { + order.push('second') + return true + }) + + // Then + await expect(first).resolves.toBe(false) + await expect(second).resolves.toBe(true) + expect(order).toEqual(['first', 'second']) + }) + + it('Given a throwing send When a mirror send queues Then the promise resolves false and the chain continues', async () => { + // Given + const state: TerminalLivePendingFlushState = { current: null } + const first = queueTerminalLiveMirrorSend(state, async () => { + throw new Error('boom') + }) + + // When + const second = queueTerminalLiveMirrorSend(state, async () => true) + + // Then + await expect(first).resolves.toBe(false) + await expect(second).resolves.toBe(true) + }) + + it('Given a settled mirror send When it was the newest Then the state resets to null', async () => { + // Given + const state: TerminalLivePendingFlushState = { current: null } + + // When + await queueTerminalLiveMirrorSend(state, async () => true) + await Promise.resolve() + + // Then + expect(state.current).toBeNull() + }) +}) diff --git a/mobile/src/terminal/terminal-live-pending-flush-state.ts b/mobile/src/terminal/terminal-live-pending-flush-state.ts new file mode 100644 index 00000000000..405198964f3 --- /dev/null +++ b/mobile/src/terminal/terminal-live-pending-flush-state.ts @@ -0,0 +1,33 @@ +export type TerminalLivePendingFlushState = { + current: Promise | null +} + +export function waitForTerminalLivePendingFlush( + state: TerminalLivePendingFlushState +): Promise { + return state.current ?? Promise.resolve(true) +} + +// Why: mirror payloads are erase/append deltas against the PTY echo. A skipped +// delta desyncs every later diff, so this chain runs each send even when the +// previous one failed. state.current should never reject; the catch keeps a +// future raw assignment from skipping a delta. +export function queueTerminalLiveMirrorSend( + state: TerminalLivePendingFlushState, + sendMirrorPayload: () => Promise +): Promise { + const previousSend = state.current + const sendPromise = (async () => { + if (previousSend) { + await previousSend.catch(() => false) + } + return sendMirrorPayload() + })().catch(() => false) + state.current = sendPromise + void sendPromise.then(() => { + if (state.current === sendPromise) { + state.current = null + } + }) + return sendPromise +} diff --git a/mobile/src/terminal/terminal-live-text-commit.test.ts b/mobile/src/terminal/terminal-live-text-commit.test.ts new file mode 100644 index 00000000000..a1206645f25 --- /dev/null +++ b/mobile/src/terminal/terminal-live-text-commit.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from 'vitest' +import { + getTerminalLiveAccessoryBytesDecision, + getTerminalLiveAccessoryLocalEditText, + getTerminalLiveSpecialKeyDecision +} from './terminal-live-text-commit' + +describe('terminal live special key decision', () => { + it('Given an unmapped key Then ignores it', () => { + expect(getTerminalLiveSpecialKeyDecision({ key: 'ㅎ', heldText: '', sentText: '' })).toEqual({ + kind: 'ignore' + }) + }) + + it('Given Backspace with any field text Then edits locally so the mirror diff handles the PTY erase', () => { + // Held syllable present + expect( + getTerminalLiveSpecialKeyDecision({ key: 'Backspace', heldText: '한', sentText: '' }) + ).toEqual({ kind: 'local-edit' }) + // Only mirrored text present — native edit fires onChangeText and the diff erases + expect( + getTerminalLiveSpecialKeyDecision({ key: 'Backspace', heldText: '', sentText: 'abc' }) + ).toEqual({ kind: 'local-edit' }) + }) + + it('Given Backspace with an empty field Then sends terminal backspace bytes', () => { + const decision = getTerminalLiveSpecialKeyDecision({ + key: 'Backspace', + heldText: '', + sentText: '' + }) + expect(decision.kind).toBe('send-now') + }) + + it('Given a control key with a held syllable Then commits the held text before the bytes', () => { + const decision = getTerminalLiveSpecialKeyDecision({ + key: 'Tab', + heldText: '글', + sentText: '한' + }) + expect(decision.kind).toBe('commit-held-then-send') + }) + + it('Given a control key with no held syllable Then sends immediately', () => { + const decision = getTerminalLiveSpecialKeyDecision({ + key: 'ArrowUp', + heldText: '', + sentText: 'ls' + }) + expect(decision.kind).toBe('send-now') + }) +}) + +describe('terminal live accessory bytes decision', () => { + it('Given a local-edit accessory key with field text Then edits locally', () => { + expect( + getTerminalLiveAccessoryBytesDecision({ + bytes: '\x7f', + localEdit: 'backspace', + heldText: '한', + sentText: '' + }) + ).toEqual({ kind: 'local-edit', localEdit: 'backspace' }) + expect( + getTerminalLiveAccessoryBytesDecision({ + bytes: '\x7f', + localEdit: 'backspace', + heldText: '', + sentText: 'abc' + }) + ).toEqual({ kind: 'local-edit', localEdit: 'backspace' }) + }) + + it('Given raw accessory bytes with a held syllable Then commits held text first', () => { + const decision = getTerminalLiveAccessoryBytesDecision({ + bytes: '\x1b', + heldText: '한', + sentText: '' + }) + expect(decision).toEqual({ kind: 'commit-held-then-send', bytes: '\x1b' }) + }) + + it('Given raw accessory bytes with nothing held Then sends immediately', () => { + const decision = getTerminalLiveAccessoryBytesDecision({ + bytes: '\x1b', + heldText: '', + sentText: 'abc' + }) + expect(decision).toEqual({ kind: 'send-now', bytes: '\x1b' }) + }) + + it('Given a local-edit accessory key with an empty field Then sends the raw bytes', () => { + const decision = getTerminalLiveAccessoryBytesDecision({ + bytes: '\x7f', + localEdit: 'backspace', + heldText: '', + sentText: '' + }) + expect(decision).toEqual({ kind: 'send-now', bytes: '\x7f' }) + }) +}) + +describe('terminal live accessory local edit text', () => { + it('Given backspace Then drops the last code point of the field text', () => { + expect( + getTerminalLiveAccessoryLocalEditText({ localEdit: 'backspace', fieldText: '한글' }) + ).toBe('한') + expect(getTerminalLiveAccessoryLocalEditText({ localEdit: 'backspace', fieldText: '한' })).toBe( + '' + ) + }) + + it('Given forward delete Then keeps the field text unchanged', () => { + expect(getTerminalLiveAccessoryLocalEditText({ localEdit: 'delete', fieldText: '한글' })).toBe( + '한글' + ) + }) +}) diff --git a/mobile/src/terminal/terminal-live-text-commit.ts b/mobile/src/terminal/terminal-live-text-commit.ts new file mode 100644 index 00000000000..77752e0775f --- /dev/null +++ b/mobile/src/terminal/terminal-live-text-commit.ts @@ -0,0 +1,83 @@ +import { getTerminalLiveSpecialKeyBytes } from './terminal-live-input' + +export type TerminalLiveSpecialKeyDecision = + | { readonly kind: 'ignore' } + | { readonly kind: 'local-edit' } + | { readonly kind: 'send-now'; readonly bytes: string } + | { readonly kind: 'commit-held-then-send'; readonly bytes: string } + +export type TerminalLiveSpecialKeyDecisionInput = { + readonly key: string + readonly heldText: string + readonly sentText: string +} + +export type TerminalLiveAccessoryLocalEdit = 'backspace' | 'delete' + +export type TerminalLiveAccessoryBytesDecision = + | { readonly kind: 'local-edit'; readonly localEdit: TerminalLiveAccessoryLocalEdit } + | { readonly kind: 'send-now'; readonly bytes: string } + | { readonly kind: 'commit-held-then-send'; readonly bytes: string } + +export type TerminalLiveAccessoryBytesDecisionInput = { + readonly bytes: string + readonly localEdit?: TerminalLiveAccessoryLocalEdit + readonly heldText: string + readonly sentText: string +} + +export function getTerminalLiveSpecialKeyDecision({ + key, + heldText, + sentText +}: TerminalLiveSpecialKeyDecisionInput): TerminalLiveSpecialKeyDecision { + const bytes = getTerminalLiveSpecialKeyBytes(key) + if (bytes === null) { + return { kind: 'ignore' } + } + + // Why: native field edits fire onChangeText and the mirror diff emits the + // matching PTY erase; sending raw DEL here as well would double-erase. + if ((key === 'Backspace' || key === 'Delete') && (heldText.length > 0 || sentText.length > 0)) { + return { kind: 'local-edit' } + } + + if (heldText.length > 0) { + return { kind: 'commit-held-then-send', bytes } + } + + return { kind: 'send-now', bytes } +} + +export function getTerminalLiveAccessoryBytesDecision({ + bytes, + localEdit, + heldText, + sentText +}: TerminalLiveAccessoryBytesDecisionInput): TerminalLiveAccessoryBytesDecision { + if (localEdit && (heldText.length > 0 || sentText.length > 0)) { + return { kind: 'local-edit', localEdit } + } + + if (heldText.length > 0) { + return { kind: 'commit-held-then-send', bytes } + } + + return { kind: 'send-now', bytes } +} + +export function getTerminalLiveAccessoryLocalEditText({ + localEdit, + fieldText +}: { + readonly localEdit: TerminalLiveAccessoryLocalEdit + readonly fieldText: string +}): string { + if (localEdit === 'delete') { + // Why: accessory Delete mirrors forward-delete at the hidden input's end; + // it stays local but does not remove the field text. + return fieldText + } + + return Array.from(fieldText).slice(0, -1).join('') +} diff --git a/mobile/src/terminal/terminal-path-tap-injected.ts b/mobile/src/terminal/terminal-path-tap-injected.ts index 4ce3deb36f1..766c381ea22 100644 --- a/mobile/src/terminal/terminal-path-tap-injected.ts +++ b/mobile/src/terminal/terminal-path-tap-injected.ts @@ -14,11 +14,12 @@ // often print a bare filename (the markdown link target is consumed, leaving // only the label text), so requiring a slash would miss the common case. export const TERMINAL_PATH_TAP_JS = String.raw` - var FILE_PATH_RE = /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/]|(?=[A-Za-z0-9._-]*\.[A-Za-z0-9]))[A-Za-z0-9._~\-\/%+@\\()[\]]*(?::\d+)?(?::\d+)?/g; - var PATH_LEADING_TRIM = { '(': 1, '[': 1, '{': 1, '"': 1, "'": 1 }; - var PATH_TRAILING_TRIM = { ')': 1, ']': 1, '}': 1, '"': 1, "'": 1, ',': 1, ';': 1, '.': 1 }; + var FILE_PATH_RE = /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/]|(?=[A-Za-z0-9._-]*\.[A-Za-z0-9]))[A-Za-z0-9._~\-\/%+@\\()[\]]*(?::\d+)?(?::\d+)?/g; + var SPACED_PATH_RE = /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/])[^()[\]{}'",;<>|\`\r\n]+(?::\d+)?(?::\d+)?/g; + var PATH_LEADING_TRIM = { '(': 1, '[': 1, '{': 1, '"': 1, "'": 1 }; + var PATH_TRAILING_TRIM = { ')': 1, ']': 1, '}': 1, '"': 1, "'": 1, ',': 1, ';': 1, '.': 1 }; - function parsePathLineCol(value) { + function parsePathLineCol(value) { var m = /^(.*?)(?::(\d+))?(?::(\d+))?$/.exec(value); if (!m) return null; var pathText = m[1]; @@ -27,24 +28,91 @@ export const TERMINAL_PATH_TAP_JS = String.raw` var line = m[2] ? parseInt(m[2], 10) : null; var column = m[3] ? parseInt(m[3], 10) : null; if ((line !== null && line < 1) || (column !== null && column < 1)) return null; - return { pathText: pathText, line: line, column: column }; - } + return { pathText: pathText, line: line, column: column }; + } - function matchFilePathAtColumn(lineText, col) { - FILE_PATH_RE.lastIndex = 0; + function trimPathBoundaryPunctuation(raw, rawStart) { + var start = 0, end = raw.length; + while (start < end && PATH_LEADING_TRIM[raw.charAt(start)]) start += 1; + while (end > start && PATH_TRAILING_TRIM[raw.charAt(end - 1)]) end -= 1; + if (start >= end) return null; + return { text: raw.slice(start, end), startIndex: rawStart + start, endIndex: rawStart + end }; + } + + function hasSeparatorAfterWhitespace(text) { + var sawWhitespace = false; + for (var i = 0; i < text.length; i++) { + var ch = text.charAt(i); + if (/\s/.test(ch)) { sawWhitespace = true; continue; } + if (sawWhitespace && (ch === '/' || ch === '\\')) return true; + } + return false; + } + + function trimSpacedPathTrailingProse(range, col) { + // A line-end extension token only extends the span when the added segment + // is path-like (contains a separator) — prose must not be swallowed. + var selected = null; + var extensionPrefixPattern = /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?(?=\s+|$)/g; + var match; + while ((match = extensionPrefixPattern.exec(range.text)) !== null) { + var end = match.index + match[0].length; + var text = range.text.slice(0, end); + if (countPathStarts(text) > 1) continue; + if (end < range.text.length || selected === null || /[\\/]/.test(range.text.slice(selected.length, end))) { + selected = text; + } + } + if (selected) { + if (col !== undefined && col >= range.startIndex + selected.length) return null; + return { text: selected, startIndex: range.startIndex, endIndex: range.startIndex + selected.length }; + } + var text = range.text.replace(/\s+$/, ''); + return { text: text, startIndex: range.startIndex, endIndex: range.startIndex + text.length }; + } + + function countPathStarts(text) { + var count = 0; + var pathStartPattern = /(?:^|\s)(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/])/g; + while (pathStartPattern.exec(text) !== null) count += 1; + return count; + } + + function hasSpacedPathExtension(text) { + var range = trimSpacedPathTrailingProse({ text: text, startIndex: 0, endIndex: text.length }); + if (!range) return false; + var trimmed = range.text.replace(/\s+$/, ''); + return /\s/.test(trimmed) && /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?$/.test(trimmed); + } + + function matchSpacedFilePathAtColumn(lineText, col) { + SPACED_PATH_RE.lastIndex = 0; + var match; + while ((match = SPACED_PATH_RE.exec(lineText)) !== null) { + var trimmed = trimPathBoundaryPunctuation(match[0], match.index); + if (!trimmed || (!hasSeparatorAfterWhitespace(trimmed.text) && !hasSpacedPathExtension(trimmed.text))) continue; + var candidate = trimSpacedPathTrailingProse(trimmed, col); + if (!candidate) continue; + if (col < candidate.startIndex || col >= candidate.endIndex) continue; + var parsed = parsePathLineCol(candidate.text); + if (parsed) return parsed; + } + return null; + } + + function matchFilePathAtColumn(lineText, col) { + var spaced = matchSpacedFilePathAtColumn(lineText, col); + if (spaced) return spaced; + FILE_PATH_RE.lastIndex = 0; var match; while ((match = FILE_PATH_RE.exec(lineText)) !== null) { var raw = match[0]; if (raw.length === 0) { FILE_PATH_RE.lastIndex += 1; continue; } - var start = 0, end = raw.length; - while (start < end && PATH_LEADING_TRIM[raw.charAt(start)]) start += 1; - while (end > start && PATH_TRAILING_TRIM[raw.charAt(end - 1)]) end -= 1; - if (start >= end) continue; - var spanStart = match.index + start; - var spanEnd = match.index + end; - if (col < spanStart || col > spanEnd) continue; - var parsed = parsePathLineCol(raw.slice(start, end)); - if (parsed) return parsed; + var trimmed = trimPathBoundaryPunctuation(raw, match.index); + if (!trimmed) continue; + if (col < trimmed.startIndex || col >= trimmed.endIndex) continue; + var parsed = parsePathLineCol(trimmed.text); + if (parsed) return parsed; } return null; } diff --git a/mobile/src/terminal/terminal-path-tap.test.ts b/mobile/src/terminal/terminal-path-tap.test.ts index e31971b6ec6..ebb8e0b9ec3 100644 --- a/mobile/src/terminal/terminal-path-tap.test.ts +++ b/mobile/src/terminal/terminal-path-tap.test.ts @@ -1,6 +1,14 @@ +import { Script, createContext } from 'node:vm' import { describe, expect, it } from 'vitest' +import { + TERMINAL_FILE_LINK_TAP_CONFORMANCE_CASES, + columnForTerminalFileLinkTap +} from '../../../src/shared/terminal-file-link-conformance' +import { TERMINAL_PATH_TAP_JS } from './terminal-path-tap-injected' import { matchFilePathAtColumn, parsePathWithOptionalLineColumn } from './terminal-path-tap' +type InjectedPathMatcher = typeof matchFilePathAtColumn + // Returns the column of the first occurrence of `needle` in `line` (+offset). function colOf(line: string, needle: string, offset = 0): number { return line.indexOf(needle) + offset @@ -32,6 +40,15 @@ describe('parsePathWithOptionalLineColumn', () => { }) describe('matchFilePathAtColumn', () => { + it.each(TERMINAL_FILE_LINK_TAP_CONFORMANCE_CASES)( + 'matches shared terminal file-link tap case: $name', + (testCase) => { + expect( + matchFilePathAtColumn(testCase.lineText, columnForTerminalFileLinkTap(testCase)) + ).toEqual(testCase.expected) + } + ) + it('matches an absolute path under the tap', () => { const line = 'created /tmp/out/report.html for you' const result = matchFilePathAtColumn(line, colOf(line, 'report')) @@ -50,12 +67,68 @@ describe('matchFilePathAtColumn', () => { expect(result?.pathText).toBe('~/Documents/notes.md') }) - it('yields the tight whitespace-bounded segment under the tap', () => { - // On a path whose dir name has a space, tapping the file segment yields the - // openable sub-path after the space (still resolves against the worktree). + it('matches a path whose directory name contains a space', () => { const line = '/Users/me/My Project/readme.md done' const result = matchFilePathAtColumn(line, colOf(line, 'readme')) - expect(result?.pathText).toBe('Project/readme.md') + expect(result?.pathText).toBe('/Users/me/My Project/readme.md') + }) + + it('matches a spaced path with line and column suffixes', () => { + const line = 'wrote /tmp/orca report/result.json:12:3 for you' + const result = matchFilePathAtColumn(line, colOf(line, 'result')) + expect(result).toEqual({ + pathText: '/tmp/orca report/result.json', + line: 12, + column: 3 + }) + }) + + it('matches a spaced path when an earlier directory segment contains a dot', () => { + const line = 'wrote /tmp/v1.2 reports/result.json for you' + expect(matchFilePathAtColumn(line, colOf(line, 'v1.2'))).toEqual({ + pathText: '/tmp/v1.2 reports/result.json', + line: null, + column: null + }) + expect(matchFilePathAtColumn(line, colOf(line, 'reports'))).toEqual({ + pathText: '/tmp/v1.2 reports/result.json', + line: null, + column: null + }) + expect(matchFilePathAtColumn(line, colOf(line, 'result'))).toEqual({ + pathText: '/tmp/v1.2 reports/result.json', + line: null, + column: null + }) + }) + + it('matches a path whose final filename contains a space', () => { + const line = 'wrote /tmp/final report.json for you' + expect(matchFilePathAtColumn(line, colOf(line, 'final'))).toEqual({ + pathText: '/tmp/final report.json', + line: null, + column: null + }) + + expect(matchFilePathAtColumn(line, colOf(line, 'report'))).toEqual({ + pathText: '/tmp/final report.json', + line: null, + column: null + }) + }) + + it('does not merge two spaced path candidates through prose', () => { + const line = 'see /tmp/a.txt and /tmp/b.txt done' + expect(matchFilePathAtColumn(line, colOf(line, 'a.txt'))).toEqual({ + pathText: '/tmp/a.txt', + line: null, + column: null + }) + expect(matchFilePathAtColumn(line, colOf(line, 'b.txt'))).toEqual({ + pathText: '/tmp/b.txt', + line: null, + column: null + }) }) it('trims surrounding punctuation', () => { @@ -74,6 +147,11 @@ describe('matchFilePathAtColumn', () => { expect(matchFilePathAtColumn(line, 0)).toBeNull() }) + it('returns null when the tap is immediately after the path span', () => { + const line = 'prefix /tmp/x.ts next' + expect(matchFilePathAtColumn(line, line.indexOf(' next'))).toBeNull() + }) + it('matches a bare filename with an extension (no slash)', () => { // Why: agents commonly print a bare filename (e.g. a markdown link whose // target was consumed). The host existence-check rejects non-files. @@ -87,3 +165,24 @@ describe('matchFilePathAtColumn', () => { expect(matchFilePathAtColumn(line, colOf(line, 'Here'))).toBeNull() }) }) + +describe('injected matchFilePathAtColumn', () => { + const matcher = createInjectedPathMatcher() + + it.each(TERMINAL_FILE_LINK_TAP_CONFORMANCE_CASES)( + 'matches shared terminal file-link tap case: $name', + (testCase) => { + expect(matcher(testCase.lineText, columnForTerminalFileLinkTap(testCase))).toEqual( + testCase.expected + ) + } + ) +}) + +function createInjectedPathMatcher(): InjectedPathMatcher { + const context = createContext({}) + new Script( + `${TERMINAL_PATH_TAP_JS}\nthis.__matchFilePathAtColumn = matchFilePathAtColumn;` + ).runInContext(context) + return (context as { __matchFilePathAtColumn: InjectedPathMatcher }).__matchFilePathAtColumn +} diff --git a/mobile/src/terminal/terminal-path-tap.ts b/mobile/src/terminal/terminal-path-tap.ts index 7c9d295158b..ac2a8ea0ff3 100644 --- a/mobile/src/terminal/terminal-path-tap.ts +++ b/mobile/src/terminal/terminal-path-tap.ts @@ -13,11 +13,11 @@ export type TappedFilePath = { // a bare filename with an extension (README.md, index.ts), optionally suffixed // with :line or :line:col. Like desktop, we propose candidates and let the host // existence-check reject non-files — agents often print a bare filename, so -// requiring a slash would miss the common case. The desktop's spaced-path -// variants are intentionally not ported: a tap always lands inside one -// whitespace-bounded segment, so this already covers the real cases. +// requiring a slash would miss the common case. const LOCAL_PATH_REGEX = /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/]|(?=[A-Za-z0-9._-]*\.[A-Za-z0-9]))[A-Za-z0-9._~\-/%+@\\()[\]]*(?::\d+)?(?::\d+)?/g +const SPACED_PATH_REGEX = + /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/])[^()[\]{}'",;<>|`\r\n]+(?::\d+)?(?::\d+)?/g const LEADING_TRIM_CHARS = new Set(['(', '[', '{', '"', "'"]) const TRAILING_TRIM_CHARS = new Set([')', ']', '}', '"', "'", ',', ';', '.']) @@ -64,9 +64,116 @@ export function parsePathWithOptionalLineColumn(value: string): TappedFilePath | return { pathText, line, column } } +function hasSeparatorAfterWhitespace(text: string): boolean { + let sawWhitespace = false + for (const char of text) { + if (/\s/.test(char)) { + sawWhitespace = true + continue + } + if (sawWhitespace && (char === '/' || char === '\\')) { + return true + } + } + return false +} + +function trimSpacedPathTrailingProse( + range: Span & { text: string }, + col?: number +): (Span & { text: string }) | null { + // Why: a line-end extension token only extends the span when the added + // segment is path-like (contains a separator) — "v1.2 reports/result.json" + // extends, prose like "failed to start app.py" must not be swallowed. + let selected: string | null = null + const extensionPrefixPattern = /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?(?=\s+|$)/g + let match: RegExpExecArray | null + while ((match = extensionPrefixPattern.exec(range.text)) !== null) { + const end = match.index + match[0].length + const text = range.text.slice(0, end) + if (countPathStarts(text) > 1) { + continue + } + if ( + end < range.text.length || + selected === null || + /[\\/]/.test(range.text.slice(selected.length, end)) + ) { + selected = text + } + } + if (!selected) { + const text = range.text.trimEnd() + return { + ...range, + text, + endIndex: range.startIndex + text.length + } + } + if (col !== undefined && col >= range.startIndex + selected.length) { + return null + } + return { + text: selected, + startIndex: range.startIndex, + endIndex: range.startIndex + selected.length + } +} + +function countPathStarts(text: string): number { + let count = 0 + for (const match of text.matchAll(/(?:^|\s)(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/])/g)) { + void match + count += 1 + } + return count +} + +function hasSpacedPathExtension(text: string): boolean { + const trimmed = trimSpacedPathTrailingProse({ + text, + startIndex: 0, + endIndex: text.length + })?.text.trimEnd() + if (!trimmed) { + return false + } + return /\s/.test(trimmed) && /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?$/.test(trimmed) +} + +function matchSpacedFilePathAtColumn(lineText: string, col: number): TappedFilePath | null { + SPACED_PATH_REGEX.lastIndex = 0 + let match: RegExpExecArray | null + while ((match = SPACED_PATH_REGEX.exec(lineText)) !== null) { + const trimmed = trimBoundaryPunctuation(match[0], match.index) + if ( + !trimmed || + (!hasSeparatorAfterWhitespace(trimmed.text) && !hasSpacedPathExtension(trimmed.text)) + ) { + continue + } + const candidate = trimSpacedPathTrailingProse(trimmed, col) + if (!candidate) { + continue + } + if (col < candidate.startIndex || col >= candidate.endIndex) { + continue + } + const parsed = parsePathWithOptionalLineColumn(candidate.text) + if (parsed) { + return parsed + } + } + return null +} + // Returns the file-path span (after punctuation trim) that contains `col`, or // null when the tap isn't on a path. export function matchFilePathAtColumn(lineText: string, col: number): TappedFilePath | null { + const spaced = matchSpacedFilePathAtColumn(lineText, col) + if (spaced) { + return spaced + } LOCAL_PATH_REGEX.lastIndex = 0 let match: RegExpExecArray | null while ((match = LOCAL_PATH_REGEX.exec(lineText)) !== null) { @@ -78,8 +185,7 @@ export function matchFilePathAtColumn(lineText: string, col: number): TappedFile if (!trimmed) { continue } - // Inclusive of the trailing edge so a tap on the last glyph still counts. - if (col < trimmed.startIndex || col > trimmed.endIndex) { + if (col < trimmed.startIndex || col >= trimmed.endIndex) { continue } const parsed = parsePathWithOptionalLineColumn(trimmed.text) diff --git a/mobile/src/terminal/terminal-send-rpc-response.test.ts b/mobile/src/terminal/terminal-send-rpc-response.test.ts new file mode 100644 index 00000000000..5ef459a6fde --- /dev/null +++ b/mobile/src/terminal/terminal-send-rpc-response.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import type { RpcResponse } from '../transport/types' +import { isTerminalSendRpcAccepted } from './terminal-send-rpc-response' + +const runtimeMeta = { runtimeId: 'test-runtime' } as const + +describe('terminal send RPC response', () => { + it('Given accepted terminal send response When checked Then reports success', () => { + // Given + const response: RpcResponse = { + id: '1', + ok: true, + result: { send: { handle: 'terminal-1', accepted: true, bytesWritten: 1 } }, + _meta: runtimeMeta + } + + // When / Then + expect(isTerminalSendRpcAccepted(response)).toBe(true) + }) + + it('Given rejected terminal send response When checked Then reports failure', () => { + // Given + const response: RpcResponse = { + id: '1', + ok: true, + result: { send: { handle: 'terminal-1', accepted: false, bytesWritten: 0 } }, + _meta: runtimeMeta + } + + // When / Then + expect(isTerminalSendRpcAccepted(response)).toBe(false) + }) + + it('Given RPC failure or malformed terminal send response When checked Then reports failure', () => { + // Given + const rpcFailure: RpcResponse = { + id: '1', + ok: false, + error: { code: 'terminal_error', message: 'failed' }, + _meta: runtimeMeta + } + const malformedSuccess: RpcResponse = { + id: '2', + ok: true, + result: {}, + _meta: runtimeMeta + } + + // When / Then + expect(isTerminalSendRpcAccepted(rpcFailure)).toBe(false) + expect(isTerminalSendRpcAccepted(malformedSuccess)).toBe(false) + }) +}) diff --git a/mobile/src/terminal/terminal-send-rpc-response.ts b/mobile/src/terminal/terminal-send-rpc-response.ts new file mode 100644 index 00000000000..454c5e32972 --- /dev/null +++ b/mobile/src/terminal/terminal-send-rpc-response.ts @@ -0,0 +1,15 @@ +import type { RpcResponse } from '../transport/types' + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +export function isTerminalSendRpcAccepted(response: RpcResponse): boolean { + if (!response.ok) { + return false + } + if (!isRecord(response.result) || !isRecord(response.result.send)) { + return false + } + return response.result.send.accepted === true +} diff --git a/mobile/src/terminal/terminal-webview-contract.ts b/mobile/src/terminal/terminal-webview-contract.ts new file mode 100644 index 00000000000..aff86cecad2 --- /dev/null +++ b/mobile/src/terminal/terminal-webview-contract.ts @@ -0,0 +1,64 @@ +import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types' +import type { TerminalOscLinkRange } from './terminal-osc-link-ranges' + +type TerminalMouseTrackingMode = 'none' | 'x10' | 'vt200' | 'drag' | 'any' + +export type TerminalModes = { + bracketedPasteMode: boolean + altScreen: boolean + mouseTrackingMode: TerminalMouseTrackingMode + sgrMouseMode: boolean + sgrMousePixelsMode: boolean +} + +export type TerminalKeyboardAvoidanceMetrics = { + cursorY: number + rows: number + altScreen: boolean +} + +export type MobileTerminalTheme = RuntimeMobileTerminalTheme + +export type TerminalSelectionEvents = { + onSelectionMode?: (active: boolean) => void + onSelectionCopy?: (text: string) => void + onSelectionEvicted?: () => void + onModesChanged?: (modes: TerminalModes) => void + onKeyboardAvoidanceMetrics?: (metrics: TerminalKeyboardAvoidanceMetrics) => void + onHaptic?: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void + onTerminalInput?: (bytes: string) => void + onTerminalTap?: () => void + // Tap landed on a detected file path; RN resolves + opens it. + onFileTap?: (pathText: string, line: number | null, column: number | null) => void + // WebView-detected URL tap; RN chooses the mobile routing destination. + onOpenUrl?: (url: string) => void + // Why: pinch-to-zoom in the terminal snaps to a text-size preset and reports it + // here so the app persists it and keeps Settings + other panes in sync. + onTextScaleChange?: (scale: number) => void +} + +export type TerminalWebViewHandle = { + write: (data: string) => void + init: ( + cols: number, + rows: number, + initialData?: string, + preserveScroll?: boolean, + oscLinks?: TerminalOscLinkRange[] + ) => void + resize: (cols: number, rows: number) => void + // Why: reflow the local xterm buffer (scrollback included) to a new width + // after a server-side PTY reflow, so older wrapped lines rewrap to match the + // latest output. No-op on the alternate screen. + reflow: (cols: number, rows: number) => void + clear: () => void + measureFitDimensions: (containerHeight?: number) => Promise<{ cols: number; rows: number } | null> + resetZoom: () => void + cancelSelect: () => void + doSelectAll: () => void + // Why: lets callers await the WebView-side `init` rAF chain (term.open + // → renderService population → first paint) so a follow-up measure + // doesn't race ahead and find term=null or cellWidth=0. Resolves on + // the next 'ready' notify after the most recent init. + awaitReady: () => Promise +} diff --git a/mobile/src/terminal/terminal-webview-engine-error-state.tsx b/mobile/src/terminal/terminal-webview-engine-error-state.tsx new file mode 100644 index 00000000000..0ba3e79db8d --- /dev/null +++ b/mobile/src/terminal/terminal-webview-engine-error-state.tsx @@ -0,0 +1,130 @@ +import { useCallback, useState } from 'react' +import { RefreshCw } from 'lucide-react-native' +import { Pressable, StyleSheet, Text, View } from 'react-native' +import { colors } from '../theme/mobile-theme' + +export type NativeWebViewEngineEvent = { + readonly nativeEvent?: object +} + +type TerminalWebViewEngineErrorOverlayProps = { + readonly message: string + readonly onReload: () => void +} + +type NativeWebViewEngineFields = { + readonly description?: unknown + readonly code?: unknown + readonly statusCode?: unknown + readonly domain?: unknown + readonly didCrash?: unknown +} + +export function useTerminalWebViewEngineErrorState(onEngineError?: (message: string) => void) { + const [engineError, setEngineError] = useState(null) + const clearEngineError = useCallback(() => setEngineError(null), []) + const reportEngineError = useCallback( + (message: string, fatal: boolean) => { + onEngineError?.(message) + // eslint-disable-next-line no-console + console.warn('[terminal-webview] engine error', message) + if (fatal) { + // Why: the first fatal report is the root cause; later cascades (e.g. the + // web-ready watchdog firing after a process-crash report) must not + // overwrite its more specific diagnostics. clearEngineError resets. + setEngineError((previous) => previous ?? message) + } + }, + [onEngineError] + ) + const reportNativeEngineError = useCallback( + (context: string, event?: NativeWebViewEngineEvent) => { + reportEngineError(describeNativeWebViewEngineError(context, event), true) + }, + [reportEngineError] + ) + return { clearEngineError, engineError, reportEngineError, reportNativeEngineError } +} + +export function describeNativeWebViewEngineError( + context: string, + event?: NativeWebViewEngineEvent +): string { + const native = event?.nativeEvent as NativeWebViewEngineFields | undefined + const parts = [context] + const description = native?.description + const statusCode = native?.statusCode + const code = native?.code + const domain = native?.domain + if (typeof description === 'string') { + parts.push(description) + } + if (typeof statusCode === 'number') { + parts.push(`status ${statusCode}`) + } + if (typeof code === 'number') { + parts.push(`code ${code}`) + } + if (typeof domain === 'string') { + parts.push(domain) + } + if (native?.didCrash === true) { + parts.push('renderer crashed') + } + return parts.join(' - ') +} + +export function TerminalWebViewEngineErrorOverlay({ + message, + onReload +}: TerminalWebViewEngineErrorOverlayProps) { + return ( + + Terminal failed to load + + {message} + + + + Reload + + + ) +} + +const styles = StyleSheet.create({ + errorOverlay: { + ...StyleSheet.absoluteFillObject, + alignItems: 'center', + justifyContent: 'center', + gap: 12, + padding: 24, + backgroundColor: colors.terminalBg + }, + errorTitle: { + color: colors.textPrimary, + fontSize: 16, + fontWeight: '700', + textAlign: 'center' + }, + errorDetail: { + color: colors.textSecondary, + fontSize: 13, + lineHeight: 18, + textAlign: 'center' + }, + reloadButton: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + minHeight: 36, + paddingHorizontal: 14, + borderRadius: 6, + backgroundColor: colors.surfaceBright + }, + reloadButtonText: { + color: colors.terminalBg, + fontSize: 14, + fontWeight: '700' + } +}) diff --git a/mobile/src/terminal/terminal-webview-engine-error.test.ts b/mobile/src/terminal/terminal-webview-engine-error.test.ts new file mode 100644 index 00000000000..82267967b7c --- /dev/null +++ b/mobile/src/terminal/terminal-webview-engine-error.test.ts @@ -0,0 +1,178 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { TerminalWebView } from './TerminalWebView' + +vi.mock('react-native', () => ({ + AppState: { currentState: 'active' }, + Pressable: 'Pressable', + StyleSheet: { + absoluteFillObject: { + bottom: 0, + left: 0, + position: 'absolute', + right: 0, + top: 0 + }, + create: (styles: unknown) => styles + }, + Text: 'Text', + View: 'View' +})) + +vi.mock('react-native-webview', () => ({ + WebView: 'WebView', + default: 'WebView' +})) + +vi.mock('lucide-react-native', () => ({ + RefreshCw: 'RefreshCw' +})) + +function suppressReactTestRendererDeprecationWarning(): () => void { + const originalConsoleError = console.error + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + const firstArg = args[0] + if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) { + return + } + originalConsoleError(...args) + }) + return () => consoleErrorSpy.mockRestore() +} + +// Why: mounting TerminalWebView arms the web-ready watchdog; tests must +// unmount so the timer can't survive into later tests and fire in teardown. +let activeRenderer: ReactTestRenderer | null = null + +function createTerminalWebViewRenderer(onEngineError = vi.fn()) { + let renderer: ReactTestRenderer | null = null + const restoreConsoleError = suppressReactTestRendererDeprecationWarning() + try { + act(() => { + renderer = create(createElement(TerminalWebView, { onEngineError })) + }) + } finally { + restoreConsoleError() + } + if (!renderer) { + throw new Error('TerminalWebView did not render') + } + activeRenderer = renderer + return { onEngineError, renderer } +} + +function postWebViewMessage(renderer: ReactTestRenderer, payload: Record) { + const webView = renderer.root.findByType('WebView') + act(() => { + webView.props.onMessage({ nativeEvent: { data: JSON.stringify(payload) } }) + }) +} + +function renderedText(renderer: ReactTestRenderer): string { + return renderer.root + .findAllByType('Text') + .flatMap((node) => node.props.children) + .join(' ') +} + +describe('TerminalWebView engine errors', () => { + afterEach(() => { + if (activeRenderer) { + act(() => { + activeRenderer?.unmount() + }) + activeRenderer = null + } + vi.restoreAllMocks() + }) + + it('renders the reload overlay for fatal engine errors from the WebView', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { onEngineError, renderer } = createTerminalWebViewRenderer() + + postWebViewMessage(renderer, { + fatal: true, + message: 'terminal engine missing - SyntaxError - Chrome 74', + type: 'error' + }) + + expect(onEngineError).toHaveBeenCalledWith('terminal engine missing - SyntaxError - Chrome 74') + expect(renderedText(renderer)).toContain('Terminal failed to load') + expect(renderedText(renderer)).toContain('terminal engine missing - SyntaxError - Chrome 74') + expect(renderedText(renderer)).toContain('Reload') + }) + + it('reports non-fatal engine errors without covering a live terminal', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { onEngineError, renderer } = createTerminalWebViewRenderer() + + postWebViewMessage(renderer, { + fatal: false, + message: 'terminal message failed - malformed chunk', + type: 'error' + }) + + expect(onEngineError).toHaveBeenCalledWith('terminal message failed - malformed chunk') + expect(renderedText(renderer)).not.toContain('Terminal failed to load') + }) + + it('keeps the first fatal diagnostics when later fatal reports cascade', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { renderer } = createTerminalWebViewRenderer() + + postWebViewMessage(renderer, { + fatal: true, + message: 'terminal engine missing - root cause', + type: 'error' + }) + postWebViewMessage(renderer, { + fatal: true, + message: 'Terminal did not initialize - watchdog cascade', + type: 'error' + }) + + expect(renderedText(renderer)).toContain('terminal engine missing - root cause') + expect(renderedText(renderer)).not.toContain('watchdog cascade') + }) + + // Why: if the document dies before the glue can post anything (or the RN + // bridge never comes up), no message and no native error handler fires — + // the watchdog must convert that silence into the visible fatal overlay. + it('paints the fatal overlay when web-ready never arrives', () => { + vi.useFakeTimers() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const { onEngineError, renderer } = createTerminalWebViewRenderer() + + act(() => { + vi.advanceTimersByTime(15000) + }) + + expect(onEngineError).toHaveBeenCalledWith( + 'Terminal did not initialize - no ready signal from the terminal view' + ) + expect(renderedText(renderer)).toContain('Terminal failed to load') + } finally { + vi.useRealTimers() + } + }) + + it('does not fire the watchdog once web-ready has arrived', () => { + vi.useFakeTimers() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const { onEngineError, renderer } = createTerminalWebViewRenderer() + + postWebViewMessage(renderer, { type: 'web-ready' }) + act(() => { + vi.advanceTimersByTime(60000) + }) + + expect(onEngineError).not.toHaveBeenCalled() + expect(renderedText(renderer)).not.toContain('Terminal failed to load') + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/mobile/src/terminal/terminal-webview-engine.test.ts b/mobile/src/terminal/terminal-webview-engine.test.ts new file mode 100644 index 00000000000..36c8c039493 --- /dev/null +++ b/mobile/src/terminal/terminal-webview-engine.test.ts @@ -0,0 +1,109 @@ +import { readFileSync } from 'node:fs' +import { Script } from 'node:vm' +import { parse } from 'acorn' +import { describe, expect, it } from 'vitest' +import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.generated' +import { XTERM_HTML } from './terminal-webview-html' + +const terminalHtmlSource = readFileSync( + new URL('./terminal-webview-html.ts', import.meta.url), + 'utf8' +) + +describe('terminal WebView bundled engine', () => { + it('keeps the assembled terminal HTML free of external engine URLs', () => { + expect(XTERM_HTML).not.toMatch(/\bhttps?:\/\//) + expect(XTERM_HTML).not.toContain('cdn.jsdelivr.net') + expect(XTERM_HTML).not.toContain(' { + const start = terminalHtmlSource.indexOf('function handleIncomingMessage') + const end = terminalHtmlSource.indexOf("window.addEventListener('resize'", start) + expect(start).toBeGreaterThanOrEqual(0) + expect(end).toBeGreaterThan(start) + const handlerSource = terminalHtmlSource.slice(start, end) + + expect(handlerSource).toContain('reportEngineError(') + expect(handlerSource).toContain("'terminal init failed'") + expect(handlerSource).toContain("'terminal message failed'") + expect(handlerSource).not.toContain('catch(ex) {}') + }) + + it('classifies runtime errors by a document-scoped ever-ready latch', () => { + // Why: init() flips `ready` false on every re-init (live width reflow keeps the + // old surface visible meanwhile), so the fatal default and the init-catch must + // key off `everReady` — otherwise a transient reflow error blanks a live + // terminal behind the fatal overlay. The latch stays set for the document. + expect(terminalHtmlSource).toContain('var everReady = false;') + expect(terminalHtmlSource).toContain('everReady = true;') + expect(terminalHtmlSource).toContain('fatal === undefined ? !everReady : !!fatal') + expect(terminalHtmlSource).toContain("msg.type === 'init' && !everReady") + expect(terminalHtmlSource).not.toMatch(/fatal === undefined \? !ready\b/) + }) + + it('bounds error capture and non-fatal reporting on a degraded engine', () => { + // Why: a constructed-but-broken engine can throw per render frame; both + // onerror capture sites must cap the buffer and non-fatal notifies must + // stop flooding RN while fatal reports always emit. + const capSites = terminalHtmlSource.match(/__engineErrors\.length < 20/g) ?? [] + expect(capSites.length).toBe(2) + expect(terminalHtmlSource).toContain('nonFatalErrorNotifies > 5') + }) +}) diff --git a/mobile/src/terminal/terminal-webview-frame-styles.ts b/mobile/src/terminal/terminal-webview-frame-styles.ts new file mode 100644 index 00000000000..907a1f3326d --- /dev/null +++ b/mobile/src/terminal/terminal-webview-frame-styles.ts @@ -0,0 +1,13 @@ +import { StyleSheet } from 'react-native' +import { colors } from '../theme/mobile-theme' + +export const TERMINAL_WEBVIEW_FRAME_STYLES = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.terminalBg + }, + webview: { + flex: 1, + backgroundColor: colors.terminalBg + } +}) diff --git a/mobile/src/terminal/terminal-webview-html.ts b/mobile/src/terminal/terminal-webview-html.ts index a1672f3f91a..63e4d2b9ed7 100644 --- a/mobile/src/terminal/terminal-webview-html.ts +++ b/mobile/src/terminal/terminal-webview-html.ts @@ -4,6 +4,7 @@ import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-typ import { colors } from '../theme/mobile-theme' import { TERMINAL_TEXT_SCALES } from '../storage/preferences' import { TERMINAL_PATH_TAP_JS } from './terminal-path-tap-injected' +import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.generated' import { TERMINAL_REFLOW_JS } from './terminal-webview-reflow-injected' import { TERMINAL_TAP_DISPATCH_JS } from './terminal-webview-tap-dispatch-injected' import { URL_TAP_WEBVIEW_JS } from './terminal-webview-url-tap' @@ -47,7 +48,15 @@ export const XTERM_HTML = ` - + + +

- + {sprite ? ( buildAiVaultProjectContext({ @@ -121,6 +130,7 @@ export default function AiVaultPanel(): React.JSX.Element { ) const sessionWorktreeById = useAiVaultSessionWorktreeMap({ sessions, + repos, worktrees: allWorktrees, activeWorktreeId: activeWorktreeId ?? activeWorktree?.id ?? null }) @@ -210,6 +220,8 @@ export default function AiVaultPanel(): React.JSX.Element { const getSessionResumeState = useCallback( (session: AiVaultSession) => resolveAiVaultSessionResumeState({ + sessionFilePath: session.filePath, + sessionExecutionHostId: session.executionHostId, worktreeInfo: sessionWorktreeById.get(session.id) ?? null, activeWorktreeId: activeWorktreeId ?? activeWorktree?.id ?? null, worktrees: allWorktrees, @@ -229,6 +241,8 @@ export default function AiVaultPanel(): React.JSX.Element { const getSessionResumeActions = useCallback( (session: AiVaultSession) => resolveAiVaultSessionResumeActions({ + sessionFilePath: session.filePath, + sessionExecutionHostId: session.executionHostId, worktreeInfo: sessionWorktreeById.get(session.id) ?? null, activeWorktreeId: activeWorktreeId ?? activeWorktree?.id ?? null, worktrees: allWorktrees, @@ -291,6 +305,8 @@ export default function AiVaultPanel(): React.JSX.Element { activeWorktreePath={activeWorktreePath} activeProjectKey={activeProjectKey} scope={scope} + executionHostScope={executionHostScope} + activeSshExecutionHostScope={activeSshExecutionHostScope} agents={agents} sort={sort} group={group} @@ -298,6 +314,7 @@ export default function AiVaultPanel(): React.JSX.Element { adjustmentCount={viewAdjustmentCount} onQueryChange={setQuery} onScopeChange={handleScopeChange} + onExecutionHostScopeChange={onExecutionHostScopeChange} onAgentEnabledChange={setAgentEnabled} onSortChange={setSort} onGroupChange={setGroup} @@ -338,6 +355,7 @@ export default function AiVaultPanel(): React.JSX.Element { sessionsCount={sessions.length} filteredSessionsCount={filteredSessions.length} error={error} + vaultScope={scope} buildResumeStartup={buildResumeStartup} getSessionResumeState={getSessionResumeState} getSessionResumeActions={getSessionResumeActions} diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx index 227e96ccca9..2b3cc2802b0 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx @@ -7,7 +7,8 @@ import { FolderOpen, ListFilter, LoaderCircle, - PanelsTopLeft + PanelsTopLeft, + Server } from 'lucide-react' import { Button } from '@/components/ui/button' import { @@ -31,6 +32,12 @@ import { type AiVaultScope, type AiVaultSort } from '../../../../shared/ai-vault-types' +import { + ALL_EXECUTION_HOSTS_SCOPE, + getExecutionHostLabel, + LOCAL_EXECUTION_HOST_ID, + type ExecutionHostScope +} from '../../../../shared/execution-host' import { agentLabel, type AiVaultSessionGroup } from './ai-vault-session-filters' import { translate } from '@/i18n/i18n' @@ -175,6 +182,59 @@ export function VaultScopeSwitch({ ) } +export function VaultHostScopeMenu({ + executionHostScope, + activeSshExecutionHostScope, + onExecutionHostScopeChange +}: { + executionHostScope: ExecutionHostScope + activeSshExecutionHostScope: ExecutionHostScope | null + onExecutionHostScopeChange: (scope: ExecutionHostScope) => void +}): React.JSX.Element { + const hostOptions = [ + LOCAL_EXECUTION_HOST_ID, + ...(activeSshExecutionHostScope ? [activeSshExecutionHostScope] : []), + ALL_EXECUTION_HOSTS_SCOPE + ] as const + const label = getExecutionHostLabel(executionHostScope) + + return ( + + + + + + + {translate('auto.components.right.sidebar.AiVaultPanelControls.host', 'Host')} + + onExecutionHostScopeChange(value as ExecutionHostScope)} + > + {hostOptions.map((scope) => ( + + {getExecutionHostLabel(scope)} + + ))} + + + + ) +} + export function VaultViewMenu({ agents, sort, diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx index 6f40190617e..b463e188a7e 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx @@ -7,7 +7,8 @@ import type { AiVaultScope, AiVaultSort } from '../../../../shared/ai-vault-types' -import { VaultScopeSwitch, VaultViewMenu } from './AiVaultPanelControls' +import type { ExecutionHostScope } from '../../../../shared/execution-host' +import { VaultHostScopeMenu, VaultScopeSwitch, VaultViewMenu } from './AiVaultPanelControls' type AiVaultPanelHeaderProps = { query: string @@ -18,6 +19,8 @@ type AiVaultPanelHeaderProps = { activeWorktreePath: string | null activeProjectKey: string | null scope: AiVaultScope + executionHostScope: ExecutionHostScope + activeSshExecutionHostScope: ExecutionHostScope | null agents: readonly AiVaultAgent[] sort: AiVaultSort group: AiVaultGroup @@ -25,6 +28,7 @@ type AiVaultPanelHeaderProps = { adjustmentCount: number onQueryChange: (query: string) => void onScopeChange: (scope: AiVaultScope) => void + onExecutionHostScopeChange: (scope: ExecutionHostScope) => void onAgentEnabledChange: (agent: AiVaultAgent, enabled: boolean) => void onSortChange: (sort: AiVaultSort) => void onGroupChange: (group: AiVaultGroup) => void @@ -42,6 +46,8 @@ export function AiVaultPanelHeader({ activeWorktreePath, activeProjectKey, scope, + executionHostScope, + activeSshExecutionHostScope, agents, sort, group, @@ -49,6 +55,7 @@ export function AiVaultPanelHeader({ adjustmentCount, onQueryChange, onScopeChange, + onExecutionHostScopeChange, onAgentEnabledChange, onSortChange, onGroupChange, @@ -99,6 +106,11 @@ export function AiVaultPanelHeader({
+ void + onJumpToOriginalPane?: () => void + showJumpToWorktree: boolean + onJumpToWorktree?: () => void + onCopyResume: () => void + onCopyId: () => void + onCopyPath: () => void + onOpenLog?: () => void + onRevealLog?: () => void + onOpenCwd?: () => void +}) { + const Item = menuKind === 'context' ? ContextMenuItem : DropdownMenuItem + const Separator = menuKind === 'context' ? ContextMenuSeparator : DropdownMenuSeparator + const hasLocalPathActions = Boolean(onOpenLog || onRevealLog || onOpenCwd) + + return ( + <> + {onJumpToOriginalPane ? ( + + + {translate( + 'auto.components.right.sidebar.AiVaultSessionRow.jumpToOriginalPane', + 'Jump to Original Pane' + )} + + ) : null} + {showJumpToWorktree ? ( + + + {translate( + 'auto.components.right.sidebar.AiVaultSessionRow.jumpToWorktree', + 'Jump to Worktree' + )} + + ) : null} + + + {resumeLabel} + + + + {translate( + 'auto.components.right.sidebar.AiVaultSessionRow.copyResumeCommand', + 'Copy Resume Command' + )} + + {hasLocalPathActions ? ( + <> + + {onOpenLog ? ( + + + {translate('auto.components.right.sidebar.AiVaultSessionRow.openLog', 'Open Log')} + + ) : null} + {onRevealLog ? ( + + + {translate('auto.components.right.sidebar.AiVaultSessionRow.revealLog', 'Reveal Log')} + + ) : null} + {onOpenCwd ? ( + + + {translate( + 'auto.components.right.sidebar.AiVaultSessionRow.openWorkingDirectory', + 'Open Working Directory' + )} + + ) : null} + + ) : null} + + + {translate( + 'auto.components.right.sidebar.AiVaultSessionRow.copySessionId', + 'Copy Session ID' + )} + + + {translate('auto.components.right.sidebar.AiVaultSessionRow.copyLogPath', 'Copy Log Path')} + + + ) +} diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx index 58923a53479..85f5b2a5759 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx @@ -3,13 +3,14 @@ import { FileJson, FolderGit2, MessageSquare, Play } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' -import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import type { AiVaultScope, AiVaultSession } from '../../../../shared/ai-vault-types' import { translate } from '@/i18n/i18n' import { sessionDetailConversationTurns } from './ai-vault-session-display' import { aiVaultWorktreeCompactPath, aiVaultWorktreeStatusLabel, shouldShowAiVaultWorktreeStatusBadge, + shouldShowAiVaultSessionWorktreeLine, type AiVaultSessionWorktreeInfo } from './ai-vault-session-worktree' @@ -17,6 +18,7 @@ export function SessionInlineDetails({ id, session, worktreeInfo, + vaultScope, resumeActions, onResumeInWorktree, onResumeInNewTab, @@ -25,13 +27,14 @@ export function SessionInlineDetails({ id: string session: AiVaultSession worktreeInfo: AiVaultSessionWorktreeInfo | null + vaultScope: AiVaultScope resumeActions: { worktree: { worktreeId: string | null; disabled: boolean } newTab: { worktreeId: string | null; disabled: boolean } } onResumeInWorktree: () => void onResumeInNewTab: () => void - onOpenLog: () => void + onOpenLog?: () => void }): React.JSX.Element { const showResumeInWorktree = Boolean(resumeActions.worktree.worktreeId) const showResumeInNewTab = !showResumeInWorktree || Boolean(resumeActions.newTab.worktreeId) @@ -78,7 +81,7 @@ export function SessionInlineDetails({ )} - {worktreeDisplay ? ( + {shouldShowAiVaultSessionWorktreeLine(worktreeDisplay, { vaultScope }) ? ( } label={translate( @@ -86,7 +89,7 @@ export function SessionInlineDetails({ 'Worktree' )} > - + ) : null}
@@ -132,20 +135,22 @@ export function SessionInlineDetails({ )} ) : null} - + {onOpenLog ? ( + + ) : null} ) @@ -200,9 +205,11 @@ function ConversationTurnCard({ } function WorktreeMetadataLines({ - worktreeInfo + worktreeInfo, + vaultScope }: { worktreeInfo: AiVaultSessionWorktreeInfo + vaultScope: AiVaultScope }): React.JSX.Element { const compactPath = aiVaultWorktreeCompactPath(worktreeInfo.path) const pathLine = @@ -212,7 +219,7 @@ function WorktreeMetadataLines({ return (
- {shouldShowAiVaultWorktreeStatusBadge(worktreeInfo.status) ? ( + {shouldShowAiVaultWorktreeStatusBadge(worktreeInfo.status, { vaultScope }) ? ( <> {aiVaultWorktreeStatusLabel(worktreeInfo.status)} diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx index fe2e07bc338..b0d88016327 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx @@ -1,44 +1,36 @@ import { useCallback } from 'react' import type React from 'react' -import { Copy, FileJson, FolderOpen, LocateFixed, PanelTopOpen, Play } from 'lucide-react' -import { DropdownMenuItem, DropdownMenuSeparator } from '@/components/ui/dropdown-menu' -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuTrigger -} from '@/components/ui/context-menu' -import { Badge } from '@/components/ui/badge' -import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' -import { AgentIcon } from '@/lib/agent-catalog' +import { ContextMenu, ContextMenuContent, ContextMenuTrigger } from '@/components/ui/context-menu' import { cn } from '@/lib/utils' -import { useRepoById } from '@/store/selectors' -import { resolveRepoBadgeColor } from '../../../../shared/repo-badge-color' -import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree-id' import { AI_VAULT_SESSION_DRAG_END_EVENT, AI_VAULT_SESSION_DRAG_START_EVENT, writeAiVaultSessionDragData } from '@/lib/ai-vault-session-drag' -import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import type { AiVaultScope, AiVaultSession } from '../../../../shared/ai-vault-types' import type { AiVaultResumeStartup } from '@/lib/ai-vault-resume-command' -import { agentLabel } from './ai-vault-session-filters' import { translate } from '@/i18n/i18n' -import { SessionInlineDetails, SessionTime } from './AiVaultSessionDetails' +import { SessionInlineDetails } from './AiVaultSessionDetails' import { latestSessionConversationTurn } from './ai-vault-session-display' +import { SessionActionMenuItems } from './AiVaultSessionActionMenuItems' import { SessionRowTrailingActions } from './SessionRowTrailingActions' import type { AiVaultSessionResumeActions } from './ai-vault-session-resume' import { - aiVaultWorktreeStatusLabel, - shouldShowAiVaultWorktreeStatusBadge, + shouldShowAiVaultSessionWorktreeLine, type AiVaultSessionWorktreeInfo } from './ai-vault-session-worktree' +import { + conversationRoleLabel, + getSessionDetailsId, + SessionMetadata, + SessionWorktreeLine +} from './ai-vault-session-row-display' export function VaultSessionRow({ session, resumeStartup, worktreeInfo, + vaultScope, detailsExpanded, resumeDisabled, onToggleDetails, @@ -60,6 +52,7 @@ export function VaultSessionRow({ session: AiVaultSession resumeStartup: AiVaultResumeStartup worktreeInfo: AiVaultSessionWorktreeInfo | null + vaultScope: AiVaultScope detailsExpanded: boolean resumeDisabled: boolean onToggleDetails: () => void @@ -74,8 +67,8 @@ export function VaultSessionRow({ onCopyResume: () => void onCopyId: () => void onCopyPath: () => void - onOpenLog: () => void - onRevealLog: () => void + onOpenLog?: () => void + onRevealLog?: () => void onOpenCwd?: () => void }) { const updatedAt = session.updatedAt ?? session.modifiedAt @@ -101,12 +94,14 @@ export function VaultSessionRow({ sessionId: session.sessionId, title: session.title, command: resumeStartup.command, + sessionFilePath: session.filePath, + sessionExecutionHostId: session.executionHostId, ...(resumeStartup.env ? { env: resumeStartup.env } : {}), ...(resumeStartup.launchConfig ? { launchConfig: resumeStartup.launchConfig } : {}) }) window.dispatchEvent(new Event(AI_VAULT_SESSION_DRAG_START_EVENT)) }, - [resumeDisabled, session.agent, session.sessionId, session.title, resumeStartup] + [resumeDisabled, session, resumeStartup] ) return ( @@ -159,9 +154,9 @@ export function VaultSessionRow({ onOpenCwd={onOpenCwd} />
- {detailsExpanded && worktreeInfo ? ( + {detailsExpanded && shouldShowAiVaultSessionWorktreeLine(worktreeInfo, { vaultScope }) ? (
- +
) : null} {!detailsExpanded ? ( @@ -185,6 +180,7 @@ export function VaultSessionRow({ session={session} updatedAt={updatedAt} worktreeInfo={worktreeInfo} + vaultScope={vaultScope} /> ) : null} @@ -193,6 +189,7 @@ export function VaultSessionRow({ id={detailsId} session={session} worktreeInfo={worktreeInfo} + vaultScope={vaultScope} resumeActions={resumeActions} onResumeInWorktree={onResumeInWorktree} onResumeInNewTab={onResumeInNewTab} @@ -221,186 +218,3 @@ export function VaultSessionRow({ ) } - -export function SessionActionMenuItems({ - menuKind = 'dropdown', - resumeDisabled, - resumeLabel, - onResume, - onJumpToOriginalPane, - showJumpToWorktree, - onJumpToWorktree, - onCopyResume, - onCopyId, - onCopyPath, - onOpenLog, - onRevealLog, - onOpenCwd -}: { - menuKind?: 'dropdown' | 'context' - resumeDisabled: boolean - resumeLabel: string - onResume: () => void - onJumpToOriginalPane?: () => void - showJumpToWorktree: boolean - onJumpToWorktree?: () => void - onCopyResume: () => void - onCopyId: () => void - onCopyPath: () => void - onOpenLog: () => void - onRevealLog: () => void - onOpenCwd?: () => void -}) { - const Item = menuKind === 'context' ? ContextMenuItem : DropdownMenuItem - const Separator = menuKind === 'context' ? ContextMenuSeparator : DropdownMenuSeparator - - return ( - <> - {onJumpToOriginalPane ? ( - - - {translate( - 'auto.components.right.sidebar.AiVaultSessionRow.jumpToOriginalPane', - 'Jump to Original Pane' - )} - - ) : null} - {showJumpToWorktree ? ( - - - {translate( - 'auto.components.right.sidebar.AiVaultSessionRow.jumpToWorktree', - 'Jump to Worktree' - )} - - ) : null} - - - {resumeLabel} - - - - {translate( - 'auto.components.right.sidebar.AiVaultSessionRow.copyResumeCommand', - 'Copy Resume Command' - )} - - - - - {translate('auto.components.right.sidebar.AiVaultSessionRow.openLog', 'Open Log')} - - - - {translate('auto.components.right.sidebar.AiVaultSessionRow.revealLog', 'Reveal Log')} - - {onOpenCwd ? ( - - - {translate( - 'auto.components.right.sidebar.AiVaultSessionRow.openWorkingDirectory', - 'Open Working Directory' - )} - - ) : null} - - - {translate( - 'auto.components.right.sidebar.AiVaultSessionRow.copySessionId', - 'Copy Session ID' - )} - - - {translate('auto.components.right.sidebar.AiVaultSessionRow.copyLogPath', 'Copy Log Path')} - - - ) -} - -function getSessionDetailsId(sessionId: string): string { - return `ai-vault-session-details-${sessionId.replace(/[^A-Za-z0-9_-]/g, '-')}` -} - -function SessionMetadata({ - session, - updatedAt, - worktreeInfo -}: { - session: AiVaultSession - updatedAt: string - worktreeInfo: AiVaultSessionWorktreeInfo | null -}) { - return ( -
-
- - - - {agentLabel(session.agent)} - - {translate( - 'auto.components.right.sidebar.AiVaultSessionRow.messageCount', - '{{value0}} msgs', - { value0: session.messageCount } - )} - - · - -
- {worktreeInfo ? : null} -
- ) -} - -function SessionWorktreeLine({ - worktreeInfo -}: { - worktreeInfo: AiVaultSessionWorktreeInfo -}): React.JSX.Element { - const repoId = worktreeInfo.worktreeId - ? (splitWorktreeIdForFilesystem(worktreeInfo.worktreeId)?.repoId ?? null) - : null - const repo = useRepoById(repoId) - - return ( -
- {shouldShowAiVaultWorktreeStatusBadge(worktreeInfo.status) ? ( - - {worktreeStatusLabel(worktreeInfo.status)} - - ) : null} - - - -
- ) -} - -function worktreeStatusLabel(status: AiVaultSessionWorktreeInfo['status']): string { - return aiVaultWorktreeStatusLabel(status) -} - -function conversationRoleLabel(role: AiVaultSession['previewMessages'][number]['role']): string { - if (role === 'user') { - return translate('auto.components.right.sidebar.AiVaultSessionRow.userRole', 'You') - } - if (role === 'assistant') { - return translate('auto.components.right.sidebar.AiVaultSessionRow.agentRole', 'Agent') - } - if (role === 'tool') { - return translate('auto.components.right.sidebar.AiVaultSessionRow.toolRole', 'Tool') - } - if (role === 'system') { - return translate('auto.components.right.sidebar.AiVaultSessionRow.systemRole', 'System') - } - return translate('auto.components.right.sidebar.AiVaultSessionRow.sessionRole', 'Session') -} diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx index e7293f15de7..a8de4d84313 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx @@ -1,6 +1,6 @@ import { useVirtualizer } from '@tanstack/react-virtual' import { useCallback, useMemo, useRef, useState } from 'react' -import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import type { AiVaultScope, AiVaultSession } from '../../../../shared/ai-vault-types' import type { AiVaultResumeStartup } from '@/lib/ai-vault-resume-command' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' @@ -19,6 +19,7 @@ import { isAiVaultSessionInCurrentWorktree, type AiVaultSessionWorktreeInfo } from './ai-vault-session-worktree' +import { canUseLocalAiVaultSessionPathActions } from './ai-vault-session-path-actions' import { extractVaultVirtualRowIndexes, getVaultStickyHeaderIndexes, @@ -40,6 +41,7 @@ export function AiVaultSessionVirtualList({ sessionsCount, filteredSessionsCount, error, + vaultScope, buildResumeStartup, getOriginalPaneTarget, getWorktreeInfo, @@ -62,6 +64,7 @@ export function AiVaultSessionVirtualList({ sessionsCount: number filteredSessionsCount: number error: string | null + vaultScope: AiVaultScope buildResumeStartup: (session: AiVaultSession, worktreeId?: string | null) => AiVaultResumeStartup getOriginalPaneTarget: (session: AiVaultSession) => AiVaultOriginalPaneTarget | null getWorktreeInfo: (session: AiVaultSession) => AiVaultSessionWorktreeInfo | null @@ -184,6 +187,7 @@ export function AiVaultSessionVirtualList({ measureElement={virtualizer.measureElement} collapsedGroups={collapsedGroups} expandedSessionIds={expandedSessionIds} + vaultScope={vaultScope} buildResumeStartup={buildResumeStartup} getOriginalPaneTarget={getOriginalPaneTarget} getWorktreeInfo={getWorktreeInfo} @@ -216,6 +220,7 @@ function AiVaultVirtualRow({ measureElement, collapsedGroups, expandedSessionIds, + vaultScope, buildResumeStartup, getOriginalPaneTarget, getWorktreeInfo, @@ -240,6 +245,7 @@ function AiVaultVirtualRow({ measureElement: (node: Element | null) => void collapsedGroups: ReadonlySet expandedSessionIds: ReadonlySet + vaultScope: AiVaultScope buildResumeStartup: (session: AiVaultSession, worktreeId?: string | null) => AiVaultResumeStartup getOriginalPaneTarget: (session: AiVaultSession) => AiVaultOriginalPaneTarget | null getWorktreeInfo: (session: AiVaultSession) => AiVaultSessionWorktreeInfo | null @@ -265,8 +271,7 @@ function AiVaultVirtualRow({ const originalPaneTarget = row.type === 'session' ? getOriginalPaneTarget(row.session) : null const worktreeInfo = row.type === 'session' ? getWorktreeInfo(row.session) : null // Why: omit the jump affordance when the session already lives in the - // worktree on screen — jumping there is a no-op the "Current worktree" badge - // already conveys. + // worktree on screen — jumping there is a no-op. const showJumpToWorktree = !isAiVaultSessionInCurrentWorktree(worktreeInfo) const worktreeJumpId = showJumpToWorktree && canJumpToAiVaultSessionWorktree(worktreeInfo) @@ -275,6 +280,8 @@ function AiVaultVirtualRow({ const resumeState = row.type === 'session' ? getSessionResumeState(row.session) : null const resumeActions = row.type === 'session' ? getSessionResumeActions(row.session) : null const resumeLabel = resumeState ? aiVaultSessionResumeLabel(resumeState) : '' + const canOpenLocalSessionPaths = + row.type === 'session' && canUseLocalAiVaultSessionPathActions(row.session.executionHostId) return (
onCopyResume(row.session, resumeState?.worktreeId)} onCopyId={() => onCopyId(row.session)} onCopyPath={() => onCopyPath(row.session)} - onOpenLog={() => onOpenLog(row.session)} - onRevealLog={() => onRevealLog(row.session)} - onOpenCwd={row.session.cwd ? () => onOpenCwd(row.session) : undefined} + onOpenLog={canOpenLocalSessionPaths ? () => onOpenLog(row.session) : undefined} + onRevealLog={canOpenLocalSessionPaths ? () => onRevealLog(row.session) : undefined} + onOpenCwd={ + canOpenLocalSessionPaths && row.session.cwd ? () => onOpenCwd(row.session) : undefined + } /> )}
diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index 5ccc1b9f4ec..0682c1b0790 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -49,6 +49,10 @@ import { PRCommentsList, PRTriageStrip } from './checks-panel-content' +import { + clearPRCommentsListSelection, + type PRCommentsListSelectionClearRequest +} from './pr-comments-list-selection' import { ENTRY_REFRESH_GRACE_MS, shouldEntryRefresh } from './checks-entry-refresh' import type { GitLabDiscussionResolveResult, @@ -435,6 +439,9 @@ export default function ChecksPanel(): React.JSX.Element { const [comments, setComments] = useState([]) const [commentsLoading, setCommentsLoading] = useState(false) const commentsRef = useRef([]) + const [commentsSelectionClearRequest, setCommentsSelectionClearRequest] = + useState(null) + const commentsSelectionClearTokenRef = useRef(0) const [emptyRefreshing, setEmptyRefreshing] = useState(false) const [isRefreshing, setIsRefreshing] = useState(false) const refreshInFlightRef = useRef(false) @@ -2789,6 +2796,15 @@ export default function ChecksPanel(): React.JSX.Element { ] ) + const clearSentCommentSelection = useCallback((reviewContextKey: string): void => { + clearPRCommentsListSelection(reviewContextKey) + commentsSelectionClearTokenRef.current += 1 + setCommentsSelectionClearRequest({ + contextKey: reviewContextKey, + token: commentsSelectionClearTokenRef.current + }) + }, []) + const refreshCommentsAfterBulkResolve = useCallback( async (provider: ChecksPanelReview['provider']): Promise => { if (provider === 'gitlab') { @@ -2802,9 +2818,14 @@ export default function ChecksPanel(): React.JSX.Element { const resolveSelectedThreadsAfterLaunch = useCallback( async (resolution: NonNullable) => { + clearSentCommentSelection(resolution.reviewContextKey) let resolved = 0 - let skipped = 0 + let skipped = Math.max( + 0, + resolution.selectedGroups.length - resolution.selectedThreadIds.length + ) let failed = 0 + let attemptedThreadCount = 0 if (resolution.selectedThreadIds.length === 0) { toast.success( translate( @@ -2816,9 +2837,10 @@ export default function ChecksPanel(): React.JSX.Element { } for (const threadId of resolution.selectedThreadIds) { if (asyncResultKeyRef.current !== resolution.reviewContextKey) { - skipped += resolution.selectedThreadIds.length - resolved - skipped - failed + skipped += resolution.selectedThreadIds.length - attemptedThreadCount break } + attemptedThreadCount += 1 const currentGroup = groupPRComments(commentsRef.current).find( (group) => group.kind === 'thread' && group.threadId === threadId ) @@ -2856,7 +2878,7 @@ export default function ChecksPanel(): React.JSX.Element { ) ) }, - [handleResolve, refreshCommentsAfterBulkResolve] + [clearSentCommentSelection, handleResolve, refreshCommentsAfterBulkResolve] ) const handleFixChecksWithAI = useCallback(async (): Promise => { @@ -3780,6 +3802,7 @@ export default function ChecksPanel(): React.JSX.Element { commentsDisabled={!canTargetPRComments} commentsDisabledReason={commentsDisabledReason} selectionContextKey={stateRequestKey} + selectionClearRequest={commentsSelectionClearRequest} resolveCommentsWithAIDisabled={Boolean(resolveCommentsWithAIDisabledReason)} resolveCommentsWithAIDisabledReason={resolveCommentsWithAIDisabledReason} onAddComment={pr ? handleAddPRComment : undefined} diff --git a/src/renderer/src/components/right-sidebar/FileExplorer.test.tsx b/src/renderer/src/components/right-sidebar/FileExplorer.test.tsx index 1722a4ed815..0b0dee7079a 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorer.test.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorer.test.tsx @@ -18,6 +18,7 @@ import { shouldShowCollapseFolderAction, shouldShowFindInFolderAction, shouldShowCopyFileAction, + shouldShowOpenInTerminalAction, shouldShowRemoteDownloadAction } from './FileExplorerRow' import { FileExplorerVirtualRows } from './FileExplorerVirtualRows' @@ -575,6 +576,11 @@ describe('FileExplorerRow collapse folder action', () => { ).toBe(false) }) + it('only shows open in terminal for directories', () => { + expect(shouldShowOpenInTerminalAction(directoryNode)).toBe(true) + expect(shouldShowOpenInTerminalAction(fileNode)).toBe(false) + }) + it('shows remote download only for desktop SSH or Remote Host file-like rows', () => { const runtimeContext = { settings: { activeRuntimeEnvironmentId: 'runtime-1' }, @@ -776,6 +782,7 @@ describe('FileExplorerRow collapse folder action', () => { onDuplicate: vi.fn(), onAddFolderAsProject: vi.fn(), canAddFolderAsProject: () => false, + onOpenInTerminal: vi.fn(), onRequestDelete: vi.fn(), onCollapseFolderSubtree, onFindInFolder: vi.fn(), @@ -827,6 +834,7 @@ describe('FileExplorerRow collapse folder action', () => { onDuplicate: vi.fn(), onAddFolderAsProject: vi.fn(), canAddFolderAsProject: () => false, + onOpenInTerminal: vi.fn(), onRequestDelete: vi.fn(), onCollapseFolderSubtree: vi.fn(), onFindInFolder, @@ -878,6 +886,7 @@ describe('FileExplorerRow collapse folder action', () => { onDuplicate: vi.fn(), onAddFolderAsProject: vi.fn(), canAddFolderAsProject: () => false, + onOpenInTerminal: vi.fn(), onRequestDelete: vi.fn(), onCollapseFolderSubtree: vi.fn(), onFindInFolder: vi.fn(), @@ -896,4 +905,56 @@ describe('FileExplorerRow collapse folder action', () => { expect(row.props.connectionId).toBe('ssh-1') }) + + it('passes the row node to the open in terminal handler', () => { + const onOpenInTerminal = vi.fn() + const element = FileExplorerVirtualRows({ + virtualizer: { + getTotalSize: () => 26, + getVirtualItems: () => [{ index: 0, key: 'src', start: 0 }], + measureElement: vi.fn() + } as never, + inlineInputIndex: -1, + rowProjection: createFileExplorerRowProjection([directoryNode]), + inlineInput: null, + handleInlineSubmit: vi.fn(), + dismissInlineInput: vi.fn(), + folderStatusByRelativePath: new Map(), + statusByRelativePath: new Map(), + ignoredByRelativePath: new Set(), + expanded: new Set([directoryNode.path]), + dirCache: {}, + selectedPaths: new Set(), + activeFileId: null, + flashingPath: null, + deleteShortcutLabel: 'Del', + onClick: vi.fn(), + onDoubleClick: vi.fn(), + onContextMenuSelect: vi.fn(), + onCopyPaths: vi.fn(), + onStartNew: vi.fn(), + onStartRename: vi.fn(), + onDuplicate: vi.fn(), + onAddFolderAsProject: vi.fn(), + canAddFolderAsProject: () => false, + onOpenInTerminal, + onRequestDelete: vi.fn(), + onCollapseFolderSubtree: vi.fn(), + onFindInFolder: vi.fn(), + onMoveDrop: vi.fn(), + onDragTargetChange: vi.fn(), + onDragSourceChange: vi.fn(), + onDragExpandDir: vi.fn(), + onNativeDragTargetChange: vi.fn(), + onNativeDragExpandDir: vi.fn(), + dropTargetDir: null, + dragSourcePath: null, + nativeDropTargetDir: null + }) + + const row = findFileExplorerRow(element) + ;(row.props.onOpenInTerminal as () => void)() + + expect(onOpenInTerminal).toHaveBeenCalledWith(directoryNode) + }) }) diff --git a/src/renderer/src/components/right-sidebar/FileExplorer.tsx b/src/renderer/src/components/right-sidebar/FileExplorer.tsx index ff236353958..83defe0e715 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorer.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorer.tsx @@ -54,6 +54,7 @@ import { translate } from '@/i18n/i18n' import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from '@/components/tab-bar/SortableTab' import type { RightSidebarExplorerView } from '../../../../shared/types' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { createNewTerminalTab } from '@/components/terminal/terminal-tab-actions' function FileExplorerFiles(): React.JSX.Element { const explorerView = useAppStore((s) => s.rightSidebarExplorerView) @@ -577,6 +578,15 @@ function FileExplorerFiles(): React.JSX.Element { }, [activeRepo, openModal] ) + const handleOpenInTerminal = useCallback( + (node: TreeNode) => { + if (!activeWorktreeId || !node.isDirectory) { + return + } + createNewTerminalTab(activeWorktreeId, undefined, { startupCwd: node.path }) + }, + [activeWorktreeId] + ) if (!worktreePath) { return ( @@ -743,6 +753,7 @@ function FileExplorerFiles(): React.JSX.Element { onDuplicate={handleDuplicate} onAddFolderAsProject={handleAddFolderAsProject} canAddFolderAsProject={(node) => canShowAddAsProjectAction(node, activeRepo)} + onOpenInTerminal={handleOpenInTerminal} onRequestDelete={handleContextMenuDelete} onCollapseFolderSubtree={handleCollapseFolderSubtree} onFindInFolder={handleFindInFolder} diff --git a/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx b/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx index d24d3d65637..765c1071b54 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx @@ -20,6 +20,7 @@ import { Loader2, Pencil, Search, + SquareTerminal, Trash2 } from 'lucide-react' import { toast } from 'sonner' @@ -285,6 +286,7 @@ type FileExplorerRowProps = { onDuplicate: (node: TreeNode) => void onAddFolderAsProject: () => void canAddAsProject: boolean + onOpenInTerminal: () => void onRequestDelete: () => void onCollapseFolderSubtree: () => void onFindInFolder: () => void @@ -304,6 +306,10 @@ export function shouldShowFindInFolderAction(node: TreeNode): boolean { return node.isDirectory } +export function shouldShowOpenInTerminalAction(node: TreeNode): boolean { + return node.isDirectory +} + export function shouldShowRemoteDownloadAction( node: TreeNode, connectionId?: string | null, @@ -422,6 +428,7 @@ export function FileExplorerRow({ onDuplicate, onAddFolderAsProject, canAddAsProject, + onOpenInTerminal, onRequestDelete, onCollapseFolderSubtree, onFindInFolder, @@ -696,6 +703,15 @@ export function FileExplorerRow({ )} )} + {shouldShowOpenInTerminalAction(node) && ( + + + {translate( + 'auto.components.right.sidebar.FileExplorerRow.e887fa4b2e', + 'Open in Terminal' + )} + + )} {!node.isDirectory && activeWorktreeId && ( diff --git a/src/renderer/src/components/right-sidebar/FileExplorerVirtualRows.tsx b/src/renderer/src/components/right-sidebar/FileExplorerVirtualRows.tsx index a400fef2c78..fe4e71dd608 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorerVirtualRows.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorerVirtualRows.tsx @@ -37,6 +37,7 @@ type FileExplorerVirtualRowsProps = { onDuplicate: (node: TreeNode) => void onAddFolderAsProject: (node: TreeNode) => void canAddFolderAsProject: (node: TreeNode) => boolean + onOpenInTerminal: (node: TreeNode) => void onRequestDelete: (node: TreeNode) => void onCollapseFolderSubtree: (node: TreeNode) => void onFindInFolder: (node: TreeNode) => void @@ -80,6 +81,7 @@ export function FileExplorerVirtualRows(props: FileExplorerVirtualRowsProps): Re onDuplicate, onAddFolderAsProject, canAddFolderAsProject, + onOpenInTerminal, onRequestDelete, onCollapseFolderSubtree, onFindInFolder, @@ -185,6 +187,7 @@ export function FileExplorerVirtualRows(props: FileExplorerVirtualRowsProps): Re onDuplicate={onDuplicate} onAddFolderAsProject={() => onAddFolderAsProject(n)} canAddAsProject={canAddFolderAsProject(n)} + onOpenInTerminal={() => onOpenInTerminal(n)} onRequestDelete={() => onRequestDelete(n)} onCollapseFolderSubtree={() => onCollapseFolderSubtree(n)} onFindInFolder={() => onFindInFolder(n)} diff --git a/src/renderer/src/components/right-sidebar/FileExplorerVirtualRowsAddProject.test.tsx b/src/renderer/src/components/right-sidebar/FileExplorerVirtualRowsAddProject.test.tsx index 7910c5f8fcf..16236cbd71f 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorerVirtualRowsAddProject.test.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorerVirtualRowsAddProject.test.tsx @@ -77,6 +77,7 @@ describe('FileExplorerVirtualRows add-as-project action', () => { onDuplicate: vi.fn(), onAddFolderAsProject, canAddFolderAsProject: (node) => node.path === directoryNode.path, + onOpenInTerminal: vi.fn(), onRequestDelete: vi.fn(), onCollapseFolderSubtree: vi.fn(), onFindInFolder: vi.fn(), diff --git a/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx b/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx index 27a29bbf032..10167da346b 100644 --- a/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx +++ b/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx @@ -11,7 +11,7 @@ import { cn } from '@/lib/utils' import type { AiVaultSession } from '../../../../shared/ai-vault-types' import { agentLabel } from './ai-vault-session-filters' import { translate } from '@/i18n/i18n' -import { SessionActionMenuItems } from './AiVaultSessionRow' +import { SessionActionMenuItems } from './AiVaultSessionActionMenuItems' import { aiVaultWorktreeJumpTooltip, type AiVaultSessionWorktreeInfo @@ -67,8 +67,8 @@ export function SessionRowTrailingActions({ onCopyResume: () => void onCopyId: () => void onCopyPath: () => void - onOpenLog: () => void - onRevealLog: () => void + onOpenLog?: () => void + onRevealLog?: () => void onOpenCwd?: () => void }) { const jumpToWorktreeTooltip = aiVaultWorktreeJumpTooltip(worktreeInfo) diff --git a/src/renderer/src/components/right-sidebar/SourceControl.preview-open.test.tsx b/src/renderer/src/components/right-sidebar/SourceControl.preview-open.test.tsx index 61bb6ac3634..6d0121157bb 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.preview-open.test.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.preview-open.test.tsx @@ -47,6 +47,7 @@ const mocks = vi.hoisted(() => { openBranchDiff: vi.fn(), createEmptySplitGroup: vi.fn(), discardRuntimeGitPath: vi.fn(), + bulkStageRuntimeGitPaths: vi.fn(), refreshGitStatusForWorktree: vi.fn(), requestEditorSaveQuiesce: vi.fn(), notifyEditorExternalFileChange: vi.fn() @@ -85,7 +86,8 @@ vi.mock('@/runtime/runtime-git-client', async (importOriginal) => { const actual = await importOriginal>() return { ...actual, - discardRuntimeGitPath: mocks.calls.discardRuntimeGitPath + discardRuntimeGitPath: mocks.calls.discardRuntimeGitPath, + bulkStageRuntimeGitPaths: mocks.calls.bulkStageRuntimeGitPaths } }) @@ -140,6 +142,7 @@ function resetState(overrides: Partial> = {}): void { vi.clearAllMocks() mocks.calls.createEmptySplitGroup.mockReturnValue('group-2') mocks.calls.discardRuntimeGitPath.mockResolvedValue(undefined) + mocks.calls.bulkStageRuntimeGitPaths.mockResolvedValue(undefined) mocks.calls.refreshGitStatusForWorktree.mockResolvedValue(undefined) mocks.calls.requestEditorSaveQuiesce.mockResolvedValue(undefined) mocks.state = { @@ -437,13 +440,76 @@ describe('SourceControl preview row opens', () => { const row = container.querySelector( '[data-source-control-path="packages/nested"]' ) - expect(row?.textContent).toContain('Submodule changes - stage inside submodule') + expect(row?.textContent).toContain('Stage inside submodule') + expect( + row?.querySelector('[title*="cannot stage file changes inside a submodule"]') + ).not.toBeNull() - const stageButton = row?.querySelector( - 'button[aria-label="Stage these changes inside the submodule"]' + expect(row?.querySelector('button[aria-label="Stage"]')).toBeNull() + }) + + it('does not render a commit-area Stage All primary for submodule worktree-only rows', () => { + resetState({ + gitStatusByWorktree: { + [mocks.activeWorktree.id]: [ + gitEntry({ + path: 'june-11th-launch', + submodule: { commitChanged: false, trackedChanges: true, untrackedChanges: false } + }) + ] + } + }) + renderSourceControl() + + const row = container.querySelector( + '[data-source-control-path="june-11th-launch"]' + ) + expect(row?.textContent).toContain('Stage inside submodule') + + const stageAllButton = [...container.querySelectorAll('button')].find( + (button) => button.textContent?.trim() === 'Stage All' + ) + expect(stageAllButton).toBeUndefined() + + const commitButton = [...container.querySelectorAll('button')].find( + (button) => button.textContent?.trim() === 'Commit' + ) + expect(commitButton).toBeDefined() + expect(commitButton?.disabled).toBe(true) + + act(() => { + commitButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + expect(mocks.calls.bulkStageRuntimeGitPaths).not.toHaveBeenCalled() + }) + + it('keeps the commit-area Stage All primary for changed submodule gitlinks', async () => { + resetState({ + gitStatusByWorktree: { + [mocks.activeWorktree.id]: [ + gitEntry({ + path: 'packages/nested', + submodule: { commitChanged: true, trackedChanges: false, untrackedChanges: false } + }) + ] + } + }) + renderSourceControl() + + const stageAllButton = [...container.querySelectorAll('button')].find( + (button) => button.textContent?.trim() === 'Stage All' + ) + expect(stageAllButton).toBeDefined() + expect(stageAllButton?.disabled).toBe(false) + + await act(async () => { + stageAllButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await Promise.resolve() + }) + expect(mocks.calls.bulkStageRuntimeGitPaths).toHaveBeenCalledWith( + expect.objectContaining({ worktreeId: mocks.activeWorktree.id, worktreePath: '/repo/wt' }), + ['packages/nested'] ) - expect(stageButton).not.toBeNull() - expect(stageButton?.getAttribute('aria-disabled')).toBe('true') }) it('passes preview=true when a plain branch row click opens a branch diff tab', () => { diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index 59a44672999..85080732d4c 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -254,6 +254,7 @@ import { createPrIntentCurrentTargetConflictsWithToken, createPrIntentGitStatusMatchesToken, createPrIntentRunTokenMatches, + getCreatePrIntentCommitFailureNoticeMessage, getCreatePrIntentStagePaths, resolveCreatePrIntentReviewBase, resolveCreatePrIntentRemoteStep, @@ -536,8 +537,9 @@ const SOURCE_CONTROL_TREE_DIRECTORY_PADDING_PX = 8 const SOURCE_CONTROL_TREE_FILE_PADDING_PX = 20 const EMPTY_GIT_HISTORY_STATE: GitHistoryPanelState = { status: 'idle' } const DEFAULT_COLLAPSED_SECTIONS = ['history'] as const -const SUBMODULE_WORKTREE_ONLY_LABEL = 'Submodule changes - stage inside submodule' -const SUBMODULE_WORKTREE_ONLY_STAGE_TOOLTIP = 'Stage these changes inside the submodule' +const SUBMODULE_WORKTREE_ONLY_LABEL = 'Stage inside submodule' +const SUBMODULE_WORKTREE_ONLY_TOOLTIP = + 'The parent repo (including Stage All) cannot stage file changes inside a submodule' const SUBMODULE_LOADING_LABEL = 'Loading submodule changes…' const SUBMODULE_EMPTY_LABEL = 'No changes in submodule' const SUBMODULE_ERROR_LABEL = 'Failed to load submodule changes' @@ -1026,6 +1028,7 @@ function SourceControlInner(): React.JSX.Element { loadSessionCommitDrafts() ) const commitDraftsRef = useRef(commitDrafts) + const commitErrorsRef = useRef>({}) const [commitErrors, setCommitErrors] = useState>({}) const [remoteActionErrors, setRemoteActionErrors] = useState< Record @@ -1154,6 +1157,13 @@ function SourceControlInner(): React.JSX.Element { }, [] ) + const setCommitErrorForWorktree = useCallback( + (worktreeId: string, message: string | null): void => { + commitErrorsRef.current = { ...commitErrorsRef.current, [worktreeId]: message } + setCommitErrors((prev) => ({ ...prev, [worktreeId]: message })) + }, + [] + ) const isFolder = activeRepo ? isFolderRepo(activeRepo) : false const worktreePath = activeWorktree?.path ?? null @@ -1916,6 +1926,7 @@ function SourceControlInner(): React.JSX.Element { return changed ? next : prev } updateCommitDrafts((prev) => pruneRecord(prev)) + commitErrorsRef.current = pruneRecord(commitErrorsRef.current) setCommitErrors((prev) => pruneRecord(prev)) setRemoteActionErrors((prev) => pruneRecord(prev)) setCommitInFlightByWorktree((prev) => pruneRecord(prev)) @@ -2036,7 +2047,7 @@ function SourceControlInner(): React.JSX.Element { commitInFlightRef.current[target.worktreeId] = true setCommitInFlightByWorktree((prev) => ({ ...prev, [target.worktreeId]: true })) - setCommitErrors((prev) => ({ ...prev, [target.worktreeId]: null })) + setCommitErrorForWorktree(target.worktreeId, null) try { const commitResult = await commitRuntimeGit( { @@ -2049,10 +2060,7 @@ function SourceControlInner(): React.JSX.Element { message ) if (!commitResult.success) { - setCommitErrors((prev) => ({ - ...prev, - [target.worktreeId]: commitResult.error ?? 'Commit failed' - })) + setCommitErrorForWorktree(target.worktreeId, commitResult.error ?? 'Commit failed') return false } @@ -2070,7 +2078,7 @@ function SourceControlInner(): React.JSX.Element { } return writeCommitDraftForWorktree(prev, target.worktreeId, '') }) - setCommitErrors((prev) => ({ ...prev, [target.worktreeId]: null })) + setCommitErrorForWorktree(target.worktreeId, null) if (!options?.target) { void refreshActiveGitStatusAfterMutation() } @@ -2102,10 +2110,10 @@ function SourceControlInner(): React.JSX.Element { } return true } catch (error) { - setCommitErrors((prev) => ({ - ...prev, - [target.worktreeId]: error instanceof Error ? error.message : 'Commit failed' - })) + setCommitErrorForWorktree( + target.worktreeId, + error instanceof Error ? error.message : 'Commit failed' + ) return false } finally { setCommitInFlightByWorktree((prev) => ({ ...prev, [target.worktreeId]: false })) @@ -2121,6 +2129,7 @@ function SourceControlInner(): React.JSX.Element { compareBaseRef, grouped.staged.length, refreshActiveGitStatusAfterMutation, + setCommitErrorForWorktree, updateCommitDrafts, unresolvedConflicts.length, worktreePath @@ -3821,12 +3830,21 @@ function SourceControlInner(): React.JSX.Element { if (abortIfStale()) { return } + const commitFailure = commitErrorsRef.current[token.worktreeId] ?? null setCreatePrIntentNoticeForWorktree(token.worktreeId, { tone: 'destructive', - message: translate( - 'auto.components.right.sidebar.SourceControl.createPrIntentCommitFailed', - 'Could not commit changes. Fix the issue, then retry Create PR.' - ) + message: getCreatePrIntentCommitFailureNoticeMessage(commitFailure, { + fallback: translate( + 'auto.components.right.sidebar.SourceControl.createPrIntentCommitFailed', + 'Could not commit changes. Fix the issue, then retry Create PR.' + ), + withSummary: (summary) => + translate( + 'auto.components.right.sidebar.SourceControl.createPrIntentCommitBlockedSummary', + 'Commit blocked: {{value0}} Fix the issue, then retry Create PR.', + { value0: summary } + ) + }) }) return } @@ -3995,7 +4013,12 @@ function SourceControlInner(): React.JSX.Element { ]) const hasUnstagedChanges = grouped.unstaged.length > 0 || grouped.untracked.length > 0 - const hasStageableChanges = hasUnstagedChanges + const hasStageableChanges = useMemo( + () => + grouped.unstaged.some(isStageableStatusEntry) || + grouped.untracked.some(isStageableStatusEntry), + [grouped.unstaged, grouped.untracked] + ) const hasPartiallyStagedChanges = useMemo(() => { if (grouped.staged.length === 0 || grouped.unstaged.length === 0) { return false @@ -8115,9 +8138,17 @@ const UncommittedEntryRow = React.memo(function UncommittedEntryRow({ {dirPath} )} - {(conflictLabel || isSubmoduleWorktreeOnly) && ( -
- {conflictLabel ?? SUBMODULE_WORKTREE_ONLY_LABEL} + {conflictLabel && ( +
{conflictLabel}
+ )} + {isSubmoduleWorktreeOnly && ( + // Why: parent git can stage a changed gitlink, but not nested + // worktree dirtiness. Keep that boundary visible in the row. +
+ {SUBMODULE_WORKTREE_ONLY_LABEL}
)}
@@ -8176,19 +8207,14 @@ const UncommittedEntryRow = React.memo(function UncommittedEntryRow({ }} /> )} - {(canStage || isSubmoduleWorktreeOnly) && ( + {canStage && ( { event.stopPropagation() void onStage(entry.path) }} - disabled={isSubmoduleWorktreeOnly} /> )} {canUnstage && ( diff --git a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.tsx b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.tsx index 49d01dd3459..a9eb07aa247 100644 --- a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.tsx @@ -65,6 +65,7 @@ export function SourceControlAgentActionDialog( const { handleOpenChange, shouldRenderDialog, + agentScopeNote, agentOptions, selectedAgent, hasEnabledAgents, @@ -101,6 +102,7 @@ export function SourceControlAgentActionDialog( { expect(repoMarkup).not.toContain('Launch recipe already saved') expect(repoMarkup).toContain('Save & start agent') }) + + it('surfaces a diverging repo override alongside the save controls', () => { + const markup = renderForm({ + agentScopeNote: { effectiveAgentLabel: 'Codex', globalAgentLabel: 'Claude' } + }) + + expect(markup).toContain('overrides your global default (Claude)') + expect(markup).toContain('currently runs Codex') + }) + + it('omits the scope note when there is no diverging repo override', () => { + const markup = renderForm({ agentScopeNote: null }) + + expect(markup).not.toContain('overrides your global default') + }) + + it('omits the scope note when the save controls are hidden', () => { + const markup = renderForm({ + agentScopeNote: { effectiveAgentLabel: 'Codex', globalAgentLabel: 'Claude' }, + canSaveAgentDefault: false + }) + + expect(markup).not.toContain('overrides your global default') + }) }) diff --git a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx index 779a200087c..4f2caf0133b 100644 --- a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx @@ -1,5 +1,13 @@ import React from 'react' -import { CheckCircle2, RefreshCw, RotateCcw, Settings, Sparkles, TriangleAlert } from 'lucide-react' +import { + CheckCircle2, + Info, + RefreshCw, + RotateCcw, + Settings, + Sparkles, + TriangleAlert +} from 'lucide-react' import AgentCombobox from '@/components/agent/AgentCombobox' import { Button } from '@/components/ui/button' import { DialogFooter } from '@/components/ui/dialog' @@ -19,6 +27,7 @@ import type { SourceControlAiWriteTarget } from '../../../../shared/source-contr import type { GlobalSettings, Repo, TuiAgent } from '../../../../shared/types' import { SourceControlActionVariableChips } from '../source-control/SourceControlActionVariableChips' import { sourceControlActionRecipeMatchesTarget } from './source-control-action-recipe-match' +import type { SourceControlAgentScopeNote } from './source-control-agent-action-dialog-result' import { translate } from '@/i18n/i18n' export type SourceControlAgentActionDeliveryPlanState = @@ -29,6 +38,7 @@ export type SourceControlAgentActionDeliveryPlanState = type SourceControlAgentActionDialogFormProps = { actionId: SourceControlLaunchActionId baseCommandInput: string + agentScopeNote: SourceControlAgentScopeNote | null agentOptions: AgentCatalogEntry[] selectedAgent: TuiAgent | null hasEnabledAgents: boolean @@ -73,6 +83,7 @@ function sourceControlLaunchSaveTargetFromValue( export function SourceControlAgentActionDialogForm({ actionId, baseCommandInput, + agentScopeNote, agentOptions, selectedAgent, hasEnabledAgents, @@ -263,6 +274,22 @@ export function SourceControlAgentActionDialogForm({ ) : null}
+ {showSaveLaunchRecipe && agentScopeNote ? ( +
+ + + {translate( + 'auto.components.right.sidebar.SourceControlAgentActionDialogForm.repoAgentOverrideNote', + 'This repository overrides your global default ({{global}}) and currently runs {{effective}}. Save to this repository to change what runs here.', + { + effective: agentScopeNote.effectiveAgentLabel, + global: agentScopeNote.globalAgentLabel + } + )} + +
+ ) : null} + {showSaveLaunchRecipe ? (
+ +let root: Root | null = null +let latest: HostScopeResult | null = null + +function HookProbe(props: { + activeWorktreeId: string | null + resumeTargetState: AiVaultSessionResumeTargetState +}): null { + latest = useAiVaultExecutionHostScope(props) + return null +} + +async function renderHook(props: { + activeWorktreeId: string | null + resumeTargetState: AiVaultSessionResumeTargetState +}): Promise { + if (!root) { + const container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + } + await act(async () => { + root?.render(createElement(HookProbe, props)) + }) +} + +function stateForWorktree(args: { + worktreeId: string + repoId: string + executionHostId?: string | null + hostId?: string | null +}): AiVaultSessionResumeTargetState { + return { + folderWorkspaces: [], + projectGroups: [], + repos: [ + { + id: args.repoId, + connectionId: null, + executionHostId: args.executionHostId ?? 'local' + } + ], + worktreesByRepo: { + [args.repoId]: [ + { + id: args.worktreeId, + repoId: args.repoId, + hostId: args.hostId ?? null + } + ] + } + } as unknown as Pick +} + +afterEach(() => { + if (root) { + act(() => root?.unmount()) + } + root = null + latest = null + document.body.replaceChildren() +}) + +describe('useAiVaultExecutionHostScope', () => { + it('defaults SSH worktrees to their SSH execution host', async () => { + await renderHook({ + activeWorktreeId: 'repo-1::/remote/repo', + resumeTargetState: stateForWorktree({ + worktreeId: 'repo-1::/remote/repo', + repoId: 'repo-1', + hostId: 'ssh:dev-box' + }) + }) + + expect(latest?.executionHostScope).toBe('ssh:dev-box') + expect(latest?.activeSshExecutionHostScope).toBe('ssh:dev-box') + }) + + it('defaults local worktrees to local history', async () => { + await renderHook({ + activeWorktreeId: 'repo-1::/local/repo', + resumeTargetState: stateForWorktree({ + worktreeId: 'repo-1::/local/repo', + repoId: 'repo-1' + }) + }) + + expect(latest?.executionHostScope).toBe('local') + expect(latest?.activeSshExecutionHostScope).toBeNull() + }) + + it('preserves manual host scope changes across unrelated rerenders', async () => { + const props = { + activeWorktreeId: 'repo-1::/remote/repo', + resumeTargetState: stateForWorktree({ + worktreeId: 'repo-1::/remote/repo', + repoId: 'repo-1', + hostId: 'ssh:dev-box' + }) + } + await renderHook(props) + + await act(async () => { + latest?.onExecutionHostScopeChange('all') + }) + await renderHook({ ...props, resumeTargetState: { ...props.resumeTargetState } }) + + expect(latest?.executionHostScope).toBe('all') + }) +}) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-host-scope.ts b/src/renderer/src/components/right-sidebar/ai-vault-host-scope.ts new file mode 100644 index 00000000000..8993e8a00e6 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-host-scope.ts @@ -0,0 +1,64 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { getAiVaultResumeWorkspaceExecutionHostId } from '@/lib/ai-vault-resume-target' +import { + ALL_EXECUTION_HOSTS_SCOPE, + LOCAL_EXECUTION_HOST_ID, + parseExecutionHostId, + type ExecutionHostScope +} from '../../../../shared/execution-host' +import type { AiVaultSessionResumeTargetState } from './ai-vault-session-resume' + +export function useAiVaultExecutionHostScope(args: { + activeWorktreeId: string | null + resumeTargetState: AiVaultSessionResumeTargetState +}): { + executionHostScope: ExecutionHostScope + activeSshExecutionHostScope: ExecutionHostScope | null + onExecutionHostScopeChange: (scope: ExecutionHostScope) => void +} { + const userChangedHostScopeRef = useRef(false) + const activeExecutionHostId = useMemo( + () => getAiVaultResumeWorkspaceExecutionHostId(args.resumeTargetState, args.activeWorktreeId), + [args.activeWorktreeId, args.resumeTargetState] + ) + const activeExecutionHost = parseExecutionHostId(activeExecutionHostId) + const defaultExecutionHostScope: ExecutionHostScope = + activeExecutionHost?.kind === 'ssh' ? activeExecutionHost.id : LOCAL_EXECUTION_HOST_ID + const activeSshExecutionHostScope: ExecutionHostScope | null = + activeExecutionHost?.kind === 'ssh' ? activeExecutionHost.id : null + const [executionHostScope, setExecutionHostScope] = + useState(defaultExecutionHostScope) + + useEffect(() => { + // Why: preserve an explicit user choice (e.g. "All") across incidental + // rerenders, but reset to the new default once that choice no longer + // applies to the active worktree's host. + const allowedScopes = new Set([ + LOCAL_EXECUTION_HOST_ID, + ALL_EXECUTION_HOSTS_SCOPE, + ...(activeSshExecutionHostScope ? [activeSshExecutionHostScope] : []) + ]) + if (!allowedScopes.has(executionHostScope)) { + setExecutionHostScope(defaultExecutionHostScope) + userChangedHostScopeRef.current = false + return + } + if (!userChangedHostScopeRef.current && executionHostScope !== defaultExecutionHostScope) { + setExecutionHostScope(defaultExecutionHostScope) + } + }, [activeSshExecutionHostScope, defaultExecutionHostScope, executionHostScope]) + + const handleExecutionHostScopeChange = useCallback( + (nextScope: ExecutionHostScope) => { + userChangedHostScopeRef.current = nextScope !== defaultExecutionHostScope + setExecutionHostScope(nextScope) + }, + [defaultExecutionHostScope] + ) + + return { + executionHostScope, + activeSshExecutionHostScope, + onExecutionHostScopeChange: handleExecutionHostScopeChange + } +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-original-pane.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-original-pane.test.ts index cec8a30a270..c47f64b9720 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-original-pane.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-original-pane.test.ts @@ -10,6 +10,7 @@ const OTHER_LEAF_ID = '22222222-2222-4222-8222-222222222222' const baseSession: AiVaultSession = { id: 'codex:session-1', + executionHostId: 'local', agent: 'codex', sessionId: 'session-1', title: 'Fix the pane focus', diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-display.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-display.test.ts index b341558c628..3da906781d9 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-display.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-display.test.ts @@ -9,6 +9,7 @@ import { const baseSession: AiVaultSession = { id: 'codex:1', + executionHostId: 'local', agent: 'codex', sessionId: 'session-1', title: 'Fix the flaky golden tests', diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts index 3190221f389..acbe0b051a1 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts @@ -15,6 +15,7 @@ import { const baseSession: AiVaultSession = { id: 'claude:1', + executionHostId: 'local', agent: 'claude', sessionId: 'session-1', title: 'Implement vault filters', diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts index ac5c07645fa..c55acb1e939 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts @@ -12,8 +12,9 @@ import { } from '@/lib/worktree-activation' import { useAppStore } from '@/store' import { - getAiVaultResumeWorkspaceTargetStatus, - isSupportedAiVaultResumeTargetStatus + canResumeAiVaultSessionOnTarget, + getAiVaultResumeWorkspaceExecutionHostId, + getAiVaultResumeWorkspaceTargetStatus } from '@/lib/ai-vault-resume-target' import type { AiVaultAgent, AiVaultSession } from '../../../../shared/ai-vault-types' import type { Worktree } from '../../../../shared/types' @@ -78,6 +79,8 @@ export function useAiVaultSessionLaunchActions({ const handleResume = useCallback( (session: AiVaultSession, targetWorktreeId?: string): void => { const targetId = resolveAiVaultSessionLaunchTarget({ + sessionFilePath: session.filePath, + sessionExecutionHostId: session.executionHostId, activeWorktreeId: activeWorktreeId ?? activeWorktree?.id ?? null, targetWorktreeId, targetState @@ -93,17 +96,7 @@ export function useAiVaultSessionLaunchActions({ } if (targetId.status === 'unsupported') { - toast.error( - targetId.targetStatus === 'runtime' - ? translate( - 'auto.components.right.sidebar.AiVaultPanel.runtimeWorkspacesUnsupported', - 'Resume from history is not available in runtime-hosted workspaces.' - ) - : translate( - 'auto.components.right.sidebar.AiVaultPanel.openSupportedWorkspace', - 'Open a local or SSH workspace before resuming a session.' - ) - ) + toast.error(aiVaultResumeUnsupportedMessage(targetId.targetStatus)) return } @@ -138,6 +131,8 @@ export type AiVaultSessionLaunchTarget = | { status: 'ready'; worktreeId: string } export function resolveAiVaultSessionLaunchTarget(args: { + sessionFilePath: string | null + sessionExecutionHostId?: AiVaultSession['executionHostId'] | null activeWorktreeId: string | null targetWorktreeId?: string targetState: AiVaultSessionResumeTargetState @@ -151,13 +146,47 @@ export function resolveAiVaultSessionLaunchTarget(args: { } const targetStatus = getAiVaultResumeWorkspaceTargetStatus(args.targetState, targetWorktreeId) - if (!isSupportedAiVaultResumeTargetStatus(targetStatus)) { + const targetExecutionHostId = getAiVaultResumeWorkspaceExecutionHostId( + args.targetState, + targetWorktreeId + ) + if ( + !canResumeAiVaultSessionOnTarget({ + sessionFilePath: args.sessionFilePath, + sessionExecutionHostId: args.sessionExecutionHostId, + targetStatus, + targetExecutionHostId + }) + ) { return { status: 'unsupported', targetStatus } } return { status: 'ready', worktreeId: targetWorktreeId } } +function aiVaultResumeUnsupportedMessage( + targetStatus: ReturnType +): string { + if (targetStatus === 'runtime') { + return translate( + 'auto.components.right.sidebar.AiVaultPanel.runtimeWorkspacesUnsupported', + 'Resume from history is not available in runtime-hosted workspaces.' + ) + } + // Why: local and SSH targets can both be valid generally; this branch means + // the session's recorded host does not match the selected workspace. + if (targetStatus === 'ssh' || targetStatus === 'local') { + return translate( + 'auto.components.right.sidebar.AiVaultPanel.sessionHostMismatchUnsupported', + 'This session belongs to a different host. Open a workspace on the same host to resume it.' + ) + } + return translate( + 'auto.components.right.sidebar.AiVaultPanel.openSupportedWorkspace', + 'Open a local or SSH workspace before resuming a session.' + ) +} + function activateAiVaultResumeWorkspace(workspaceId: string): void { const workspaceScope = parseWorkspaceKey(workspaceId) if (workspaceScope?.type === 'folder') { diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.test.ts new file mode 100644 index 00000000000..93fee6c94e9 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { canUseLocalAiVaultSessionPathActions } from './ai-vault-session-path-actions' + +describe('canUseLocalAiVaultSessionPathActions', () => { + it('allows OS path actions for local session history', () => { + expect(canUseLocalAiVaultSessionPathActions('local')).toBe(true) + }) + + it('blocks OS path actions for non-local or unknown session history', () => { + expect(canUseLocalAiVaultSessionPathActions('ssh:dev-box')).toBe(false) + expect(canUseLocalAiVaultSessionPathActions('runtime:gpu-box')).toBe(false) + expect(canUseLocalAiVaultSessionPathActions(undefined)).toBe(false) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.ts new file mode 100644 index 00000000000..f723bbb1959 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-path-actions.ts @@ -0,0 +1,13 @@ +import { + LOCAL_EXECUTION_HOST_ID, + normalizeExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' + +export function canUseLocalAiVaultSessionPathActions( + executionHostId: ExecutionHostId | null | undefined +): boolean { + // Why: Electron shell open/reveal APIs only validate paths on this computer; + // SSH session history exposes paths that exist on the remote host instead. + return normalizeExecutionHostId(executionHostId) === LOCAL_EXECUTION_HOST_ID +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts index fa53c685da8..d0e4c1a1c6e 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts @@ -6,6 +6,7 @@ import { buildAiVaultProjectContext, toAiVaultProjectKey } from './ai-vault-sess const baseSession: AiVaultSession = { id: 'claude:1', + executionHostId: 'local', agent: 'claude', sessionId: 'session-1', title: 'Implement project history', @@ -182,7 +183,11 @@ describe('buildAiVaultProjectContext', () => { repoId: repo.id, path: '/runtime/orca' }) - const session = makeSession({ id: 'claude:runtime-worktree', cwd: '/runtime/orca/src' }) + const session = makeSession({ + id: 'claude:runtime-worktree', + cwd: '/runtime/orca/src', + executionHostId: 'runtime:preview' + }) const context = buildAiVaultProjectContext({ repos: [repo], @@ -292,7 +297,7 @@ describe('buildAiVaultProjectContext', () => { expect(context.sessionProjectById.get(session.id)?.key).toBe('repo:repo-win') }) - it('falls back to folder when a hostless session matches multiple host buckets', () => { + it('uses the session host when matching overlapping local and SSH project paths', () => { const localRepo = makeRepo({ id: 'local', displayName: 'Local', path: '/srv/orca' }) const sshRepo = makeRepo({ id: 'ssh', @@ -300,7 +305,11 @@ describe('buildAiVaultProjectContext', () => { path: '/srv/orca', connectionId: 'target-1' }) - const session = makeSession({ id: 'claude:ambiguous', cwd: '/srv/orca/src' }) + const session = makeSession({ + id: 'claude:ssh-session', + cwd: '/srv/orca/src', + executionHostId: 'ssh:target-1' + }) const context = buildAiVaultProjectContext({ repos: [localRepo, sshRepo], @@ -324,16 +333,21 @@ describe('buildAiVaultProjectContext', () => { }) expect(context.sessionProjectById.get(session.id)).toMatchObject({ - kind: 'folder', - key: 'folder:/srv/orca/src', - label: 'orca/src' + kind: 'repo', + key: 'repo:ssh', + label: 'SSH', + hostKey: 'ssh:target-1' }) }) - it('uses ProjectHostSetup host ids when detecting ambiguous host buckets', () => { + it('falls back to folder when a legacy hostless session matches multiple host buckets', () => { const localRepo = makeRepo({ id: 'local', displayName: 'Local', path: '/srv/orca' }) const runtimeRepo = makeRepo({ id: 'runtime', displayName: 'Runtime', path: '/srv/orca' }) - const session = makeSession({ id: 'claude:runtime-ambiguous', cwd: '/srv/orca/src' }) + const session = makeSession({ + id: 'claude:runtime-ambiguous', + cwd: '/srv/orca/src', + executionHostId: undefined as unknown as AiVaultSession['executionHostId'] + }) const context = buildAiVaultProjectContext({ repos: [localRepo, runtimeRepo], diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts index fdb78325025..c0734a11881 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts @@ -1,4 +1,9 @@ -import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' +import { + getRepoExecutionHostId, + LOCAL_EXECUTION_HOST_ID, + normalizeExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' import type { ProjectHostSetupProjection } from '../../../../shared/project-host-setup-projection' import type { AiVaultSession } from '../../../../shared/ai-vault-types' import type { ProjectHostSetup, Repo, Worktree } from '../../../../shared/types' @@ -14,7 +19,7 @@ export type AiVaultSessionProject = { label: string projectId?: string repoId?: string - hostKey?: string + hostKey?: ExecutionHostId } export type AiVaultProjectContext = { @@ -27,7 +32,7 @@ export type AiVaultProjectContext = { type SessionProjectCandidate = { source: 'worktree' | 'setup' normalizedPath: string - hostKey: string + hostKey: ExecutionHostId projectId: string | null repoId: string | null } @@ -63,7 +68,7 @@ export function buildAiVaultProjectContext({ for (const session of sessions) { sessionProjectById.set( session.id, - resolveSessionProject(session.cwd, candidates, projectLabelByKey) + resolveSessionProject(session, candidates, projectLabelByKey) ) } @@ -147,10 +152,11 @@ function buildProjectCandidates( candidates.push({ source: 'worktree', normalizedPath: normalizeRuntimePathForComparison(worktree.path), - hostKey: - worktree.hostId ?? - setup?.hostId ?? - (repo ? getRepoExecutionHostId(repo) : LOCAL_EXECUTION_HOST_ID), + hostKey: resolveCandidateHostId( + worktree.hostId, + setup?.hostId, + repo ? getRepoExecutionHostId(repo) : null + ), projectId: worktree.projectId ?? setup?.projectId ?? null, repoId: worktree.repoId }) @@ -168,7 +174,7 @@ function buildProjectCandidates( candidates.push({ source: 'setup', normalizedPath: normalizeRuntimePathForComparison(setup.path), - hostKey: setup.hostId || getRepoExecutionHostId(setup), + hostKey: resolveCandidateHostId(setup.hostId, getRepoExecutionHostId(setup)), projectId: setup.projectId, repoId: setup.repoId || null }) @@ -193,15 +199,28 @@ function buildProjectCandidates( return candidates } +function resolveCandidateHostId( + ...values: readonly (string | null | undefined)[] +): ExecutionHostId { + for (const value of values) { + const hostId = normalizeExecutionHostId(value) + if (hostId) { + return hostId + } + } + return LOCAL_EXECUTION_HOST_ID +} + function hasCandidatePath(pathValue: string): boolean { return pathValue.trim().length > 0 } function resolveSessionProject( - cwd: string | null, + session: AiVaultSession, candidates: readonly SessionProjectCandidate[], projectLabelByKey: ReadonlyMap ): AiVaultSessionProject { + const cwd = session.cwd if (!cwd) { return { kind: 'unknown', key: 'unknown', label: '' } } @@ -209,14 +228,22 @@ function resolveSessionProject( const matches = candidates.filter((candidate) => isPathInsideOrEqual(candidate.normalizedPath, cwd) ) - const hostBuckets = new Set(matches.map((candidate) => candidate.hostKey)) - if (hostBuckets.size > 1) { - // Why: session rows do not carry host ids yet, so overlapping local/SSH - // paths must stay visible without being attributed to the wrong project. + const sessionHostId = normalizeExecutionHostId(session.executionHostId) + const hostMatches = sessionHostId + ? matches.filter((candidate) => candidate.hostKey === sessionHostId) + : matches + if (sessionHostId && matches.length > 0 && hostMatches.length === 0) { + // Why: same-path projects can exist on several hosts; a tagged transcript + // should never be attributed to a project on a different machine. return folderProject(cwd) } - const bestCandidate = matches.sort(compareCandidates)[0] + const hostBuckets = new Set(hostMatches.map((candidate) => candidate.hostKey)) + if (!sessionHostId && hostBuckets.size > 1) { + return folderProject(cwd) + } + + const bestCandidate = hostMatches.sort(compareCandidates)[0] if (!bestCandidate) { return folderProject(cwd) } diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts new file mode 100644 index 00000000000..2ea75f40f48 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts @@ -0,0 +1,374 @@ +// @vitest-environment happy-dom + +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { AiVaultListResult } from '../../../../shared/ai-vault-types' +import { useAppStore } from '@/store' +import { + resetAiVaultForcedRescanThrottleForTest, + useAiVaultSessionRefresh +} from './ai-vault-session-refresh' + +const EMPTY_RESULT: AiVaultListResult = { + sessions: [], + issues: [], + scannedAt: '2026-07-01T00:00:00.000Z' +} + +const THROTTLE_MS = 5_000 + +const listSessionsMock = vi.fn<(args: unknown) => Promise>() + +// Captures the hook's subscription to the main-process window-focus push. +let windowFocusCallback: (() => void) | null = null +const onWindowFocusedMock = vi.fn((callback: () => void) => { + windowFocusCallback = callback + return () => { + windowFocusCallback = null + } +}) + +async function fireWindowFocused(): Promise { + await act(async () => { + windowFocusCallback?.() + }) + await flushMicrotasks() +} + +const initialAppState = useAppStore.getInitialState() + +const roots: Root[] = [] +let latest: ReturnType | null = null + +function HookProbe(props: { + scopePaths: readonly string[] + executionHostScope?: 'local' | 'all' | `ssh:${string}` +}): null { + latest = useAiVaultSessionRefresh(props.scopePaths, props.executionHostScope ?? 'local') + return null +} + +async function renderHook( + scopePaths: readonly string[] = [], + executionHostScope: 'local' | 'all' | `ssh:${string}` = 'local' +): Promise { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + roots.push(root) + await act(async () => { + root.render(createElement(HookProbe, { scopePaths, executionHostScope })) + }) +} + +async function rerenderHook( + scopePaths: readonly string[] = [], + executionHostScope: 'local' | 'all' | `ssh:${string}` = 'local' +): Promise { + const root = roots.at(-1) + if (!root) { + throw new Error('renderHook must be called before rerenderHook') + } + await act(async () => { + root.render(createElement(HookProbe, { scopePaths, executionHostScope })) + }) +} + +async function flushMicrotasks(): Promise { + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) +} + +async function dispatch(target: EventTarget, type: string): Promise { + await act(async () => { + target.dispatchEvent(new Event(type)) + }) + await flushMicrotasks() +} + +async function advance(ms: number): Promise { + await act(async () => { + vi.advanceTimersByTime(ms) + }) + await flushMicrotasks() +} + +function makeAgentEntry(sessionId: string, state = 'working'): AgentStatusEntry { + return { + state, + prompt: '', + updatedAt: 0, + stateStartedAt: 0, + paneKey: `tab-${sessionId}:leaf-${sessionId}`, + stateHistory: [], + providerSession: { key: 'session_id', id: sessionId } + } as AgentStatusEntry +} + +async function setAgentStatuses(entries: Record): Promise { + await act(async () => { + useAppStore.setState({ agentStatusByPaneKey: entries }) + }) + await flushMicrotasks() +} + +function lastCallArgs(): unknown { + return listSessionsMock.mock.calls.at(-1)?.[0] +} + +beforeEach(() => { + vi.useFakeTimers() + listSessionsMock.mockReset().mockResolvedValue(EMPTY_RESULT) + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- test-only window.api shim + ;(window as any).api = { + aiVault: { listSessions: listSessionsMock, onWindowFocused: onWindowFocusedMock } + } + resetAiVaultForcedRescanThrottleForTest() + useAppStore.setState(initialAppState, true) +}) + +afterEach(() => { + roots.splice(0).forEach((root) => act(() => root.unmount())) + document.body.replaceChildren() + useAppStore.setState(initialAppState, true) + vi.useRealTimers() + vi.restoreAllMocks() +}) + +describe('useAiVaultSessionRefresh refocus behavior', () => { + it('bypasses the scan cache on mount so panel entry shows new sessions', async () => { + await renderHook() + await flushMicrotasks() + + expect(listSessionsMock).toHaveBeenCalledTimes(1) + expect(listSessionsMock.mock.calls[0]?.[0]).toMatchObject({ + executionHostScope: 'local', + force: true + }) + }) + + it('passes the requested execution host scope to the scanner', async () => { + await renderHook(['/repo'], 'ssh:dev-box') + await flushMicrotasks() + + expect(listSessionsMock).toHaveBeenCalledTimes(1) + expect(lastCallArgs()).toMatchObject({ + executionHostScope: 'ssh:dev-box', + scopePaths: ['/repo'] + }) + }) + + it('does not apply stale results after the host scope changes mid-scan', async () => { + let resolveLocal: ((result: AiVaultListResult) => void) | null = null + let resolveSsh: ((result: AiVaultListResult) => void) | null = null + listSessionsMock + .mockImplementationOnce( + () => new Promise((resolve) => (resolveLocal = resolve)) + ) + .mockImplementationOnce( + () => new Promise((resolve) => (resolveSsh = resolve)) + ) + + await renderHook([], 'local') + expect(listSessionsMock).toHaveBeenCalledTimes(1) + + await rerenderHook(['/remote/repo'], 'ssh:dev-box') + expect(listSessionsMock).toHaveBeenCalledTimes(1) + + await act(async () => { + resolveLocal?.({ ...EMPTY_RESULT, scannedAt: '2026-07-01T00:00:01.000Z' }) + }) + await flushMicrotasks() + + expect(latest?.scanResult).toBeNull() + expect(listSessionsMock).toHaveBeenCalledTimes(2) + expect(lastCallArgs()).toMatchObject({ + executionHostScope: 'ssh:dev-box', + scopePaths: ['/remote/repo'] + }) + + await act(async () => { + resolveSsh?.({ ...EMPTY_RESULT, scannedAt: '2026-07-01T00:00:02.000Z' }) + }) + await flushMicrotasks() + + expect(latest?.scanResult?.scannedAt).toBe('2026-07-01T00:00:02.000Z') + }) + + it('force re-scans on refocus once the throttle allows it', async () => { + await renderHook() + await flushMicrotasks() + expect(listSessionsMock).toHaveBeenCalledTimes(1) + + await advance(THROTTLE_MS + 1) + await fireWindowFocused() + + expect(listSessionsMock).toHaveBeenCalledTimes(2) + expect(lastCallArgs()).toMatchObject({ force: true }) + }) + + it('defers a refocus inside the throttle window to a trailing forced scan', async () => { + await renderHook() + await flushMicrotasks() + expect(listSessionsMock).toHaveBeenCalledTimes(1) + + // Within the throttle window nothing runs yet — the event must not be + // dropped, so it lands as one trailing scan when the throttle frees up. + await fireWindowFocused() + await dispatch(document, 'visibilitychange') + expect(listSessionsMock).toHaveBeenCalledTimes(1) + + await advance(THROTTLE_MS + 1) + expect(listSessionsMock).toHaveBeenCalledTimes(2) + expect(lastCallArgs()).toMatchObject({ force: true }) + }) + + it('ignores focus/visibility events while the document is hidden', async () => { + await renderHook() + await flushMicrotasks() + expect(listSessionsMock).toHaveBeenCalledTimes(1) + + vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden') + await advance(THROTTLE_MS + 1) + await dispatch(document, 'visibilitychange') + await fireWindowFocused() + await advance(THROTTLE_MS + 1) + + expect(listSessionsMock).toHaveBeenCalledTimes(1) + }) + + it('stops listening and cancels trailing scans after unmount', async () => { + await renderHook() + await flushMicrotasks() + expect(listSessionsMock).toHaveBeenCalledTimes(1) + + // Queue a trailing forced scan, then unmount before it fires. + await fireWindowFocused() + roots.splice(0).forEach((root) => act(() => root.unmount())) + await advance(THROTTLE_MS + 1) + await fireWindowFocused() + await dispatch(document, 'visibilitychange') + + expect(listSessionsMock).toHaveBeenCalledTimes(1) + }) + + it('does not raise the loading flag for refocus refreshes', async () => { + await renderHook() + await flushMicrotasks() + await advance(THROTTLE_MS + 1) + + let resolveScan: ((result: AiVaultListResult) => void) | null = null + listSessionsMock.mockImplementationOnce( + () => new Promise((resolve) => (resolveScan = resolve)) + ) + await fireWindowFocused() + + expect(listSessionsMock).toHaveBeenCalledTimes(2) + expect(latest?.loading).toBe(false) + + await act(async () => { + resolveScan?.({ ...EMPTY_RESULT, scannedAt: '2026-07-01T00:00:01.000Z' }) + }) + await flushMicrotasks() + expect(latest?.loading).toBe(false) + }) + + it('skips state updates when a refresh returns the applied snapshot', async () => { + await renderHook() + await flushMicrotasks() + const firstResult = latest?.scanResult + + // Same scannedAt = the snapshot already on screen was replayed. + listSessionsMock.mockResolvedValueOnce({ ...EMPTY_RESULT }) + await advance(THROTTLE_MS + 1) + await fireWindowFocused() + expect(latest?.scanResult).toBe(firstResult) + + listSessionsMock.mockResolvedValueOnce({ + ...EMPTY_RESULT, + scannedAt: '2026-07-01T00:00:02.000Z' + }) + await advance(THROTTLE_MS + 1) + await fireWindowFocused() + expect(latest?.scanResult).not.toBe(firstResult) + }) + + it('keeps the manual refresh button forcing a cache bypass', async () => { + await renderHook() + await flushMicrotasks() + + await act(async () => { + await latest?.refresh({ force: true }) + }) + + expect(lastCallArgs()).toMatchObject({ force: true }) + }) + + it('counts a manual force refresh against the rescan throttle', async () => { + await renderHook() + await flushMicrotasks() + + await advance(THROTTLE_MS + 1) + await act(async () => { + await latest?.refresh({ force: true }) + }) + expect(listSessionsMock).toHaveBeenCalledTimes(2) + + // The button just scanned; an immediate refocus defers to trailing. + await fireWindowFocused() + expect(listSessionsMock).toHaveBeenCalledTimes(2) + }) +}) + +describe('useAiVaultSessionRefresh in-app agent session behavior', () => { + it('force re-scans when an agent session starts inside Orca', async () => { + await renderHook() + await flushMicrotasks() + expect(listSessionsMock).toHaveBeenCalledTimes(1) + + await advance(THROTTLE_MS + 1) + await setAgentStatuses({ 'pane-1': makeAgentEntry('sess-1') }) + + expect(listSessionsMock).toHaveBeenCalledTimes(2) + expect(lastCallArgs()).toMatchObject({ force: true }) + }) + + it('defers an in-throttle session start to a trailing forced scan', async () => { + await renderHook() + await flushMicrotasks() + expect(listSessionsMock).toHaveBeenCalledTimes(1) + + await setAgentStatuses({ 'pane-1': makeAgentEntry('sess-1') }) + expect(listSessionsMock).toHaveBeenCalledTimes(1) + + await advance(THROTTLE_MS + 1) + expect(listSessionsMock).toHaveBeenCalledTimes(2) + expect(lastCallArgs()).toMatchObject({ force: true }) + }) + + it('ignores agent activity on already-known sessions', async () => { + await renderHook() + await flushMicrotasks() + await advance(THROTTLE_MS + 1) + await setAgentStatuses({ 'pane-1': makeAgentEntry('sess-1', 'working') }) + expect(listSessionsMock).toHaveBeenCalledTimes(2) + + // Message/tool pings and state transitions on a known session must not + // re-trigger — only a session id we haven't seen does. + await advance(THROTTLE_MS + 1) + await setAgentStatuses({ 'pane-1': makeAgentEntry('sess-1', 'done') }) + expect(listSessionsMock).toHaveBeenCalledTimes(2) + + // A closed pane re-opening the same session is not a new session either. + await setAgentStatuses({}) + await setAgentStatuses({ 'pane-1': makeAgentEntry('sess-1', 'working') }) + expect(listSessionsMock).toHaveBeenCalledTimes(2) + + await setAgentStatuses({ 'pane-2': makeAgentEntry('sess-2', 'working') }) + expect(listSessionsMock).toHaveBeenCalledTimes(3) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts index 8d9a7fdfd9d..88757297603 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts @@ -1,12 +1,39 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { AiVaultListResult, AiVaultSession } from '../../../../shared/ai-vault-types' +import type { ExecutionHostScope } from '../../../../shared/execution-host' +import { useAppStore } from '@/store' const SESSION_LIMIT = 500 -export function useAiVaultSessionRefresh(scopePaths: readonly string[]): { +// Panel entry and window refocus must show sessions started since the last +// scan, so they bypass the main process's 15s cache — but a full scan parses +// up to ~1000 transcripts, so bound forced scans to one per interval. Module +// scope so the throttle survives panel remounts (the panel unmounts per tab). +const FORCED_RESCAN_MIN_INTERVAL_MS = 5_000 +let lastForcedRescanAt = 0 + +function consumeForcedRescanBudget(): boolean { + const now = Date.now() + if (now - lastForcedRescanAt < FORCED_RESCAN_MIN_INTERVAL_MS) { + return false + } + lastForcedRescanAt = now + return true +} + +export function resetAiVaultForcedRescanThrottleForTest(): void { + lastForcedRescanAt = 0 +} + +type AiVaultRefreshArgs = { force?: boolean; background?: boolean } + +export function useAiVaultSessionRefresh( + scopePaths: readonly string[], + executionHostScope: ExecutionHostScope +): { error: string | null loading: boolean - refresh: (args?: { force?: boolean }) => Promise + refresh: (args?: AiVaultRefreshArgs) => Promise scanResult: AiVaultListResult | null sessions: AiVaultSession[] } { @@ -18,55 +45,125 @@ export function useAiVaultSessionRefresh(scopePaths: readonly string[]): { const refreshInFlightRef = useRef(false) const pendingRefreshRef = useRef(false) const pendingForceRef = useRef(false) + const pendingBackgroundRef = useRef(true) + const lastAppliedScanRef = useRef<{ scopeKey: string; scannedAt: string } | null>(null) const mountedRef = useRef(true) const scopePathsKey = useMemo(() => scopePaths.join('\n'), [scopePaths]) + const scanScopeKey = `${executionHostScope}\n${scopePathsKey}` const scopePathsRef = useRef(scopePaths) scopePathsRef.current = scopePaths + const executionHostScopeRef = useRef(executionHostScope) + executionHostScopeRef.current = executionHostScope + const currentScanScopeKey = useCallback( + () => `${executionHostScopeRef.current}\n${scopePathsRef.current.join('\n')}`, + [] + ) - const refresh = useCallback(async (args: { force?: boolean } = {}): Promise => { - // A scope change during an in-flight scan must not be dropped; queue one more - // scan so the current scoped view is refreshed after the older scan settles. - if (refreshInFlightRef.current) { - pendingRefreshRef.current = true - pendingForceRef.current ||= args.force === true - return - } - - refreshInFlightRef.current = true - const refreshId = refreshIdRef.current + 1 - refreshIdRef.current = refreshId - setLoading(true) - setError(null) - try { - const result = await window.api.aiVault.listSessions({ - limit: SESSION_LIMIT, - scopePaths: scopePathsRef.current, - force: args.force - }) - if (!mountedRef.current || refreshIdRef.current !== refreshId) { + const refresh = useCallback( + async (args: AiVaultRefreshArgs = {}): Promise => { + // A scope change during an in-flight scan must not be dropped; queue one more + // scan so the current scoped view is refreshed after the older scan settles. + if (refreshInFlightRef.current) { + pendingRefreshRef.current = true + pendingForceRef.current ||= args.force === true + pendingBackgroundRef.current &&= args.background === true return } - setScanResult(result) - setSessions(result.sessions) - } catch (err) { - if (mountedRef.current && refreshIdRef.current === refreshId) { - setError(err instanceof Error ? err.message : String(err)) + + refreshInFlightRef.current = true + const refreshId = refreshIdRef.current + 1 + refreshIdRef.current = refreshId + // A manual force scan counts against the throttle so an auto rescan right + // after the button press doesn't trigger a second full scan. + if (args.force === true) { + lastForcedRescanAt = Date.now() } - } finally { - refreshInFlightRef.current = false - if (mountedRef.current && refreshIdRef.current === refreshId) { - setLoading(false) + // Background (refocus) refreshes usually resolve from the main-process + // cache; suppressing the loading flag avoids a spinner flash on every + // return to the app. + if (args.background !== true) { + setLoading(true) } - if (pendingRefreshRef.current && mountedRef.current) { - pendingRefreshRef.current = false - const force = pendingForceRef.current - pendingForceRef.current = false - void refresh({ force }) + setError(null) + const scopeKey = scopePathsRef.current.join('\n') + const hostScope = executionHostScopeRef.current + const scanKey = `${hostScope}\n${scopeKey}` + try { + const result = await window.api.aiVault.listSessions({ + limit: SESSION_LIMIT, + scopePaths: scopePathsRef.current, + executionHostScope: hostScope, + force: args.force + }) + if (!mountedRef.current || refreshIdRef.current !== refreshId) { + return + } + // Why: host/scope changes queue a follow-up scan, but the older result + // may resolve first and must not briefly paint the wrong history list. + if (scanKey !== currentScanScopeKey()) { + return + } + // A cache hit returns the snapshot already on screen; skip the state + // updates so refocus flips don't force pointless re-renders. + if ( + lastAppliedScanRef.current?.scopeKey === scanKey && + lastAppliedScanRef.current.scannedAt === result.scannedAt + ) { + return + } + lastAppliedScanRef.current = { scopeKey: scanKey, scannedAt: result.scannedAt } + setScanResult(result) + setSessions(result.sessions) + } catch (err) { + if ( + mountedRef.current && + refreshIdRef.current === refreshId && + scanKey === currentScanScopeKey() + ) { + setError(err instanceof Error ? err.message : String(err)) + } + } finally { + refreshInFlightRef.current = false + if (mountedRef.current && refreshIdRef.current === refreshId) { + setLoading(false) + } + if (pendingRefreshRef.current && mountedRef.current) { + pendingRefreshRef.current = false + const force = pendingForceRef.current + // The queued refresh is background-only if every queued caller was. + const background = pendingBackgroundRef.current + pendingForceRef.current = false + pendingBackgroundRef.current = true + void refresh({ force, background }) + } } + // Deps intentionally avoid changing scope values: refresh reads them + // through refs and recurses on itself, so its identity must stay stable. + }, + [currentScanScopeKey] + ) + + // Forced rescans triggered by events (refocus, agent-session starts) run + // immediately when the throttle allows, otherwise once as soon as it frees + // up — dropping the event would leave a just-started session invisible + // until some unrelated later trigger. + const forcedRescanTimerRef = useRef | null>(null) + const requestForcedRescan = useCallback(() => { + const waitMs = lastForcedRescanAt + FORCED_RESCAN_MIN_INTERVAL_MS - Date.now() + if (waitMs <= 0) { + lastForcedRescanAt = Date.now() + void refresh({ background: true, force: true }) + return } - // Deps are intentionally empty: refresh reads changing values through refs - // and recurses on itself, so its identity must stay stable. - }, []) + if (forcedRescanTimerRef.current !== null) { + return + } + forcedRescanTimerRef.current = setTimeout(() => { + forcedRescanTimerRef.current = null + lastForcedRescanAt = Date.now() + void refresh({ background: true, force: true }) + }, waitMs) + }, [refresh]) useEffect(() => { mountedRef.current = true @@ -74,14 +171,75 @@ export function useAiVaultSessionRefresh(scopePaths: readonly string[]): { mountedRef.current = false refreshIdRef.current += 1 refreshInFlightRef.current = false + if (forcedRescanTimerRef.current !== null) { + clearTimeout(forcedRescanTimerRef.current) + forcedRescanTimerRef.current = null + } } }, []) // Re-scan on mount and whenever the active scope changes, since the scanner - // tailors its in-scope results to scopePaths. + // tailors its in-scope results to scopePaths. Force (throttled) so + // re-entering the panel shows sessions newer than the 15s cache; when the + // throttle denies it, paint from cache now and catch up once it frees. useEffect(() => { - void refresh() - }, [refresh, scopePathsKey]) + const force = consumeForcedRescanBudget() + void refresh({ force }) + if (!force) { + requestForcedRescan() + } + }, [refresh, requestForcedRescan, scanScopeKey]) + + // Sessions started while the app was backgrounded should appear when the + // user returns, so refocus also bypasses the scan cache (throttled). OS + // refocus arrives via the main process — renderer DOM focus events don't + // fire on macOS app activation; visibilitychange covers minimize-restore. + useEffect(() => { + const onRefocus = (): void => { + if (document.visibilityState !== 'visible') { + return + } + requestForcedRescan() + } + const unsubscribeWindowFocus = window.api.aiVault.onWindowFocused?.(onRefocus) + document.addEventListener('visibilitychange', onRefocus) + return () => { + unsubscribeWindowFocus?.() + document.removeEventListener('visibilitychange', onRefocus) + } + }, [requestForcedRescan]) + + // Sessions started inside Orca never blur the window, so refocus alone + // can't surface them. Agent hooks already report provider sessions; re-scan + // only when a session id we haven't seen appears — state transitions are + // deliberately ignored, they fire constantly while agents work. + const agentSessionIdsKey = useAppStore((s) => { + const ids: string[] = [] + for (const entry of Object.values(s.agentStatusByPaneKey)) { + if (entry.providerSession?.id) { + ids.push(entry.providerSession.id) + } + } + return ids.sort().join('\n') + }) + const seenAgentSessionIdsRef = useRef | null>(null) + useEffect(() => { + const ids = agentSessionIdsKey === '' ? [] : agentSessionIdsKey.split('\n') + // The mount refresh already covers sessions live at mount time. + if (seenAgentSessionIdsRef.current === null) { + seenAgentSessionIdsRef.current = new Set(ids) + return + } + const seen = seenAgentSessionIdsRef.current + const freshIds = ids.filter((id) => !seen.has(id)) + if (freshIds.length === 0) { + return + } + for (const id of freshIds) { + seen.add(id) + } + requestForcedRescan() + }, [agentSessionIdsKey, requestForcedRescan]) return { error, loading, refresh, scanResult, sessions } } diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts index a3cf98dd7f5..5e8bc3f4242 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts @@ -106,10 +106,15 @@ function makeWorktreeInfo( } } +const HOST_SESSION_FILE = '/Users/ada/.claude/projects/-repo-orca/session-1.jsonl' +const WSL_SESSION_FILE = + '\\\\wsl$\\Ubuntu\\home\\ada\\.claude\\projects\\-repo-orca\\session-1.jsonl' + describe('resolveAiVaultSessionResumeState', () => { it('prefers the session worktree over the active workspace', () => { expect( resolveAiVaultSessionResumeState({ + sessionFilePath: HOST_SESSION_FILE, worktreeInfo: makeWorktreeInfo('active'), activeWorktreeId: 'repo-1::/repo/other', worktrees: [ @@ -128,6 +133,7 @@ describe('resolveAiVaultSessionResumeState', () => { it('falls back to the active workspace when the session worktree is unavailable', () => { expect( resolveAiVaultSessionResumeState({ + sessionFilePath: HOST_SESSION_FILE, worktreeInfo: makeWorktreeInfo('archived'), activeWorktreeId: 'repo-1::/repo/orca', worktrees: [makeWorktree()], @@ -143,6 +149,7 @@ describe('resolveAiVaultSessionResumeState', () => { it('blocks missing worktrees even when the repo is SSH-owned', () => { expect( resolveAiVaultSessionResumeState({ + sessionFilePath: HOST_SESSION_FILE, worktreeInfo: makeWorktreeInfo('active'), activeWorktreeId: null, worktrees: [], @@ -158,6 +165,7 @@ describe('resolveAiVaultSessionResumeState', () => { it('allows SSH-owned session worktrees', () => { expect( resolveAiVaultSessionResumeState({ + sessionFilePath: WSL_SESSION_FILE, worktreeInfo: makeWorktreeInfo('active'), activeWorktreeId: null, worktrees: [makeWorktree()], @@ -173,6 +181,7 @@ describe('resolveAiVaultSessionResumeState', () => { it('allows SSH-stamped worktrees even when the repo owner is runtime', () => { expect( resolveAiVaultSessionResumeState({ + sessionFilePath: WSL_SESSION_FILE, worktreeInfo: makeWorktreeInfo('active'), activeWorktreeId: null, worktrees: [makeWorktree({ hostId: 'ssh:ssh-1' })], @@ -188,6 +197,7 @@ describe('resolveAiVaultSessionResumeState', () => { it('blocks runtime-owned targets even when they have no SSH connection', () => { expect( resolveAiVaultSessionResumeState({ + sessionFilePath: HOST_SESSION_FILE, worktreeInfo: makeWorktreeInfo('active'), activeWorktreeId: null, worktrees: [makeWorktree()], @@ -208,6 +218,7 @@ describe('resolveAiVaultSessionResumeState', () => { it('blocks runtime-stamped worktrees even when the repo owner is local', () => { expect( resolveAiVaultSessionResumeState({ + sessionFilePath: HOST_SESSION_FILE, worktreeInfo: makeWorktreeInfo('active'), activeWorktreeId: null, worktrees: [makeWorktree({ hostId: 'runtime:env-1' })], @@ -223,6 +234,7 @@ describe('resolveAiVaultSessionResumeState', () => { it('prefers the session worktree when the active workspace is SSH-owned', () => { expect( resolveAiVaultSessionResumeState({ + sessionFilePath: HOST_SESSION_FILE, worktreeInfo: makeWorktreeInfo('active'), activeWorktreeId: 'repo-2::/remote/orca', worktrees: [ @@ -245,6 +257,7 @@ describe('resolveAiVaultSessionResumeState', () => { it('falls back to the active workspace when the session worktree is runtime-owned', () => { expect( resolveAiVaultSessionResumeState({ + sessionFilePath: HOST_SESSION_FILE, worktreeInfo: makeWorktreeInfo('active'), activeWorktreeId: 'repo-2::/repo/other', worktrees: [ @@ -275,6 +288,7 @@ describe('resolveAiVaultSessionResumeState', () => { it('falls back to an active SSH folder workspace', () => { expect( resolveAiVaultSessionResumeState({ + sessionFilePath: WSL_SESSION_FILE, worktreeInfo: makeWorktreeInfo('archived'), activeWorktreeId: folderWorkspaceKey('folder-1'), worktrees: [], @@ -288,9 +302,27 @@ describe('resolveAiVaultSessionResumeState', () => { }) }) + it('blocks host-stored sessions when only an SSH workspace is open', () => { + expect( + resolveAiVaultSessionResumeState({ + sessionFilePath: HOST_SESSION_FILE, + worktreeInfo: makeWorktreeInfo('archived'), + activeWorktreeId: folderWorkspaceKey('folder-1'), + worktrees: [], + repos: [], + targetState: makeFolderTargetState({ id: 'group-1', connectionId: 'ssh-1' }) + }) + ).toEqual({ + blocked: true, + worktreeId: null, + usesSessionWorktree: false + }) + }) + it('blocks an active runtime folder workspace', () => { expect( resolveAiVaultSessionResumeState({ + sessionFilePath: HOST_SESSION_FILE, worktreeInfo: makeWorktreeInfo('archived'), activeWorktreeId: folderWorkspaceKey('folder-1'), worktrees: [], @@ -309,6 +341,7 @@ describe('resolveAiVaultSessionResumeActions', () => { it('exposes separate session-worktree and active-workspace targets', () => { expect( resolveAiVaultSessionResumeActions({ + sessionFilePath: HOST_SESSION_FILE, worktreeInfo: makeWorktreeInfo('active'), activeWorktreeId: 'repo-1::/repo/other', worktrees: [ @@ -326,6 +359,7 @@ describe('resolveAiVaultSessionResumeActions', () => { it('enables an SSH active-workspace action when the session worktree is local', () => { expect( resolveAiVaultSessionResumeActions({ + sessionFilePath: WSL_SESSION_FILE, worktreeInfo: makeWorktreeInfo('active'), activeWorktreeId: 'repo-2::/remote/orca', worktrees: [ @@ -344,9 +378,32 @@ describe('resolveAiVaultSessionResumeActions', () => { }) }) + it('disables the SSH active-workspace action for host-stored sessions', () => { + expect( + resolveAiVaultSessionResumeActions({ + sessionFilePath: HOST_SESSION_FILE, + worktreeInfo: makeWorktreeInfo('active'), + activeWorktreeId: 'repo-2::/remote/orca', + worktrees: [ + makeWorktree(), + makeWorktree({ + id: 'repo-2::/remote/orca', + repoId: 'repo-2', + path: '/remote/orca' + }) + ], + repos: [{ id: 'repo-1' } as Repo, { id: 'repo-2', connectionId: 'ssh-1' } as Repo] + }) + ).toEqual({ + worktree: { worktreeId: 'repo-1::/repo/orca', disabled: false }, + newTab: { worktreeId: 'repo-2::/remote/orca', disabled: true } + }) + }) + it('disables runtime-owned targets without disabling local targets', () => { expect( resolveAiVaultSessionResumeActions({ + sessionFilePath: HOST_SESSION_FILE, worktreeInfo: makeWorktreeInfo('active'), activeWorktreeId: 'repo-2::/repo/other', worktrees: [ @@ -376,6 +433,7 @@ describe('resolveAiVaultSessionResumeActions', () => { it('does not expose the active workspace as a duplicate new-tab target', () => { expect( resolveAiVaultSessionResumeActions({ + sessionFilePath: HOST_SESSION_FILE, worktreeInfo: makeWorktreeInfo('current'), activeWorktreeId: 'repo-1::/repo/orca', worktrees: [makeWorktree()], @@ -390,6 +448,7 @@ describe('resolveAiVaultSessionResumeActions', () => { it('enables the active folder workspace action when it is local or SSH-owned', () => { expect( resolveAiVaultSessionResumeActions({ + sessionFilePath: WSL_SESSION_FILE, worktreeInfo: makeWorktreeInfo('archived'), activeWorktreeId: folderWorkspaceKey('folder-1'), worktrees: [], @@ -405,6 +464,7 @@ describe('resolveAiVaultSessionResumeActions', () => { it('disables the active folder workspace action when it is runtime-owned', () => { expect( resolveAiVaultSessionResumeActions({ + sessionFilePath: HOST_SESSION_FILE, worktreeInfo: makeWorktreeInfo('archived'), activeWorktreeId: folderWorkspaceKey('folder-1'), worktrees: [], @@ -422,6 +482,7 @@ describe('resolveAiVaultSessionLaunchTarget', () => { it('allows direct resume into an active SSH folder workspace', () => { expect( resolveAiVaultSessionLaunchTarget({ + sessionFilePath: WSL_SESSION_FILE, activeWorktreeId: folderWorkspaceKey('folder-1'), targetState: makeFolderTargetState({ id: 'group-1', connectionId: 'ssh-1' }) }) @@ -431,9 +492,23 @@ describe('resolveAiVaultSessionLaunchTarget', () => { }) }) + it('blocks direct resume of a host-stored session into an SSH folder workspace', () => { + expect( + resolveAiVaultSessionLaunchTarget({ + sessionFilePath: HOST_SESSION_FILE, + activeWorktreeId: folderWorkspaceKey('folder-1'), + targetState: makeFolderTargetState({ id: 'group-1', connectionId: 'ssh-1' }) + }) + ).toEqual({ + status: 'unsupported', + targetStatus: 'ssh' + }) + }) + it('blocks direct resume into a runtime folder workspace', () => { expect( resolveAiVaultSessionLaunchTarget({ + sessionFilePath: WSL_SESSION_FILE, activeWorktreeId: folderWorkspaceKey('folder-1'), targetState: makeFolderTargetState({ id: 'group-1', executionHostId: 'runtime:env-1' }) }) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts index 07091909d94..eb82d910cb9 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts @@ -1,8 +1,10 @@ import type { Repo, Worktree } from '../../../../shared/types' import { - getAiVaultResumeWorkspaceTargetStatus, - isSupportedAiVaultResumeTargetStatus + canResumeAiVaultSessionOnTarget, + getAiVaultResumeWorkspaceExecutionHostId, + getAiVaultResumeWorkspaceTargetStatus } from '@/lib/ai-vault-resume-target' +import type { AiVaultSession } from '../../../../shared/ai-vault-types' import type { AppState } from '@/store/types' import { translate } from '@/i18n/i18n' import { parseWorkspaceKey } from '../../../../shared/workspace-scope' @@ -33,6 +35,8 @@ export type AiVaultSessionResumeActions = { } export function resolveAiVaultSessionResumeState(args: { + sessionFilePath: string | null + sessionExecutionHostId?: AiVaultSession['executionHostId'] | null worktreeInfo: AiVaultSessionWorktreeInfo | null activeWorktreeId: string | null worktrees: readonly Worktree[] @@ -54,6 +58,8 @@ export function resolveAiVaultSessionResumeState(args: { for (const worktreeId of candidateWorktreeIds) { const targetId = resolveSupportedResumeWorktreeId({ + sessionFilePath: args.sessionFilePath, + sessionExecutionHostId: args.sessionExecutionHostId, worktreeId, targetState }) @@ -75,6 +81,8 @@ export function resolveAiVaultSessionResumeState(args: { } export function resolveAiVaultSessionResumeActions(args: { + sessionFilePath: string | null + sessionExecutionHostId?: AiVaultSession['executionHostId'] | null worktreeInfo: AiVaultSessionWorktreeInfo | null activeWorktreeId: string | null worktrees: readonly Worktree[] @@ -88,10 +96,14 @@ export function resolveAiVaultSessionResumeActions(args: { const targetState = resolveAiVaultResumeTargetState(args) const sessionTargetId = resolveSupportedResumeWorktreeId({ + sessionFilePath: args.sessionFilePath, + sessionExecutionHostId: args.sessionExecutionHostId, worktreeId: sessionWorktreeId, targetState }) const activeTargetId = resolveSupportedResumeWorktreeId({ + sessionFilePath: args.sessionFilePath, + sessionExecutionHostId: args.sessionExecutionHostId, worktreeId: args.activeWorktreeId && args.activeWorktreeId !== sessionWorktreeId ? args.activeWorktreeId @@ -136,6 +148,8 @@ export function isKnownAiVaultResumeWorkspaceTarget( } function resolveSupportedResumeWorktreeId(args: { + sessionFilePath: string | null + sessionExecutionHostId?: AiVaultSession['executionHostId'] | null worktreeId: string | null targetState: AiVaultSessionResumeTargetState }): string | null { @@ -148,7 +162,18 @@ function resolveSupportedResumeWorktreeId(args: { } const targetStatus = getAiVaultResumeWorkspaceTargetStatus(args.targetState, args.worktreeId) - if (!isSupportedAiVaultResumeTargetStatus(targetStatus)) { + const targetExecutionHostId = getAiVaultResumeWorkspaceExecutionHostId( + args.targetState, + args.worktreeId + ) + if ( + !canResumeAiVaultSessionOnTarget({ + sessionFilePath: args.sessionFilePath, + sessionExecutionHostId: args.sessionExecutionHostId, + targetStatus, + targetExecutionHostId + }) + ) { return null } diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-row-display.tsx b/src/renderer/src/components/right-sidebar/ai-vault-session-row-display.tsx new file mode 100644 index 00000000000..1d3972a6fac --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-row-display.tsx @@ -0,0 +1,115 @@ +import type React from 'react' +import { Badge } from '@/components/ui/badge' +import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' +import { AgentIcon } from '@/lib/agent-catalog' +import { useRepoById } from '@/store/selectors' +import { resolveRepoBadgeColor } from '../../../../shared/repo-badge-color' +import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree-id' +import type { AiVaultScope, AiVaultSession } from '../../../../shared/ai-vault-types' +import { translate } from '@/i18n/i18n' +import { SessionTime } from './AiVaultSessionDetails' +import { agentLabel } from './ai-vault-session-filters' +import { + aiVaultWorktreeStatusLabel, + shouldShowAiVaultWorktreeStatusBadge, + shouldShowAiVaultSessionWorktreeLine, + type AiVaultSessionWorktreeInfo +} from './ai-vault-session-worktree' + +export function getSessionDetailsId(sessionId: string): string { + return `ai-vault-session-details-${sessionId.replace(/[^A-Za-z0-9_-]/g, '-')}` +} + +export function SessionMetadata({ + session, + updatedAt, + worktreeInfo, + vaultScope +}: { + session: AiVaultSession + updatedAt: string + worktreeInfo: AiVaultSessionWorktreeInfo | null + vaultScope: AiVaultScope +}) { + return ( +
+ + + +
+ {agentLabel(session.agent)} + + {translate( + 'auto.components.right.sidebar.AiVaultSessionRow.messageCount', + '{{value0}} msgs', + { value0: session.messageCount } + )} + + · + +
+ {shouldShowAiVaultSessionWorktreeLine(worktreeInfo, { vaultScope }) ? ( +
+ +
+ ) : null} +
+ ) +} + +export function SessionWorktreeLine({ + worktreeInfo, + vaultScope +}: { + worktreeInfo: AiVaultSessionWorktreeInfo + vaultScope: AiVaultScope +}): React.JSX.Element { + const repoId = worktreeInfo.worktreeId + ? (splitWorktreeIdForFilesystem(worktreeInfo.worktreeId)?.repoId ?? null) + : null + const repo = useRepoById(repoId) + + return ( +
+ {shouldShowAiVaultWorktreeStatusBadge(worktreeInfo.status, { vaultScope }) ? ( + + {worktreeStatusLabel(worktreeInfo.status)} + + ) : null} + + + +
+ ) +} + +function worktreeStatusLabel(status: AiVaultSessionWorktreeInfo['status']): string { + return aiVaultWorktreeStatusLabel(status) +} + +export function conversationRoleLabel( + role: AiVaultSession['previewMessages'][number]['role'] +): string { + if (role === 'user') { + return translate('auto.components.right.sidebar.AiVaultSessionRow.userRole', 'You') + } + if (role === 'assistant') { + return translate('auto.components.right.sidebar.AiVaultSessionRow.agentRole', 'Agent') + } + if (role === 'tool') { + return translate('auto.components.right.sidebar.AiVaultSessionRow.toolRole', 'Tool') + } + if (role === 'system') { + return translate('auto.components.right.sidebar.AiVaultSessionRow.systemRole', 'System') + } + return translate('auto.components.right.sidebar.AiVaultSessionRow.sessionRole', 'Session') +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree-affordances.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree-affordances.ts new file mode 100644 index 00000000000..6551d961bd9 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree-affordances.ts @@ -0,0 +1,123 @@ +import { normalizeRuntimePathSeparators } from '../../../../shared/cross-platform-path' +import type { AiVaultScope } from '../../../../shared/ai-vault-types' +import { translate } from '@/i18n/i18n' +import type { + AiVaultSessionWorktreeInfo, + AiVaultSessionWorktreeStatus +} from './ai-vault-session-worktree' + +export function canJumpToAiVaultSessionWorktree( + worktreeInfo: AiVaultSessionWorktreeInfo | null +): boolean { + return Boolean( + worktreeInfo?.worktreeId && + worktreeInfo.status !== 'archived' && + worktreeInfo.status !== 'unavailable' + ) +} + +// Why: a session in the worktree you're already viewing has nowhere to jump, +// so we hide the affordance rather than offering a self-jump (the "Current +// worktree" badge already signals where it lives). +export function isAiVaultSessionInCurrentWorktree( + worktreeInfo: AiVaultSessionWorktreeInfo | null +): boolean { + return worktreeInfo?.status === 'current' +} + +export function aiVaultWorktreeJumpTooltip( + worktreeInfo: AiVaultSessionWorktreeInfo | null +): string { + if (canJumpToAiVaultSessionWorktree(worktreeInfo)) { + return translate( + 'auto.components.right.sidebar.AiVaultSessionWorktree.jumpToWorktree', + 'Jump to Worktree' + ) + } + if (!worktreeInfo) { + return translate( + 'auto.components.right.sidebar.AiVaultSessionWorktree.noRecordedWorktree', + 'No worktree was recorded for this session.' + ) + } + if (worktreeInfo.status === 'archived') { + return translate( + 'auto.components.right.sidebar.AiVaultSessionWorktree.archivedJumpUnavailable', + 'This session is in an archived worktree.' + ) + } + if (worktreeInfo.status === 'unavailable') { + return translate( + 'auto.components.right.sidebar.AiVaultSessionWorktree.noActiveWorktreeMatch', + 'No active worktree matches this session.' + ) + } + return translate( + 'auto.components.right.sidebar.AiVaultSessionWorktree.noActiveWorktreeTarget', + 'No active worktree is available.' + ) +} + +export function aiVaultWorktreeCompactPath(pathValue: string): string { + const parts = normalizeRuntimePathSeparators(pathValue).split('/').filter(Boolean) + if (parts.length >= 2) { + return parts.slice(-2).join('/') + } + return parts[0] ?? pathValue +} + +export function shouldShowAiVaultSessionWorktreeLine( + worktreeInfo: AiVaultSessionWorktreeInfo | null, + options?: { vaultScope?: AiVaultScope } +): worktreeInfo is AiVaultSessionWorktreeInfo { + if (!worktreeInfo) { + return false + } + // Why: workspace scope already limits history to the active workspace; the + // worktree row adds no value when the session lives in the worktree on screen. + if (options?.vaultScope === 'workspace' && worktreeInfo.status === 'current') { + return false + } + return true +} + +export function shouldShowAiVaultWorktreeStatusBadge( + status: AiVaultSessionWorktreeStatus, + options?: { vaultScope?: AiVaultScope } +): boolean { + // Why: "active" repeats the branch label without adding scan value in dense rows. + if (status === 'active') { + return false + } + // Why: workspace scope already filters to the active workspace, so "Current + // worktree" is redundant in the default history view. + if (status === 'current' && options?.vaultScope === 'workspace') { + return false + } + return true +} + +export function aiVaultWorktreeStatusLabel(status: AiVaultSessionWorktreeStatus): string { + if (status === 'current') { + return translate( + 'auto.components.right.sidebar.AiVaultSessionWorktree.currentWorktree', + 'Current worktree' + ) + } + if (status === 'active') { + return translate( + 'auto.components.right.sidebar.AiVaultSessionWorktree.activeWorktree', + 'Active worktree' + ) + } + if (status === 'archived') { + return translate( + 'auto.components.right.sidebar.AiVaultSessionWorktree.archivedWorktree', + 'Archived worktree' + ) + } + return translate( + 'auto.components.right.sidebar.AiVaultSessionWorktree.unavailableWorktree', + 'Unavailable worktree' + ) +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts index b378afe3d42..56ba2f246f7 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import type { AiVaultSession } from '../../../../shared/ai-vault-types' -import type { Worktree } from '../../../../shared/types' +import type { Repo, Worktree } from '../../../../shared/types' import { aiVaultWorktreeCompactPath, aiVaultWorktreeJumpTooltip, @@ -10,11 +10,13 @@ import { resolveAiVaultSessionWorktreeDisplay, resolveAiVaultSessionWorktreeInfo, shouldShowAiVaultWorktreeStatusBadge, + shouldShowAiVaultSessionWorktreeLine, type AiVaultSessionWorktreeInfo } from './ai-vault-session-worktree' const baseSession: AiVaultSession = { id: 'codex:session-1', + executionHostId: 'local', agent: 'codex', sessionId: 'session-1', title: 'Find the pane', @@ -55,6 +57,19 @@ function makeWorktree(overrides: Partial = {}): Worktree { return { ...worktree, ...overrides } } +function makeRepo(overrides: Partial = {}): Repo { + return { + id: 'repo-1', + path: '/repo/orca', + displayName: 'orca', + badgeColor: '#737373', + addedAt: 1, + connectionId: null, + executionHostId: 'local', + ...overrides + } +} + describe('resolveAiVaultSessionWorktreeInfo', () => { it('marks the selected owning worktree as current', () => { const worktree = makeWorktree() @@ -135,6 +150,55 @@ describe('resolveAiVaultSessionWorktreeInfo', () => { path: '\\\\wsl.localhost\\Ubuntu\\home\\ada\\orca' }) }) + + it('uses the session host when multiple worktrees share the same path', () => { + const localWorktree = makeWorktree({ + id: 'repo-local::/srv/orca', + repoId: 'repo-local', + displayName: 'local', + path: '/srv/orca', + hostId: 'local' + }) + const sshWorktree = makeWorktree({ + id: 'repo-ssh::/srv/orca', + repoId: 'repo-ssh', + displayName: 'ssh', + path: '/srv/orca', + hostId: 'ssh:target-1' + }) + + expect( + resolveAiVaultSessionWorktreeInfo({ + session: { ...baseSession, cwd: '/srv/orca/src', executionHostId: 'ssh:target-1' }, + worktrees: [localWorktree, sshWorktree], + activeWorktreeId: null + }) + ).toMatchObject({ + label: 'ssh', + worktreeId: sshWorktree.id + }) + }) + + it('uses repo host ownership when a legacy worktree lacks host metadata', () => { + const worktree = makeWorktree({ + id: 'repo-ssh::/srv/orca', + repoId: 'repo-ssh', + displayName: 'ssh', + path: '/srv/orca' + }) + + expect( + resolveAiVaultSessionWorktreeInfo({ + session: { ...baseSession, cwd: '/srv/orca/src', executionHostId: 'ssh:target-1' }, + repos: [makeRepo({ id: 'repo-ssh', connectionId: 'target-1', executionHostId: null })], + worktrees: [worktree], + activeWorktreeId: null + }) + ).toMatchObject({ + label: 'ssh', + worktreeId: worktree.id + }) + }) }) describe('canJumpToAiVaultSessionWorktree', () => { @@ -206,6 +270,21 @@ describe('aiVaultWorktreeCompactPath', () => { }) }) +describe('shouldShowAiVaultSessionWorktreeLine', () => { + it('hides the worktree row for the current worktree in workspace scope', () => { + expect( + shouldShowAiVaultSessionWorktreeLine(makeWorktreeInfo('current'), { vaultScope: 'workspace' }) + ).toBe(false) + expect( + shouldShowAiVaultSessionWorktreeLine(makeWorktreeInfo('current'), { vaultScope: 'all' }) + ).toBe(true) + expect( + shouldShowAiVaultSessionWorktreeLine(makeWorktreeInfo('active'), { vaultScope: 'workspace' }) + ).toBe(true) + expect(shouldShowAiVaultSessionWorktreeLine(null, { vaultScope: 'workspace' })).toBe(false) + }) +}) + describe('shouldShowAiVaultWorktreeStatusBadge', () => { it('hides the generic active badge but keeps meaningful states', () => { expect(shouldShowAiVaultWorktreeStatusBadge('active')).toBe(false) @@ -213,6 +292,12 @@ describe('shouldShowAiVaultWorktreeStatusBadge', () => { expect(shouldShowAiVaultWorktreeStatusBadge('archived')).toBe(true) expect(shouldShowAiVaultWorktreeStatusBadge('unavailable')).toBe(true) }) + + it('hides the current badge in workspace scope', () => { + expect(shouldShowAiVaultWorktreeStatusBadge('current', { vaultScope: 'workspace' })).toBe(false) + expect(shouldShowAiVaultWorktreeStatusBadge('current', { vaultScope: 'all' })).toBe(true) + expect(shouldShowAiVaultWorktreeStatusBadge('archived', { vaultScope: 'workspace' })).toBe(true) + }) }) describe('aiVaultWorktreeJumpTooltip', () => { diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.ts index bd7aeef1bea..670e2deecc1 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.ts @@ -1,15 +1,30 @@ import { useMemo } from 'react' import { parseWslUncPath } from '../../../../shared/wsl-paths' import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree-id' +import { + getRepoExecutionHostId, + LOCAL_EXECUTION_HOST_ID, + normalizeExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' import { isPathInsideOrEqual, isRuntimePathAbsolute, - normalizeRuntimePathForComparison, - normalizeRuntimePathSeparators + normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path' import type { AiVaultSession } from '../../../../shared/ai-vault-types' -import type { Worktree } from '../../../../shared/types' -import { translate } from '@/i18n/i18n' +import type { Repo, Worktree } from '../../../../shared/types' +import { aiVaultWorktreeCompactPath } from './ai-vault-session-worktree-affordances' + +export { + aiVaultWorktreeCompactPath, + aiVaultWorktreeJumpTooltip, + aiVaultWorktreeStatusLabel, + canJumpToAiVaultSessionWorktree, + isAiVaultSessionInCurrentWorktree, + shouldShowAiVaultSessionWorktreeLine, + shouldShowAiVaultWorktreeStatusBadge +} from './ai-vault-session-worktree-affordances' export type AiVaultSessionWorktreeStatus = 'current' | 'active' | 'archived' | 'unavailable' @@ -23,16 +38,19 @@ export type AiVaultSessionWorktreeInfo = { type WorktreeCandidate = { worktree: Worktree path: string + hostId: ExecutionHostId status: Exclude source: 'current-path' | 'prior-path' } export function resolveAiVaultSessionWorktreeInfo({ session, + repos = [], worktrees, activeWorktreeId }: { session: AiVaultSession + repos?: readonly Pick[] worktrees: readonly Worktree[] activeWorktreeId: string | null }): AiVaultSessionWorktreeInfo | null { @@ -40,8 +58,10 @@ export function resolveAiVaultSessionWorktreeInfo({ return null } - const candidates = buildWorktreeCandidates(worktrees) + const sessionHostId = normalizeExecutionHostId(session.executionHostId) + const candidates = buildWorktreeCandidates(worktrees, repos) .filter((candidate) => isSessionInWorktreePath(candidate.path, session.cwd!)) + .filter((candidate) => !sessionHostId || candidate.hostId === sessionHostId) .sort(compareWorktreeCandidates) const best = candidates[0] @@ -85,6 +105,7 @@ export function extractWorktreePathFromSessionTitle(title: string): string | nul export function resolveAiVaultSessionWorktreeDisplay(args: { session: AiVaultSession + repos?: readonly Pick[] worktrees: readonly Worktree[] activeWorktreeId: string | null }): AiVaultSessionWorktreeInfo | null { @@ -117,10 +138,12 @@ export function resolveAiVaultSessionWorktreeDisplay(args: { export function useAiVaultSessionWorktreeMap({ sessions, + repos = [], worktrees, activeWorktreeId }: { sessions: readonly AiVaultSession[] + repos?: readonly Pick[] worktrees: readonly Worktree[] activeWorktreeId: string | null }): ReadonlyMap { @@ -130,75 +153,33 @@ export function useAiVaultSessionWorktreeMap({ sessions.flatMap((session) => { const worktreeInfo = resolveAiVaultSessionWorktreeDisplay({ session, + repos, worktrees, activeWorktreeId }) return worktreeInfo ? [[session.id, worktreeInfo] as const] : [] }) ), - [activeWorktreeId, sessions, worktrees] + [activeWorktreeId, repos, sessions, worktrees] ) } -export function canJumpToAiVaultSessionWorktree( - worktreeInfo: AiVaultSessionWorktreeInfo | null -): boolean { - return Boolean( - worktreeInfo?.worktreeId && - worktreeInfo.status !== 'archived' && - worktreeInfo.status !== 'unavailable' - ) -} - -// Why: a session in the worktree you're already viewing has nowhere to jump, -// so we hide the affordance rather than offering a self-jump (the "Current -// worktree" badge already signals where it lives). -export function isAiVaultSessionInCurrentWorktree( - worktreeInfo: AiVaultSessionWorktreeInfo | null -): boolean { - return worktreeInfo?.status === 'current' -} - -export function aiVaultWorktreeJumpTooltip( - worktreeInfo: AiVaultSessionWorktreeInfo | null -): string { - if (canJumpToAiVaultSessionWorktree(worktreeInfo)) { - return translate( - 'auto.components.right.sidebar.AiVaultSessionWorktree.jumpToWorktree', - 'Jump to Worktree' - ) - } - if (!worktreeInfo) { - return translate( - 'auto.components.right.sidebar.AiVaultSessionWorktree.noRecordedWorktree', - 'No worktree was recorded for this session.' - ) - } - if (worktreeInfo.status === 'archived') { - return translate( - 'auto.components.right.sidebar.AiVaultSessionWorktree.archivedJumpUnavailable', - 'This session is in an archived worktree.' - ) - } - if (worktreeInfo.status === 'unavailable') { - return translate( - 'auto.components.right.sidebar.AiVaultSessionWorktree.noActiveWorktreeMatch', - 'No active worktree matches this session.' - ) - } - return translate( - 'auto.components.right.sidebar.AiVaultSessionWorktree.noActiveWorktreeTarget', - 'No active worktree is available.' - ) -} - -function buildWorktreeCandidates(worktrees: readonly Worktree[]): WorktreeCandidate[] { +function buildWorktreeCandidates( + worktrees: readonly Worktree[], + repos: readonly Pick[] +): WorktreeCandidate[] { const candidates: WorktreeCandidate[] = [] + const repoById = new Map(repos.map((repo) => [repo.id, repo])) for (const worktree of worktrees) { + const repo = repoById.get(worktree.repoId) + const hostId = + normalizeExecutionHostId(worktree.hostId) ?? + (repo ? getRepoExecutionHostId(repo) : LOCAL_EXECUTION_HOST_ID) if (hasUsablePath(worktree.path)) { candidates.push({ worktree, path: worktree.path, + hostId, status: worktree.isArchived ? 'archived' : 'active', source: 'current-path' }) @@ -211,6 +192,7 @@ function buildWorktreeCandidates(worktrees: readonly Worktree[]): WorktreeCandid candidates.push({ worktree, path: parsed.worktreePath, + hostId, status: worktree.isArchived ? 'archived' : 'active', source: 'prior-path' }) @@ -245,21 +227,6 @@ function compareWorktreeCandidates(left: WorktreeCandidate, right: WorktreeCandi return left.source === 'current-path' ? -1 : 1 } -export function aiVaultWorktreeCompactPath(pathValue: string): string { - const parts = normalizeRuntimePathSeparators(pathValue).split('/').filter(Boolean) - if (parts.length >= 2) { - return parts.slice(-2).join('/') - } - return parts[0] ?? pathValue -} - -export function shouldShowAiVaultWorktreeStatusBadge( - status: AiVaultSessionWorktreeStatus -): boolean { - // Why: "active" repeats the branch label without adding scan value in dense rows. - return status !== 'active' -} - function unavailableWorktreeInfo(pathValue: string): AiVaultSessionWorktreeInfo { return { status: 'unavailable', @@ -271,28 +238,3 @@ function unavailableWorktreeInfo(pathValue: string): AiVaultSessionWorktreeInfo function compactPathLabel(pathValue: string): string { return aiVaultWorktreeCompactPath(pathValue) } - -export function aiVaultWorktreeStatusLabel(status: AiVaultSessionWorktreeStatus): string { - if (status === 'current') { - return translate( - 'auto.components.right.sidebar.AiVaultSessionWorktree.currentWorktree', - 'Current worktree' - ) - } - if (status === 'active') { - return translate( - 'auto.components.right.sidebar.AiVaultSessionWorktree.activeWorktree', - 'Active worktree' - ) - } - if (status === 'archived') { - return translate( - 'auto.components.right.sidebar.AiVaultSessionWorktree.archivedWorktree', - 'Archived worktree' - ) - } - return translate( - 'auto.components.right.sidebar.AiVaultSessionWorktree.unavailableWorktree', - 'Unavailable worktree' - ) -} diff --git a/src/renderer/src/components/right-sidebar/checks-panel-content.tsx b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx index 62268e292b6..43ca52db9ea 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-content.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx @@ -94,7 +94,10 @@ import { RightPanelCommentComposer, type RightPanelCommentSubmitResult } from './right-panel-comment-composer' -import { usePRCommentsListSelection } from './pr-comments-list-selection' +import { + usePRCommentsListSelection, + type PRCommentsListSelectionClearRequest +} from './pr-comments-list-selection' import { translate } from '@/i18n/i18n' import { useActiveWorktree } from '@/store/selectors' import { useAppStore } from '@/store' @@ -1609,13 +1612,6 @@ function PRCommentActionBadge({ ) } - if (actionState === 'open') { - return ( - - {translate('auto.components.right.sidebar.checks.panel.content.7c1f0a2b11', 'Open')} - - ) - } if (actionState === 'resolved') { return ( @@ -2178,6 +2174,7 @@ export function PRCommentsList({ commentsDisabled, commentsDisabledReason, selectionContextKey, + selectionClearRequest, resolveCommentsWithAIDisabled, resolveCommentsWithAIDisabledReason, onAddComment, @@ -2193,6 +2190,7 @@ export function PRCommentsList({ commentsDisabled?: boolean commentsDisabledReason?: string selectionContextKey?: string + selectionClearRequest?: PRCommentsListSelectionClearRequest | null resolveCommentsWithAIDisabled?: boolean resolveCommentsWithAIDisabledReason?: string onAddComment?: (body: string) => Promise @@ -2219,7 +2217,7 @@ export function PRCommentsList({ addGroupToSelection, clearSelection, toggleGroupSelection - } = usePRCommentsListSelection(comments, selectionContextKey) + } = usePRCommentsListSelection(comments, selectionContextKey, selectionClearRequest) const visibleComments = React.useMemo( () => filterPRCommentsByAudience(comments, commentFilter), [commentFilter, comments] diff --git a/src/renderer/src/components/right-sidebar/file-explorer-drag-scroll-marker.test.tsx b/src/renderer/src/components/right-sidebar/file-explorer-drag-scroll-marker.test.tsx index ded3aa0cb69..a9d1d9a46f5 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-drag-scroll-marker.test.tsx +++ b/src/renderer/src/components/right-sidebar/file-explorer-drag-scroll-marker.test.tsx @@ -78,6 +78,7 @@ function virtualRowsElement(nodes: TreeNode[]): React.JSX.Element { onDuplicate: vi.fn(), onAddFolderAsProject: vi.fn(), canAddFolderAsProject: () => false, + onOpenInTerminal: vi.fn(), onRequestDelete: vi.fn(), onCollapseFolderSubtree: vi.fn(), onFindInFolder: vi.fn(), diff --git a/src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.test.ts b/src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.test.ts new file mode 100644 index 00000000000..e5bbde86077 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.test.ts @@ -0,0 +1,164 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as React from 'react' +import { ORCA_TERMINAL_COMMAND_FINISHED_EVENT } from '@/hooks/terminal-command-finished-event' + +type WorktreesChangedCallback = (data: { repoId: string }) => void +type HookParams = { + activeRepoId: string | null + activeWorktreeId: string | null + enabled: boolean + fetchStatus: () => void +} + +async function renderHookOnce(params: HookParams): Promise<{ + emitWorktreesChanged: (repoId: string) => void + emitCommandFinished: (worktreeId: string) => void + onChangedSubscribe: ReturnType + windowListeners: Map + cleanups: (() => void)[] +}> { + vi.resetModules() + + const cleanups: (() => void)[] = [] + vi.doMock('react', async () => { + const actual = await vi.importActual('react') + return { + ...actual, + useEffect: (effect: () => void | (() => void)) => { + const cleanup = effect() + if (typeof cleanup === 'function') { + cleanups.push(cleanup) + } + }, + useRef: (initial: T) => ({ current: initial }) + } + }) + + let worktreesChangedCallback: WorktreesChangedCallback | null = null + const onChangedUnsubscribe = vi.fn() + const onChangedSubscribe = vi.fn((callback: WorktreesChangedCallback) => { + worktreesChangedCallback = callback + return onChangedUnsubscribe + }) + const windowListeners = new Map() + + vi.stubGlobal('window', { + api: { + worktrees: { + onChanged: onChangedSubscribe + } + }, + addEventListener: vi.fn((type: string, listener: EventListener) => { + windowListeners.set(type, listener) + }), + removeEventListener: vi.fn((type: string) => { + windowListeners.delete(type) + }) + }) + vi.stubGlobal('document', { + visibilityState: 'visible', + addEventListener: vi.fn(), + removeEventListener: vi.fn() + }) + + const { useGitStatusPushSignalRefresh } = await import('./git-status-push-signal-refresh') + function PushSignalRefreshHarness(props: HookParams): null { + useGitStatusPushSignalRefresh(props) + return null + } + PushSignalRefreshHarness(params) + + return { + emitWorktreesChanged: (repoId: string) => worktreesChangedCallback?.({ repoId }), + emitCommandFinished: (worktreeId: string) => { + const listener = windowListeners.get(ORCA_TERMINAL_COMMAND_FINISHED_EVENT) + listener?.({ detail: { worktreeId } } as unknown as Event) + }, + onChangedSubscribe, + windowListeners, + cleanups + } +} + +describe('useGitStatusPushSignalRefresh', () => { + beforeEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + it('nudges status when the active repo reports a worktrees change', async () => { + const fetchStatus = vi.fn() + const harness = await renderHookOnce({ + activeRepoId: 'repo-1', + activeWorktreeId: 'wt-1', + enabled: true, + fetchStatus + }) + + harness.emitWorktreesChanged('repo-1') + expect(fetchStatus).toHaveBeenCalledTimes(1) + + harness.emitWorktreesChanged('repo-other') + expect(fetchStatus).toHaveBeenCalledTimes(1) + }) + + it('nudges status when a terminal command finishes in the active worktree', async () => { + const fetchStatus = vi.fn() + const harness = await renderHookOnce({ + activeRepoId: 'repo-1', + activeWorktreeId: 'wt-1', + enabled: true, + fetchStatus + }) + + harness.emitCommandFinished('wt-1') + expect(fetchStatus).toHaveBeenCalledTimes(1) + + harness.emitCommandFinished('wt-other') + expect(fetchStatus).toHaveBeenCalledTimes(1) + }) + + it('drops nudges while the window is hidden', async () => { + const fetchStatus = vi.fn() + const harness = await renderHookOnce({ + activeRepoId: 'repo-1', + activeWorktreeId: 'wt-1', + enabled: true, + fetchStatus + }) + + ;(document as unknown as { visibilityState: string }).visibilityState = 'hidden' + harness.emitWorktreesChanged('repo-1') + harness.emitCommandFinished('wt-1') + expect(fetchStatus).not.toHaveBeenCalled() + }) + + it('subscribes to nothing while disabled', async () => { + const fetchStatus = vi.fn() + const harness = await renderHookOnce({ + activeRepoId: 'repo-1', + activeWorktreeId: 'wt-1', + enabled: false, + fetchStatus + }) + + expect(harness.onChangedSubscribe).not.toHaveBeenCalled() + expect(harness.windowListeners.size).toBe(0) + }) + + it('unsubscribes both signals on cleanup', async () => { + const fetchStatus = vi.fn() + const harness = await renderHookOnce({ + activeRepoId: 'repo-1', + activeWorktreeId: 'wt-1', + enabled: true, + fetchStatus + }) + + expect(harness.cleanups.length).toBe(2) + for (const cleanup of harness.cleanups) { + cleanup() + } + expect(harness.windowListeners.size).toBe(0) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.ts b/src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.ts new file mode 100644 index 00000000000..e6e0ea6d87b --- /dev/null +++ b/src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.ts @@ -0,0 +1,65 @@ +import { useEffect, useRef } from 'react' +import { isWindowVisible } from '@/lib/window-visibility-interval' +import { + ORCA_TERMINAL_COMMAND_FINISHED_EVENT, + type TerminalCommandFinishedEventDetail +} from '@/hooks/terminal-command-finished-event' + +type UseGitStatusPushSignalRefreshParams = { + activeRepoId: string | null + activeWorktreeId: string | null + enabled: boolean + fetchStatus: () => void +} + +// Why: these push signals close the latency gap left by the slow terminal-only +// fallback poll — branch switches and commits made inside shells surface at +// the coalescer's floor instead of waiting out the fallback cadence. Bursts +// are safe: the main-process watcher debounces and fetchStatus feeds a +// coalesced runner with a minimum interval. +export function useGitStatusPushSignalRefresh({ + activeRepoId, + activeWorktreeId, + enabled, + fetchStatus +}: UseGitStatusPushSignalRefreshParams): void { + const fetchStatusRef = useRef(fetchStatus) + fetchStatusRef.current = fetchStatus + + useEffect(() => { + if (!enabled || !activeRepoId) { + return + } + // Why: remote web surfaces have no preload bridge; the fallback poll + // still covers them. + const subscribeToWorktreesChanged = window.api?.worktrees?.onChanged + if (!subscribeToWorktreesChanged) { + return + } + // Repo metadata (HEAD/index/worktrees) changed on disk. Hidden windows + // skip the nudge; the visibility interval refreshes immediately on reveal. + return subscribeToWorktreesChanged(({ repoId }) => { + if (repoId !== activeRepoId || !isWindowVisible()) { + return + } + fetchStatusRef.current() + }) + }, [enabled, activeRepoId]) + + useEffect(() => { + if (!enabled || !activeWorktreeId) { + return + } + const handleCommandFinished = (event: Event): void => { + const detail = (event as CustomEvent).detail + if (detail?.worktreeId !== activeWorktreeId || !isWindowVisible()) { + return + } + fetchStatusRef.current() + } + window.addEventListener(ORCA_TERMINAL_COMMAND_FINISHED_EVENT, handleCommandFinished) + return () => { + window.removeEventListener(ORCA_TERMINAL_COMMAND_FINISHED_EVENT, handleCommandFinished) + } + }, [enabled, activeWorktreeId]) +} diff --git a/src/renderer/src/components/right-sidebar/pr-comment-presentation.ts b/src/renderer/src/components/right-sidebar/pr-comment-presentation.ts index dcd72e755a7..6ce3560f6a1 100644 --- a/src/renderer/src/components/right-sidebar/pr-comment-presentation.ts +++ b/src/renderer/src/components/right-sidebar/pr-comment-presentation.ts @@ -41,7 +41,6 @@ export type PRCommentPresentationClasses = { audienceTab: string audienceTabActive: string sectionTriageLabel: string - statusBadgeOpen: string statusBadgeResolved: string statusBadgeQueued: string commentHeaderPrimary: string @@ -160,8 +159,6 @@ export function getPRCommentPresentationClasses( 'flex h-7 items-center justify-center gap-1 rounded-md px-1.5 text-[11px] font-medium text-muted-foreground transition-colors', audienceTabActive: 'bg-muted text-foreground', sectionTriageLabel: cn('px-3 pt-2', RESOLVED_SECTION_LABEL), - statusBadgeOpen: - 'shrink-0 rounded border border-status-success-border bg-status-success-background px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-status-success', statusBadgeResolved: 'shrink-0 rounded border border-border bg-muted px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground', statusBadgeQueued: @@ -211,8 +208,6 @@ export function getPRCommentPresentationClasses( 'flex h-7 items-center justify-center gap-1 rounded-md px-1.5 text-[11px] font-medium text-muted-foreground transition-colors', audienceTabActive: 'bg-muted text-foreground shadow-xs', sectionTriageLabel: cn('px-3 pt-1', RESOLVED_SECTION_LABEL), - statusBadgeOpen: - 'shrink-0 rounded border border-status-success-border bg-status-success-background px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-status-success', statusBadgeResolved: 'shrink-0 rounded border border-border bg-muted px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground', statusBadgeQueued: diff --git a/src/renderer/src/components/right-sidebar/pr-comments-list-selection.test.tsx b/src/renderer/src/components/right-sidebar/pr-comments-list-selection.test.tsx index 69d07fff0a2..61924494157 100644 --- a/src/renderer/src/components/right-sidebar/pr-comments-list-selection.test.tsx +++ b/src/renderer/src/components/right-sidebar/pr-comments-list-selection.test.tsx @@ -45,7 +45,10 @@ vi.mock('@/components/ui/dropdown-menu', () => ({ })) import type { PRComment } from '../../../../shared/types' import type { PRCommentGroup } from '@/lib/pr-comment-groups' -import { clearPRCommentsListSelection } from './pr-comments-list-selection' +import { + clearPRCommentsListSelection, + type PRCommentsListSelectionClearRequest +} from './pr-comments-list-selection' import { PRCommentsList } from './checks-panel-content' let container: HTMLDivElement @@ -79,7 +82,9 @@ function comment(overrides: Partial): PRComment { function renderList(props: { comments: PRComment[] + contextKey?: string onResolveSelectedCommentsWithAI?: (groups: PRCommentGroup[]) => void + clearRequest?: PRCommentsListSelectionClearRequest | null }): void { act(() => { root.render( @@ -87,7 +92,8 @@ function renderList(props: { @@ -297,6 +303,58 @@ describe('PRCommentsList comment resolution selection', () => { expect(container.querySelector('button[role="checkbox"]')).toBeNull() }) + it('clears sent standalone bot comments from the queue when the parent confirms launch', () => { + const comments = [ + comment({ + id: 1, + author: 'coderabbitai', + body: 'Review Change Stack. No actionable comments were generated.', + isBot: true + }) + ] + renderList({ comments }) + clickButton('Queue for agent') + + expect(hasButton('Send 1 queued comments to AI')).toBe(true) + + renderList({ + comments, + clearRequest: { contextKey: 'review:42', token: 1 } + }) + + expect(hasButton('Send 1 queued comments to AI')).toBe(false) + expect(container.querySelector('button[role="checkbox"]')).toBeNull() + }) + + it('keeps a clear request pending until its review context is mounted', () => { + const queuedComments = [ + comment({ + id: 1, + author: 'coderabbitai', + body: 'Review Change Stack. No actionable comments were generated.', + isBot: true + }) + ] + renderList({ comments: queuedComments, contextKey: 'review:42' }) + clickButton('Queue for agent') + + expect(hasButton('Send 1 queued comments to AI')).toBe(true) + + renderList({ + comments: [comment({ id: 2, body: 'Other review comment.' })], + contextKey: 'review:99', + clearRequest: { contextKey: 'review:42', token: 1 } + }) + renderList({ + comments: queuedComments, + contextKey: 'review:42', + clearRequest: { contextKey: 'review:42', token: 1 } + }) + + expect(hasButton('Send 1 queued comments to AI')).toBe(false) + expect(container.querySelector('button[role="checkbox"]')).toBeNull() + }) + it('exits selection mode when refresh leaves no eligible loaded threads', () => { renderList({ comments: [comment({ id: 1, threadId: 'thread-1', path: 'src/a.ts', isResolved: false })] diff --git a/src/renderer/src/components/right-sidebar/pr-comments-list-selection.ts b/src/renderer/src/components/right-sidebar/pr-comments-list-selection.ts index 01bfeddf103..71b51ad5b3f 100644 --- a/src/renderer/src/components/right-sidebar/pr-comments-list-selection.ts +++ b/src/renderer/src/components/right-sidebar/pr-comments-list-selection.ts @@ -65,7 +65,7 @@ export function usePRCommentsListSelection( selectionContextKey: string | undefined, clearRequest?: PRCommentsListSelectionClearRequest | null ): PRCommentsListSelection { - const lastClearRequestTokenRef = useRef(clearRequest?.token ?? null) + const lastClearRequestTokenRef = useRef(null) const [selectionState, setSelectionState] = useState(() => createSelectionState(selectionContextKey) ) @@ -77,13 +77,14 @@ export function usePRCommentsListSelection( }, [selectionContextKey]) useEffect(() => { - if (!clearRequest || clearRequest.token === lastClearRequestTokenRef.current) { + if ( + !clearRequest || + clearRequest.contextKey !== selectionContextKey || + clearRequest.token === lastClearRequestTokenRef.current + ) { return } lastClearRequestTokenRef.current = clearRequest.token - if (clearRequest.contextKey !== selectionContextKey) { - return - } const next = { contextKey: selectionContextKey, isSelectingForAI: false, diff --git a/src/renderer/src/components/right-sidebar/source-control-agent-action-dialog-result.ts b/src/renderer/src/components/right-sidebar/source-control-agent-action-dialog-result.ts index 36de9c82648..5609880477a 100644 --- a/src/renderer/src/components/right-sidebar/source-control-agent-action-dialog-result.ts +++ b/src/renderer/src/components/right-sidebar/source-control-agent-action-dialog-result.ts @@ -4,9 +4,15 @@ import type { useRepoById } from '@/store/selectors' import type { TuiAgent } from '../../../../shared/types' import type { SourceControlAgentActionDeliveryPlanState } from './SourceControlAgentActionDialogForm' +export type SourceControlAgentScopeNote = { + effectiveAgentLabel: string + globalAgentLabel: string +} + export type UseSourceControlAgentActionDialogResult = { handleOpenChange: (nextOpen: boolean) => void shouldRenderDialog: boolean + agentScopeNote: SourceControlAgentScopeNote | null agentOptions: ReturnType selectedAgent: TuiAgent | null hasEnabledAgents: boolean diff --git a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts index 07bff95171e..74853dd4147 100644 --- a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts @@ -5,6 +5,7 @@ import { createPrIntentCurrentTargetConflictsWithToken, createPrIntentGitStatusMatchesToken, createPrIntentRunTokenMatches, + getCreatePrIntentCommitFailureNoticeMessage, getCreatePrIntentStagePaths, resolveCreatePrIntentReviewBase, resolveCreatePrIntentRemoteStep @@ -241,4 +242,23 @@ describe('source-control Create PR intent flow helpers', () => { }) ).toBe('blocked') }) + + it('surfaces the commit failure summary in the Create PR intent notice', () => { + expect( + getCreatePrIntentCommitFailureNoticeMessage( + 'husky - pre-commit hook\neslint found 2 errors\nfull output' + ) + ).toBe('Commit blocked: Lint failed during commit. Fix the issue, then retry Create PR.') + + expect(getCreatePrIntentCommitFailureNoticeMessage(null)).toBe( + 'Could not commit changes. Fix the issue, then retry Create PR.' + ) + + expect( + getCreatePrIntentCommitFailureNoticeMessage('pre-commit hook failed', { + fallback: 'fallback', + withSummary: (summary) => `localized ${summary}` + }) + ).toBe('localized Pre-commit hook failed.') + }) }) diff --git a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.ts b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.ts index 009315fb197..7e183122ec5 100644 --- a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.ts +++ b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.ts @@ -5,6 +5,7 @@ import { normalizeHostedReviewHeadRef } from '../../../../shared/hosted-review-refs' import type { GitStatusEntry, GitUpstreamStatus } from '../../../../shared/types' +import { summarizeCommitFailure } from './commit-failure-summary' import { getStageAllPaths } from './discard-all-sequence' export type CreatePrIntentRemoteStep = 'publish' | 'push' | 'force_push' | 'blocked' | 'none' @@ -145,3 +146,23 @@ export function resolveCreatePrIntentRemoteStep({ return 'none' } + +export function getCreatePrIntentCommitFailureNoticeMessage( + commitError: string | null | undefined, + copy: { + fallback: string + withSummary: (summary: string) => string + } = { + fallback: 'Could not commit changes. Fix the issue, then retry Create PR.', + withSummary: (summary: string) => + `Commit blocked: ${summary} Fix the issue, then retry Create PR.` + } +): string { + const summary = commitError ? summarizeCommitFailure(commitError) : null + + if (!summary) { + return copy.fallback + } + + return copy.withSummary(summary) +} diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerTree.test.ts b/src/renderer/src/components/right-sidebar/useFileExplorerTree.test.ts new file mode 100644 index 00000000000..359a353b6d7 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/useFileExplorerTree.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, it, vi } from 'vitest' +import type { SetStateAction } from 'react' +import type { DirEntry } from '../../../../shared/types' +import type { DirCache } from './file-explorer-types' +import { createFileExplorerDirLoadTracker } from './file-explorer-dir-load-tracker' +import { refreshFileExplorerExpandedDirs } from './useFileExplorerTree' + +type CacheUpdate = SetStateAction> + +function entry(name: string, isDirectory = false): DirEntry { + return { name, isDirectory, isSymlink: false } +} + +describe('refreshFileExplorerExpandedDirs', () => { + it('reloads expanded directories with one loading cache commit and one result cache commit', async () => { + let cache: Record = { + '/repo': { + children: [ + { name: 'old', path: '/repo/old', relativePath: 'old', isDirectory: false, depth: 0 } + ], + loading: false + }, + '/repo/src': { children: [], loading: false }, + '/repo/docs': { children: [], loading: false } + } + const committedCaches: Record[] = [] + const setDirCache = vi.fn((update: CacheUpdate) => { + cache = typeof update === 'function' ? update(cache) : update + committedCaches.push(cache) + }) + const readDirectory = vi.fn(async (dirPath: string) => { + const entriesByPath: Record = { + '/repo/src': [entry('index.ts')], + '/repo/docs': [entry('guide.md')] + } + return entriesByPath[dirPath] ?? [] + }) + + const refreshed = await refreshFileExplorerExpandedDirs({ + dirs: [ + { dirPath: '/repo/src', depth: 0 }, + { dirPath: '/repo/docs', depth: 0 } + ], + worktreePath: '/repo', + dirLoadTracker: createFileExplorerDirLoadTracker(), + setDirCache, + readDirectory + }) + + expect(refreshed).toBe(true) + expect(setDirCache).toHaveBeenCalledTimes(2) + expect(committedCaches[0]).toMatchObject({ + '/repo': { loading: false, children: [{ name: 'old' }] }, + '/repo/src': { loading: true }, + '/repo/docs': { loading: true } + }) + expect(committedCaches[1]).toMatchObject({ + '/repo': { loading: false, children: [{ name: 'old' }] }, + '/repo/src': { + loading: false, + children: [ + { + name: 'index.ts', + path: '/repo/src/index.ts', + relativePath: 'src/index.ts', + isDirectory: false, + depth: 1 + } + ] + }, + '/repo/docs': { + loading: false, + children: [ + { + name: 'guide.md', + path: '/repo/docs/guide.md', + relativePath: 'docs/guide.md', + isDirectory: false, + depth: 1 + } + ] + } + }) + expect(readDirectory).toHaveBeenCalledTimes(2) + }) + + it('drops a superseded directory result so a newer concurrent load is not clobbered', async () => { + const tracker = createFileExplorerDirLoadTracker() + let cache: Record = { + '/repo/src': { children: [], loading: false }, + '/repo/docs': { children: [], loading: false } + } + const setDirCache = vi.fn((update: CacheUpdate) => { + cache = typeof update === 'function' ? update(cache) : update + }) + const newerSrcCache: DirCache = { + loading: true, + children: [ + { + name: 'fresh.ts', + path: '/repo/src/fresh.ts', + relativePath: 'src/fresh.ts', + isDirectory: false, + depth: 1 + } + ] + } + const readDirectory = vi.fn(async (dirPath: string) => { + if (dirPath === '/repo/src') { + // Simulate a concurrent newer load (e.g. a watcher-driven refreshDir) + // superseding this directory while its refresh read is still in flight: + // bump the load token and commit fresher children. + tracker.begin('/repo/src') + setDirCache((prev) => ({ + ...prev, + '/repo/src': newerSrcCache + })) + return [entry('stale.ts')] + } + return [entry('guide.md')] + }) + + const refreshed = await refreshFileExplorerExpandedDirs({ + dirs: [ + { dirPath: '/repo/src', depth: 0 }, + { dirPath: '/repo/docs', depth: 0 } + ], + worktreePath: '/repo', + dirLoadTracker: tracker, + setDirCache, + readDirectory + }) + + // Not every dir was still current, so the refresh reports partial completion. + expect(refreshed).toBe(false) + // The superseded dir keeps the newer load state; the stale read is + // dropped from the batched commit instead of clobbering fresher data. + expect(cache['/repo/src']).toEqual(newerSrcCache) + // The still-current dir is committed normally. + expect(cache['/repo/docs']).toMatchObject({ + loading: false, + children: [{ name: 'guide.md' }] + }) + }) + + it('drops a result superseded after its read resolved but before the batch commit', async () => { + const tracker = createFileExplorerDirLoadTracker() + let cache: Record = { + '/repo/src': { children: [], loading: false }, + '/repo/docs': { children: [], loading: false } + } + const setDirCache = vi.fn((update: CacheUpdate) => { + cache = typeof update === 'function' ? update(cache) : update + }) + let releaseDocs!: () => void + const docsGate = new Promise((resolve) => { + releaseDocs = resolve + }) + const readDirectory = vi.fn(async (dirPath: string) => { + if (dirPath === '/repo/src') { + return [entry('stale.ts')] + } + await docsGate + return [entry('guide.md')] + }) + + const refreshPromise = refreshFileExplorerExpandedDirs({ + dirs: [ + { dirPath: '/repo/src', depth: 0 }, + { dirPath: '/repo/docs', depth: 0 } + ], + worktreePath: '/repo', + dirLoadTracker: tracker, + setDirCache, + readDirectory + }) + + // Let /repo/src resolve (and pass its resolve-time token check) while + // /repo/docs is still in flight — the batch commit is gated on docs. + await new Promise((resolve) => setTimeout(resolve, 0)) + + // A newer load (e.g. a watcher-driven refreshDir) supersedes /repo/src in + // the window between its resolved read and the final batched commit. + tracker.begin('/repo/src') + const newerSrcCache: DirCache = { + loading: false, + children: [ + { + name: 'fresh.ts', + path: '/repo/src/fresh.ts', + relativePath: 'src/fresh.ts', + isDirectory: false, + depth: 1 + } + ] + } + setDirCache((prev) => ({ ...prev, '/repo/src': newerSrcCache })) + + releaseDocs() + const refreshed = await refreshPromise + + expect(refreshed).toBe(false) + // The stale /repo/src read must not clobber the newer committed cache. + expect(cache['/repo/src']).toEqual(newerSrcCache) + expect(cache['/repo/docs']).toMatchObject({ + loading: false, + children: [{ name: 'guide.md' }] + }) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts b/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts index 7481bb4c877..dbbd73a1901 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts @@ -1,17 +1,21 @@ -import type React from 'react' +import type { Dispatch, SetStateAction } from 'react' import { useCallback, useRef, useState } from 'react' import { joinPath, normalizeRelativePath } from '@/lib/path' import { getConnectionId } from '@/lib/connection-context' +import type { DirEntry } from '../../../../shared/types' import type { DirCache, TreeNode } from './file-explorer-types' import { splitPathSegments } from './path-tree' import { shouldIncludeFileExplorerEntry } from './file-explorer-entries' import { readRuntimeDirectory, statRuntimePath } from '@/runtime/runtime-file-client' -import { createFileExplorerDirLoadTracker } from './file-explorer-dir-load-tracker' +import { + createFileExplorerDirLoadTracker, + type FileExplorerDirLoadTracker +} from './file-explorer-dir-load-tracker' import { getRightSidebarWorktreeRuntimeSettings } from './file-explorer-runtime-owner' type UseFileExplorerTreeResult = { dirCache: Record - setDirCache: React.Dispatch>> + setDirCache: Dispatch>> rootCache: DirCache | undefined rootError: string | null loadDir: ( @@ -26,6 +30,139 @@ type UseFileExplorerTreeResult = { resetAndLoad: () => void } +type RefreshFileExplorerTreeDir = { + dirPath: string + depth: number +} + +type RefreshFileExplorerExpandedDirsParams = { + dirs: RefreshFileExplorerTreeDir[] + worktreePath: string + dirLoadTracker: FileExplorerDirLoadTracker + setDirCache: Dispatch>> + readDirectory: (dirPath: string) => Promise +} + +function entriesToTreeNodes( + entries: DirEntry[], + dirPath: string, + depth: number, + worktreePath: string | null +): TreeNode[] { + return entries.filter(shouldIncludeFileExplorerEntry).map((entry) => { + const path = joinPath(dirPath, entry.name) + return { + name: entry.name, + path, + relativePath: worktreePath + ? normalizeRelativePath(path.slice(worktreePath.length + 1)) + : entry.name, + isDirectory: entry.isDirectory, + isSymlink: entry.isSymlink, + depth: depth + 1 + } + }) +} + +async function readWorktreeDirectory( + activeWorktreeId: string | null | undefined, + worktreePath: string | null, + dirPath: string +): Promise { + const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined + return readRuntimeDirectory( + { + settings: getRightSidebarWorktreeRuntimeSettings(activeWorktreeId), + worktreeId: activeWorktreeId, + worktreePath, + connectionId + }, + dirPath + ) +} + +export async function refreshFileExplorerExpandedDirs({ + dirs, + worktreePath, + dirLoadTracker, + setDirCache, + readDirectory +}: RefreshFileExplorerExpandedDirsParams): Promise { + if (dirs.length === 0) { + return true + } + + const uniqueDirs = Array.from(new Map(dirs.map((dir) => [dir.dirPath, dir])).values()) + const loadTokens = new Map( + uniqueDirs.map((dir) => [dir.dirPath, dirLoadTracker.begin(dir.dirPath)]) + ) + + // Why: expanded refresh can touch many directories; commit the loading and + // result states in two batched setDirCache writes (rather than per-directory) + // so refreshing large worktrees stays O(N) instead of O(N²) cache spreads. + setDirCache((prev) => { + const next = { ...prev } + for (const { dirPath } of uniqueDirs) { + next[dirPath] = { + children: prev[dirPath]?.children ?? [], + loading: true + } + } + return next + }) + + const results = await Promise.all( + uniqueDirs.map(async ({ dirPath, depth }) => { + const loadToken = loadTokens.get(dirPath)! + try { + const entries = await readDirectory(dirPath) + if (!dirLoadTracker.isCurrent(loadToken)) { + return { current: false as const } + } + return { + current: true as const, + dirPath, + cache: { + children: entriesToTreeNodes(entries, dirPath, depth, worktreePath), + loading: false + } + } + } catch { + if (!dirLoadTracker.isCurrent(loadToken)) { + return { current: false as const } + } + return { + current: true as const, + dirPath, + cache: { children: [], loading: false } + } + } + }) + ) + + // Why: the batch commits only after the slowest read, so a dir can be + // superseded (watcher refreshDir, worktree reset) after its own read + // resolved. Re-check tokens at commit time so the batched write never + // clobbers a newer load — preserving the old per-dir commit ordering. + const currentResults = results.filter( + (result): result is Extract => + result.current && dirLoadTracker.isCurrent(loadTokens.get(result.dirPath)!) + ) + if (currentResults.length === 0) { + return false + } + + setDirCache((prev) => { + const next = { ...prev } + for (const result of currentResults) { + next[result.dirPath] = result.cache + } + return next + }) + + return currentResults.length === uniqueDirs.length +} + export function useFileExplorerTree( worktreePath: string | null, expanded: Set, @@ -60,34 +197,14 @@ export function useFileExplorerTree( } })) try { - const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined - const entries = await readRuntimeDirectory( - { - settings: getRightSidebarWorktreeRuntimeSettings(activeWorktreeId), - worktreeId: activeWorktreeId, - worktreePath, - connectionId - }, - dirPath - ) + const entries = await readWorktreeDirectory(activeWorktreeId, worktreePath, dirPath) if (!dirLoadTrackerRef.current.isCurrent(loadToken)) { return false } if (depth === -1) { setRootError(null) } - const children: TreeNode[] = entries - .filter(shouldIncludeFileExplorerEntry) - .map((entry) => ({ - name: entry.name, - path: joinPath(dirPath, entry.name), - relativePath: worktreePath - ? normalizeRelativePath(joinPath(dirPath, entry.name).slice(worktreePath.length + 1)) - : entry.name, - isDirectory: entry.isDirectory, - isSymlink: entry.isSymlink, - depth: depth + 1 - })) + const children = entriesToTreeNodes(entries, dirPath, depth, worktreePath) setDirCache((prev) => ({ ...prev, [dirPath]: { children, loading: false } })) return true } catch (error) { @@ -156,13 +273,22 @@ export function useFileExplorerTree( if (!rootLoadCompleted || !dirLoadTrackerRef.current.isSessionCurrent(refreshSession)) { return } - await Promise.all( - Array.from(expanded).map(async (dirPath) => { - const depth = splitPathSegments(dirPath.slice(worktreePath.length + 1)).length - 1 - await loadDir(dirPath, depth, { force: true }) - }) - ) - }, [expanded, loadDir, worktreePath]) + // Why: root (worktreePath) was just force-loaded above; exclude it here so + // refreshFileExplorerExpandedDirs doesn't queue a duplicate read of root. + const expandedDirs = Array.from(expanded) + .filter((dirPath) => dirPath !== worktreePath) + .map((dirPath) => ({ + dirPath, + depth: splitPathSegments(dirPath.slice(worktreePath.length + 1)).length - 1 + })) + await refreshFileExplorerExpandedDirs({ + dirs: expandedDirs, + worktreePath, + dirLoadTracker: dirLoadTrackerRef.current, + setDirCache, + readDirectory: (dirPath) => readWorktreeDirectory(activeWorktreeId, worktreePath, dirPath) + }) + }, [activeWorktreeId, expanded, loadDir, worktreePath]) const refreshDir = useCallback( async (dirPath: string) => { diff --git a/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts b/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts index cfd3aa05aea..5c4d57338a3 100644 --- a/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts +++ b/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts @@ -46,6 +46,7 @@ async function usePollingOnce( sshStatus?: string enabled?: boolean expectStatusCall?: boolean + stateOverrides?: Partial } = {} ): Promise<{ state: PollState; gitStatus: ReturnType }> { vi.resetModules() @@ -67,6 +68,7 @@ async function usePollingOnce( rightSidebarTab: 'source-control', openFiles: [] } + Object.assign(state, options.stateOverrides) const mockedRepo = { ...repo, connectionId: options.connectionId ?? null } const gitStatus = vi.fn().mockResolvedValue(status) @@ -112,6 +114,9 @@ async function usePollingOnce( watchWorktree: vi.fn().mockResolvedValue(undefined), unwatchWorktree: vi.fn().mockResolvedValue(undefined), onFsChanged: vi.fn(() => vi.fn()) + }, + worktrees: { + onChanged: vi.fn(() => vi.fn()) } }, addEventListener: vi.fn(), @@ -226,6 +231,27 @@ describe('useGitStatusPolling', () => { expect(gitStatus).not.toHaveBeenCalled() expect(state.setGitStatus).not.toHaveBeenCalled() + expect(globalThis.setInterval).not.toHaveBeenCalled() + }) + + it('uses a slower git status cadence when only terminal branch detection needs polling', async () => { + const { gitStatus } = await usePollingOnce( + { + entries: [], + conflictOperation: 'unknown', + head: 'abc123', + branch: 'refs/heads/main' + }, + { + stateOverrides: { + rightSidebarOpen: false, + openFiles: [] + } + } + ) + + expect(gitStatus).toHaveBeenCalledTimes(1) + expect(globalThis.setInterval).toHaveBeenCalledWith(expect.any(Function), 30_000) }) it('does not install the visible git status poll while disabled', async () => { @@ -339,6 +365,9 @@ describe('useGitStatusPolling', () => { watchWorktree: vi.fn().mockResolvedValue(undefined), unwatchWorktree: vi.fn().mockResolvedValue(undefined), onFsChanged: vi.fn(() => vi.fn()) + }, + worktrees: { + onChanged: vi.fn(() => vi.fn()) } }, addEventListener: vi.fn((type: string, listener: EventListener) => { @@ -448,6 +477,9 @@ describe('useGitStatusPolling', () => { watchWorktree: vi.fn().mockResolvedValue(undefined), unwatchWorktree: vi.fn().mockResolvedValue(undefined), onFsChanged: vi.fn(() => vi.fn()) + }, + worktrees: { + onChanged: vi.fn(() => vi.fn()) } }, addEventListener: vi.fn((type: string, listener: EventListener) => { @@ -569,6 +601,9 @@ describe('useGitStatusPolling', () => { watchWorktree: vi.fn().mockResolvedValue(undefined), unwatchWorktree: vi.fn().mockResolvedValue(undefined), onFsChanged: vi.fn(() => vi.fn()) + }, + worktrees: { + onChanged: vi.fn(() => vi.fn()) } }, addEventListener: vi.fn((type: string, listener: EventListener) => { @@ -684,6 +719,9 @@ describe('useGitStatusPolling', () => { watchWorktree: vi.fn().mockResolvedValue(undefined), unwatchWorktree: vi.fn().mockResolvedValue(undefined), onFsChanged: vi.fn(() => vi.fn()) + }, + worktrees: { + onChanged: vi.fn(() => vi.fn()) } }, addEventListener: vi.fn(), diff --git a/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts b/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts index 98e74d9f45c..895b69012a1 100644 --- a/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts +++ b/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts @@ -8,11 +8,20 @@ import { getRuntimeGitConflictOperation } from '@/runtime/runtime-git-client' import { refreshGitStatusForWorktree } from './git-status-refresh' import { type CoalescedPollRunner, createCoalescedPollRunner } from './coalesced-poll-runner' import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval' -import { shouldPollActiveGitStatus } from '@/lib/passive-macos-app-data-access' +import { + hasInteractiveActiveGitStatusConsumer, + shouldPollActiveGitStatus +} from '@/lib/passive-macos-app-data-access' import { getRightSidebarWorktreeRuntimeSettings } from './file-explorer-runtime-owner' import { useGitStatusFileWatchRefresh } from './git-status-file-watch-refresh' +import { useGitStatusPushSignalRefresh } from './git-status-push-signal-refresh' -const POLL_INTERVAL_MS = 3000 +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 export function useGitStatusPolling(options: { enabled?: boolean } = {}): void { const enabled = options.enabled ?? true @@ -44,6 +53,29 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void { !connectionId || sshConnectionStates.get(connectionId)?.status === 'connected', [sshConnectionStates] ) + const activeGitStatusPollingArgs = { + activeWorktreeId, + worktreePath, + rightSidebarOpen, + rightSidebarTab, + rightSidebarExplorerView, + openFiles + } + const isActiveConnectionReady = isConnectionReady(activeConnectionId) + const shouldPollActiveWorktreeGitStatus = + enabled && + !!activeWorktreeId && + !!worktreePath && + activeRepoSupportsGit && + shouldPollActiveGitStatus(activeGitStatusPollingArgs) && + isActiveConnectionReady && + !gitStatusHugeByWorktree?.[activeWorktreeId] + const activeStatusPollIntervalMs = 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 @@ -68,34 +100,7 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void { }, [allWorktrees, conflictOperationByWorktree, activeWorktreeId, repoMap]) const runFetchStatus = useCallback(async () => { - if (!enabled) { - return - } - if (!activeWorktreeId || !worktreePath) { - return - } - if ( - !shouldPollActiveGitStatus({ - activeWorktreeId, - worktreePath, - rightSidebarOpen, - rightSidebarTab, - rightSidebarExplorerView, - openFiles - }) || - !activeRepoSupportsGit - ) { - return - } - if (!isConnectionReady(activeConnectionId)) { - return - } - // Why: once a repo's status was truncated at the entry limit, re-running git - // status every 3s just re-does expensive work and re-truncates. Pause the - // automatic poll while huge (a manual refresh still goes through its own - // path); resolving the changes (e.g. .gitignoring the huge folder) clears - // the flag and polling resumes. Mirrors a "huge repo" disabling auto status. - if (gitStatusHugeByWorktree?.[activeWorktreeId]) { + if (!shouldPollActiveWorktreeGitStatus || !activeWorktreeId || !worktreePath) { return } try { @@ -117,18 +122,10 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void { // ignore } }, [ - activeRepoSupportsGit, - activeConnectionId, activePushTarget, activeWorktreeId, - enabled, fetchUpstreamStatus, - gitStatusHugeByWorktree, - isConnectionReady, - openFiles, - rightSidebarExplorerView, - rightSidebarOpen, - rightSidebarTab, + shouldPollActiveWorktreeGitStatus, worktreePath, setGitStatus, setUpstreamStatus, @@ -147,7 +144,7 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void { const statusPollRunnerRef = useRef(null) useEffect(() => { const runner = createCoalescedPollRunner(() => runFetchStatusRef.current(), { - minIntervalMs: POLL_INTERVAL_MS + minIntervalMs: MIN_STATUS_REFRESH_INTERVAL_MS }) statusPollRunnerRef.current = runner return () => { @@ -158,16 +155,19 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void { const fetchStatus = useCallback(() => { statusPollRunnerRef.current?.run() - }, [activeWorktreeId]) + }, []) useEffect(() => { - if (!enabled) { + if (!activeStatusPollScope) { return } // Why: this root-level poll should pause while hidden, but visible // unfocused windows still need fresh status for second-display workflows. - return installWindowVisibilityInterval({ run: fetchStatus, intervalMs: POLL_INTERVAL_MS }) - }, [enabled, fetchStatus]) + return installWindowVisibilityInterval({ + run: fetchStatus, + intervalMs: activeStatusPollIntervalMs + }) + }, [activeStatusPollIntervalMs, activeStatusPollScope, fetchStatus]) useGitStatusFileWatchRefresh({ activeConnectionId, @@ -184,6 +184,13 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void { worktreePath }) + useGitStatusPushSignalRefresh({ + activeRepoId, + activeWorktreeId, + enabled: shouldPollActiveWorktreeGitStatus, + fetchStatus + }) + // 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. @@ -225,7 +232,7 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void { // visible unfocused windows, but do not poll disconnected hidden windows. const stopVisiblePoll = installWindowVisibilityInterval({ run: () => pollRunner.run(), - intervalMs: POLL_INTERVAL_MS + intervalMs: MIN_STATUS_REFRESH_INTERVAL_MS }) return () => { pollRunner.dispose() diff --git a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.ts b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.ts index 7dca868c749..74e7403cd9d 100644 --- a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.ts +++ b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.ts @@ -1,6 +1,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { getAgentCatalog } from '@/lib/agent-catalog' -import { pickSourceControlLaunchAgent } from '@/lib/source-control-launch-agent-selection' +import { + pickSourceControlLaunchAgent, + resolveSourceControlLaunchAgentScope +} from '@/lib/source-control-launch-agent-selection' import { useAppStore } from '@/store' import { useRepoById } from '@/store/selectors' import { renderSourceControlActionCommandTemplate } from '../../../../shared/source-control-ai-actions' @@ -39,6 +42,15 @@ export function useSourceControlAgentActionDialog({ }: SourceControlAgentActionDialogProps): UseSourceControlAgentActionDialogResult { const settings = useAppStore((state) => state.settings) const repo = useRepoById(repoId ?? null) + const launchAgentScope = useMemo( + () => resolveSourceControlLaunchAgentScope({ settings, repo, actionId }), + [actionId, repo, settings] + ) + // Why: when this repo already overrides the global default, default the save + // scope to the repo so saving the corrected agent updates that override in + // place instead of writing a global default the override would still shadow. + const defaultSaveTargetValue = + launchAgentScope.overridesGlobalAgent && repoId ? 'repo' : DEFAULT_SAVE_TARGET_VALUE const ensureDetectedAgents = useAppStore((state) => state.ensureDetectedAgents) const ensureRemoteDetectedAgents = useAppStore((state) => state.ensureRemoteDetectedAgents) const [commandTemplate, setCommandTemplate] = useState( @@ -54,7 +66,7 @@ export function useSourceControlAgentActionDialog({ const [detectedOpenCycle, setDetectedOpenCycle] = useState(null) const saveTargets = useMemo(() => buildSourceControlAgentSaveTargets(repoId), [repoId]) const [saveLaunchRecipe, setSaveLaunchRecipe] = useState(true) - const [saveTargetValue, setSaveTargetValue] = useState(DEFAULT_SAVE_TARGET_VALUE) + const [saveTargetValue, setSaveTargetValue] = useState(defaultSaveTargetValue) const disabledAgents = settings?.disabledTuiAgents const connectionUnavailable = Boolean(worktreeId && connectionId === undefined) @@ -94,7 +106,7 @@ export function useSourceControlAgentActionDialog({ setAgentArgs(savedAgentArgs ?? '') setSelectedAgent(savedAgentId ?? null) setSaveLaunchRecipe(true) - setSaveTargetValue(DEFAULT_SAVE_TARGET_VALUE) + setSaveTargetValue(defaultSaveTargetValue) let stale = false void refreshDetectedAgents().then((nextAgents) => { if (stale || openCycleRef.current !== cycle) { @@ -116,6 +128,7 @@ export function useSourceControlAgentActionDialog({ stale = true } }, [ + defaultSaveTargetValue, disabledAgents, open, refreshDetectedAgents, @@ -191,11 +204,11 @@ export function useSourceControlAgentActionDialog({ if (!nextOpen) { resetDeliveryPlan() setSaveLaunchRecipe(true) - setSaveTargetValue(DEFAULT_SAVE_TARGET_VALUE) + setSaveTargetValue(defaultSaveTargetValue) } onOpenChange(nextOpen) }, - [onOpenChange, resetDeliveryPlan] + [defaultSaveTargetValue, onOpenChange, resetDeliveryPlan] ) const { autoLaunchPending } = useSavedSourceControlAgentActionAutoStart({ @@ -263,9 +276,23 @@ export function useSourceControlAgentActionDialog({ [resetDeliveryPlan] ) + const agentScopeNote = useMemo(() => { + if (!launchAgentScope.overridesGlobalAgent) { + return null + } + const catalog = getAgentCatalog() + const labelFor = (agentId: TuiAgent | null): string => + catalog.find((entry) => entry.id === agentId)?.label ?? agentId ?? '' + return { + effectiveAgentLabel: labelFor(launchAgentScope.effectiveAgentId), + globalAgentLabel: labelFor(launchAgentScope.globalAgentId) + } + }, [launchAgentScope]) + return { handleOpenChange, shouldRenderDialog: !autoLaunchPending, + agentScopeNote, agentOptions, selectedAgent, hasEnabledAgents, diff --git a/src/renderer/src/components/settings/AccountsPane.test.tsx b/src/renderer/src/components/settings/AccountsPane.test.tsx index 0bf97d581ea..3e247f88db5 100644 --- a/src/renderer/src/components/settings/AccountsPane.test.tsx +++ b/src/renderer/src/components/settings/AccountsPane.test.tsx @@ -54,8 +54,8 @@ describe('AccountsPane', () => { it('keeps the runtime label inside the localized account copy', () => { const markup = renderPane(getDefaultSettings('/tmp')) - expect(markup).toContain('Showing accounts for This device. New accounts are added there.') - expect(markup).toContain('authenticate with Google for This device. This uses credentials') + expect(markup).toContain('Showing accounts for this device. New accounts are added there.') + expect(markup).toContain('authenticate with Google for this device. This uses credentials') expect(markup).not.toContain('ShowingThis device') expect(markup).not.toContain('forThis device') }) @@ -65,8 +65,8 @@ describe('AccountsPane', () => { const markup = renderPane(getDefaultSettings('/tmp')) - expect(markup).toContain( - 'Mostrando cuentas para este dispositivo. Las nuevas cuentas se agregan allí.' + expect(markup).toMatch( + /Mostrando cuentas para [Ee]ste dispositivo\. Las nuevas cuentas se agregan allí\./ ) expect(markup).not.toContain('This device') }) diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx index 947ec64c228..993914c89ae 100644 --- a/src/renderer/src/components/settings/AccountsPane.tsx +++ b/src/renderer/src/components/settings/AccountsPane.tsx @@ -13,17 +13,37 @@ import { Badge } from '../ui/badge' import { Button } from '../ui/button' import { Input } from '../ui/input' import { Label } from '../ui/label' +import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover' import { Separator } from '../ui/separator' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' -import { AlertTriangle, Loader2, Plus, RefreshCw, Trash2, X } from 'lucide-react' +import { + AlertTriangle, + ExternalLink, + HelpCircle, + Loader2, + Lock, + LockOpen, + Plus, + RefreshCw, + ShieldCheck, + Trash2, + X +} from 'lucide-react' import { useAppStore } from '../../store' -import { ClaudeIcon, GeminiIcon, OpenAIIcon, OpenCodeGoIcon } from '../status-bar/icons' +import { + ClaudeIcon, + GeminiIcon, + MiniMaxIcon, + OpenAIIcon, + OpenCodeGoIcon +} from '../status-bar/icons' import { toast } from 'sonner' import { getAccountsClaudeSearchEntries, getAccountsCodexSearchEntries, getAccountsGeminiSearchEntries, getAccountsLocationSearchEntries, + getAccountsMiniMaxSearchEntries, getAccountsOpencodeSearchEntries, getAccountsPaneSearchEntries } from './accounts-search' @@ -41,10 +61,73 @@ import { } from '../ui/dialog' import { getCodexAccountAuthWarning } from './codex-account-auth-warning' import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' export { getAccountsPaneSearchEntries } const EMPTY_WSL_DISTROS: string[] = [] +const MINIMAX_CONSOLE_URL = 'https://platform.minimax.io/console/usage' + +function formatMiniMaxRelativeRefresh(updatedAt: number, now: number): string { + const diffMs = Math.max(0, now - updatedAt) + if (diffMs < 60_000) { + return translate('auto.components.settings.AccountsPane.3a30aaf526', 'just now') + } + const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) + const minutes = Math.round(diffMs / 60_000) + if (minutes < 60) { + return formatter.format(-minutes, 'minute') + } + const hours = Math.round(minutes / 60) + if (hours < 24) { + return formatter.format(-hours, 'hour') + } + return formatter.format(-Math.round(hours / 24), 'day') +} + +function MiniMaxCookieHelpPopover(): React.JSX.Element { + const steps = [ + translate( + 'auto.components.settings.AccountsPane.f5d8d2a6a1', + 'Open platform.minimax.io/console/usage in your browser and sign in.' + ), + translate('auto.components.settings.AccountsPane.24560fe830', 'Open DevTools.'), + translate( + 'auto.components.settings.AccountsPane.4cab0fa42d', + 'Go to the Network tab and enable Preserve log.' + ), + translate('auto.components.settings.AccountsPane.bee4e63e1c', 'Reload the page.'), + translate( + 'auto.components.settings.AccountsPane.87f814af6f', + 'Filter for remains and select the coding_plan/remains request.' + ), + translate( + 'auto.components.settings.AccountsPane.435df0ee51', + 'Under Request Headers, copy the Cookie value.' + ), + translate('auto.components.settings.AccountsPane.7492fb3bba', 'Paste it here and click Save.') + ] + return ( +
+
+

+ {translate('auto.components.settings.AccountsPane.9fec52de4b', 'How to copy the cookie')} +

+

+ {translate( + 'auto.components.settings.AccountsPane.4e32e030b2', + 'Stored locally. Orca sends it only to platform.minimax.io for usage refreshes.' + )} +

+
+
    + {steps.map((step) => ( +
  1. {step}
  2. + ))} +
+
+ ) +} type AccountsPaneProps = { settings: GlobalSettings @@ -254,9 +337,13 @@ export function AccountsPane({ const searchQuery = useAppStore((s) => s.settingsSearchQuery) const codexRateLimits = useAppStore((s) => s.rateLimits.codex) const codexRateLimitTarget = useAppStore((s) => s.rateLimits.codexTarget) + const miniMaxRateLimits = useAppStore((s) => s.rateLimits.minimax) const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) const fetchSettings = useAppStore((s) => s.fetchSettings) const recordedOpenCodeSettingEditsRef = useRef>(new Set()) + const [miniMaxCookieDraft, setMiniMaxCookieDraft] = useState('') + const [miniMaxConfigured, setMiniMaxConfigured] = useState(false) + const [miniMaxCredentialBusy, setMiniMaxCredentialBusy] = useState(false) const accountRuntime = getSelectedAccountRuntime( settings, wslSupportedPlatform, @@ -264,6 +351,11 @@ export function AccountsPane({ wslDistros, wslCapabilitiesLoading ) + // Why: host runtime labels are standalone UI labels; interpolated prose needs sentence casing. + const accountRuntimeSentenceLabel = + accountRuntime.runtime === 'host' && !navigator.userAgent.includes('Windows') + ? `${accountRuntime.label.charAt(0).toLocaleLowerCase()}${accountRuntime.label.slice(1)}` + : accountRuntime.label const [codexAccounts, setCodexAccounts] = useState({ accounts: [], @@ -314,6 +406,76 @@ export function AccountsPane({ recordFeatureInteraction('usage-tracking') } + const refreshMiniMaxCredentialStatus = async (): Promise => { + try { + const status = await window.api.minimaxCredentials.getStatus() + setMiniMaxConfigured(status.configured) + } catch (error) { + console.error('Failed to load MiniMax credential status:', error) + } + } + + const saveMiniMaxCookie = async (): Promise => { + if (!miniMaxCookieDraft.trim()) { + toast.error( + translate('auto.components.settings.AccountsPane.2f24f244a4', 'MiniMax cookie is required.') + ) + return + } + setMiniMaxCredentialBusy(true) + try { + const status = await window.api.minimaxCredentials.saveCookie(miniMaxCookieDraft.trim()) + if (!status.configured) { + throw new Error( + translate( + 'auto.components.settings.AccountsPane.8e6f0cb1d8', + 'MiniMax cookie was not saved.' + ) + ) + } + setMiniMaxConfigured(status.configured) + setMiniMaxCookieDraft('') + recordFeatureInteraction('usage-tracking') + toast.success( + translate('auto.components.settings.AccountsPane.8d61637a77', 'MiniMax cookie saved.') + ) + } catch (error) { + toast.error( + translate( + 'auto.components.settings.AccountsPane.b43e761fe5', + 'MiniMax cookie update failed.' + ), + { description: String((error as Error)?.message ?? error) } + ) + } finally { + setMiniMaxCredentialBusy(false) + } + } + + const clearMiniMaxCookie = async (): Promise => { + setMiniMaxCredentialBusy(true) + try { + const status = await window.api.minimaxCredentials.clearCookie() + setMiniMaxConfigured(status.configured) + setMiniMaxCookieDraft('') + recordFeatureInteraction('usage-tracking') + } catch (error) { + toast.error( + translate( + 'auto.components.settings.AccountsPane.b43e761fe5', + 'MiniMax cookie update failed.' + ), + { description: String((error as Error)?.message ?? error) } + ) + } finally { + setMiniMaxCredentialBusy(false) + } + } + + useEffect(() => { + void refreshMiniMaxCredentialStatus() + }, []) + useEffect(() => { let stale = false @@ -611,7 +773,7 @@ export function AccountsPane({ {translate( 'auto.components.settings.AccountsPane.c0a52abfc5', 'Showing accounts for {{value0}}. New accounts are added there.', - { value0: accountRuntime.label } + { value0: accountRuntimeSentenceLabel } )}

@@ -693,7 +855,7 @@ export function AccountsPane({ {translate( 'auto.components.settings.AccountsPane.e05d0ff737', 'Use your current {{value0}} Claude login.', - { value0: accountRuntime.label } + { value0: accountRuntimeSentenceLabel } )} @@ -703,7 +865,7 @@ export function AccountsPane({ {translate( 'auto.components.settings.AccountsPane.3fe7862418', "No managed Claude accounts for {{value0}}. Orca will use that environment's system default Claude login until you add one here.", - { value0: accountRuntime.label } + { value0: accountRuntimeSentenceLabel } )} ) : ( @@ -863,7 +1025,7 @@ export function AccountsPane({ : translate( 'auto.components.settings.AccountsPane.e4a28e8894', 'Codex reported that the {{value0}} login needs a fresh sign-in. Sign in again before starting new Codex sessions.', - { value0: accountRuntime.label } + { value0: accountRuntimeSentenceLabel } )} @@ -877,7 +1039,7 @@ export function AccountsPane({ {translate( 'auto.components.settings.AccountsPane.c0a52abfc5', 'Showing accounts for {{value0}}. New accounts are added there.', - { value0: accountRuntime.label } + { value0: accountRuntimeSentenceLabel } )}

@@ -964,12 +1126,12 @@ export function AccountsPane({ ? translate( 'auto.components.settings.AccountsPane.fd62f37c24', 'Codex reported this {{value0}} login is out of date.', - { value0: accountRuntime.label } + { value0: accountRuntimeSentenceLabel } ) : translate( 'auto.components.settings.AccountsPane.fcc4093fc1', 'Use your current {{value0}} Codex login.', - { value0: accountRuntime.label } + { value0: accountRuntimeSentenceLabel } )} @@ -979,7 +1141,7 @@ export function AccountsPane({ {translate( 'auto.components.settings.AccountsPane.b4c9450319', "No managed Codex accounts for {{value0}}. Orca will use that environment's system default Codex login until you add one here.", - { value0: accountRuntime.label } + { value0: accountRuntimeSentenceLabel } )} ) : ( @@ -1177,7 +1339,7 @@ export function AccountsPane({ {translate( 'auto.components.settings.AccountsPane.c2aee76420', 'Extracts OAuth credentials from your local Gemini CLI installation to authenticate with Google for {{value0}}. This uses credentials issued to the Gemini CLI app, not Orca. May break if Google updates the CLI. Use at your own risk.', - { value0: accountRuntime.label } + { value0: accountRuntimeSentenceLabel } )}

@@ -1346,6 +1508,235 @@ export function AccountsPane({

+ ) : null, + matchesSettingsSearch(searchQuery, getAccountsMiniMaxSearchEntries()) ? ( +
+
+
+

+ + {translate('auto.components.settings.AccountsPane.5d63bbfbec', 'MiniMax')} +

+

+ {translate( + 'auto.components.settings.AccountsPane.15e831350e', + 'Configure MiniMax usage tracking from platform.minimax.io.' + )} +

+
+ + {translate('auto.components.settings.AccountsPane.0d8e77bc40', 'Open console')} + + +
+ +
+ +
+

+ {miniMaxConfigured + ? translate('auto.components.settings.AccountsPane.0b8c1c7e02', 'Stored locally') + : translate('auto.components.settings.AccountsPane.1fd1b1b6b4', 'Cookie not set')} +

+

+ {translate( + 'auto.components.settings.AccountsPane.5e08b0fe57', + 'Stored locally and sent only to platform.minimax.io for usage refreshes.' + )} +

+
+
+ + +
+
+ + + {miniMaxConfigured ? : } + {miniMaxConfigured + ? translate('auto.components.settings.AccountsPane.73ea15f24b', 'Saved') + : translate('auto.components.settings.AccountsPane.23afe8f226', 'Not saved')} + +
+ + + + + + + + +
+
+ setMiniMaxCookieDraft(e.target.value)} + placeholder={translate( + 'auto.components.settings.AccountsPane.b8a4f21c3e', + 'Paste the Cookie header from DevTools' + )} + spellCheck={false} + className="flex-1 text-xs" + /> + + {miniMaxConfigured ? ( + + ) : null} +
+

+ {translate( + 'auto.components.settings.AccountsPane.79418c782a', + 'Open platform.minimax.io/console/usage in your browser, sign in, then copy the Cookie request header from DevTools (Network → any remains request → Cookie).' + )} +

+ {miniMaxConfigured && + miniMaxRateLimits?.status === 'ok' && + miniMaxRateLimits.error === null ? ( +

+ {translate( + 'auto.components.settings.AccountsPane.53f7b8c7a2', + 'Last refresh: {{value0}}', + { value0: formatMiniMaxRelativeRefresh(miniMaxRateLimits.updatedAt, Date.now()) } + )} +

+ ) : null} +

+ {translate( + 'auto.components.settings.AccountsPane.31d24a4e87', + 'Cookie expires when you sign out in the browser.' + )} +

+
+ +
+
+
+

+ {translate('auto.components.settings.AccountsPane.9dd50d3f75', 'Advanced')} +

+

+ {translate( + 'auto.components.settings.AccountsPane.174fb408f9', + 'Leave these defaults alone unless MiniMax usage refresh points at the wrong workspace or model.' + )} +

+
+
+ + + + updateSettings({ minimaxGroupId: e.target.value })} + placeholder={translate( + 'auto.components.settings.AccountsPane.0747d6391a', + 'Use group ID from cookie' + )} + spellCheck={false} + className="text-xs" + /> + + + + + updateSettings({ minimaxUsageModels: e.target.value })} + placeholder={translate('auto.components.settings.AccountsPane.3c92b0d31c', 'general')} + spellCheck={false} + className="text-xs" + /> + +
+
) : null ].filter(Boolean) diff --git a/src/renderer/src/components/settings/AppearancePane.test.tsx b/src/renderer/src/components/settings/AppearancePane.test.tsx index e26cffcccde..714844932f1 100644 --- a/src/renderer/src/components/settings/AppearancePane.test.tsx +++ b/src/renderer/src/components/settings/AppearancePane.test.tsx @@ -6,13 +6,13 @@ import { I18nextProvider } from 'react-i18next' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { i18n } from '@/i18n/i18n' import { getDefaultSettings } from '../../../../shared/constants' -import type { GlobalSettings } from '../../../../shared/types' +import type { GlobalSettings, StatusBarItem } from '../../../../shared/types' const mocks = vi.hoisted(() => ({ state: { availableStatusBarToggles: [] as { description: string - id: 'ports' + id: StatusBarItem keywords: string[] title: string toggleDescription: string @@ -364,6 +364,31 @@ describe('AppearancePane', () => { expect(container.querySelector('button[role="switch"][aria-label="Ports"]')).not.toBeNull() }) + it('records MiniMax status bar toggles as usage tracking interactions', async () => { + mocks.state.availableStatusBarToggles = [ + { + id: 'minimax', + title: 'MiniMax Usage', + description: 'Show MiniMax subscription usage in the status bar.', + toggleDescription: 'Show MiniMax subscription usage for the active workspace.', + keywords: ['status bar', 'minimax', 'usage'] + } + ] + mocks.state.settingsSearchQuery = 'minimax' + const container = await renderAppearancePane(getDefaultSettings('/tmp')) + const miniMaxSwitch = container.querySelector( + 'button[role="switch"][aria-label="MiniMax Usage"]' + ) + + expect(miniMaxSwitch).not.toBeNull() + await act(async () => { + miniMaxSwitch?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(mocks.state.recordFeatureInteraction).toHaveBeenCalledWith('usage-tracking') + expect(mocks.state.toggleStatusBarItem).toHaveBeenCalledWith('minimax') + }) + it('collapses sibling sections so only the Interface section is expanded by default', async () => { mocks.state.settingsSearchQuery = '' const container = await renderAppearancePane(getDefaultSettings('/tmp')) diff --git a/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx b/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx index 065056fbe6f..2173196651a 100644 --- a/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx +++ b/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx @@ -37,7 +37,14 @@ function recordStatusBarToggleInteraction( recordFeatureInteraction('ports') } else if (id === 'ssh') { recordFeatureInteraction('ssh') - } else if (id === 'claude' || id === 'codex' || id === 'gemini' || id === 'opencode-go') { + } else if ( + id === 'claude' || + id === 'codex' || + id === 'gemini' || + id === 'opencode-go' || + id === 'kimi' || + id === 'minimax' + ) { recordFeatureInteraction('usage-tracking') } } diff --git a/src/renderer/src/components/settings/BrowserProfileRow.tsx b/src/renderer/src/components/settings/BrowserProfileRow.tsx index 6c3c06dd3df..0d1c377c45f 100644 --- a/src/renderer/src/components/settings/BrowserProfileRow.tsx +++ b/src/renderer/src/components/settings/BrowserProfileRow.tsx @@ -174,8 +174,11 @@ export function BrowserProfileRow({ browser.profiles.length > 1 ? ( - {translate('auto.components.settings.BrowserProfileRow.7df818977e', 'From')} - {browser.label} + {translate( + 'auto.components.settings.BrowserProfileRow.c5a273a809', + 'From {{value0}}', + { value0: browser.label } + )} @@ -197,8 +200,11 @@ export function BrowserProfileRow({ key={browser.family} onSelect={() => void handleImportFromBrowser(browser.family)} > - {translate('auto.components.settings.BrowserProfileRow.7df818977e', 'From')} - {browser.label} + {translate( + 'auto.components.settings.BrowserProfileRow.c5a273a809', + 'From {{value0}}', + { value0: browser.label } + )} ) )} diff --git a/src/renderer/src/components/settings/BrowserUseCookieImportStep.tsx b/src/renderer/src/components/settings/BrowserUseCookieImportStep.tsx index 5104ace581f..f73668fa908 100644 --- a/src/renderer/src/components/settings/BrowserUseCookieImportStep.tsx +++ b/src/renderer/src/components/settings/BrowserUseCookieImportStep.tsx @@ -116,9 +116,9 @@ export function BrowserUseCookieImportStep({

{translate( 'auto.components.settings.BrowserUsePane.112f70adc4', - 'Last imported from' + 'Last imported from {{value0}}', + { value0: sourceLabel } )} - {sourceLabel}

) : null} {onConfigureMoreBrowsers ? ( @@ -163,8 +163,11 @@ export function BrowserUseCookieImportStep({ browser.profiles.length > 1 ? ( - {translate('auto.components.settings.BrowserUsePane.e44c5d681e', 'From')} - {browser.label} + {translate( + 'auto.components.settings.BrowserUsePane.5301857d88', + 'From {{value0}}', + { value0: browser.label } + )} @@ -186,8 +189,11 @@ export function BrowserUseCookieImportStep({ key={browser.family} onSelect={() => void handleImportFromBrowser(browser.family)} > - {translate('auto.components.settings.BrowserUsePane.e44c5d681e', 'From')} - {browser.label} + {translate( + 'auto.components.settings.BrowserUsePane.5301857d88', + 'From {{value0}}', + { value0: browser.label } + )} ) )} diff --git a/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx b/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx index 2d8ca10c2f7..a4a33f1102a 100644 --- a/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx +++ b/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx @@ -16,6 +16,7 @@ import { SettingsSwitchRow } from './SettingsFormControls' import { translate } from '@/i18n/i18n' +import { RichMarkdownSpellcheckSetting } from './RichMarkdownSpellcheckSetting' export type AutoSaveDelayDraftState = { sourceDelayMs: number @@ -378,6 +379,8 @@ export function GeneralEditorSettingsSection({ /> + + { expect(matchesSettingsSearch('wsl', entries)).toBe(true) }) + it('includes rich Markdown spellcheck keywords', () => { + const entries = getGeneralPaneSearchEntries() + + expect(matchesSettingsSearch('spellcheck', entries)).toBe(true) + expect(matchesSettingsSearch('red underline', entries)).toBe(true) + }) + it('omits the default project runtime setting when Windows runtimes are unsupported', () => { const entries = getGeneralPaneSearchEntries({ includeProjectRuntime: false }) diff --git a/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx b/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx index c67c40f8b73..51668b1ac12 100644 --- a/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx +++ b/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx @@ -7,6 +7,7 @@ import { Button } from '../ui/button' import { SearchableSetting } from './SearchableSetting' import { SettingsSubsectionHeader } from './SettingsFormControls' import { translate } from '@/i18n/i18n' +import { getUpdateCheckClickOptions, getUpdateCheckHint } from '@/lib/update-check-click-options' export function GeneralUpdateSettingsSection(): React.JSX.Element { const updateStatus = useAppStore((s) => s.updateStatus) @@ -36,6 +37,7 @@ export function GeneralUpdateSettingsSection(): React.JSX.Element { } const [appVersion, setAppVersion] = useState(null) + const updateCheckHint = getUpdateCheckHint() useEffect(() => { let cancelled = false @@ -87,14 +89,10 @@ export function GeneralUpdateSettingsSection(): React.JSX.Element { + ) : null} + + + + ) : null} ) })} diff --git a/src/renderer/src/components/settings/RepositorySourceControlAiSection.test.ts b/src/renderer/src/components/settings/RepositorySourceControlAiSection.test.ts index 9715002a997..1153604715e 100644 --- a/src/renderer/src/components/settings/RepositorySourceControlAiSection.test.ts +++ b/src/renderer/src/components/settings/RepositorySourceControlAiSection.test.ts @@ -6,11 +6,16 @@ import type { RepoSourceControlAiOverrides, SourceControlAiSettings } from '../../../../shared/source-control-ai-types' +import { + SOURCE_CONTROL_ACTION_IDS, + type SourceControlActionId +} from '../../../../shared/source-control-ai-actions' import { createRepoAiDraftState, dropRepoLegacyInstructionForAction, resolveRepoAiDraftState } from './RepositorySourceControlAiSection' +import { RepositorySourceControlAiActionRows } from './RepositorySourceControlAiActionRows' import { RepositorySourceControlAiEnablement } from './RepositorySourceControlAiEnablement' describe('RepositorySourceControlAiSection draft state', () => { @@ -98,6 +103,53 @@ describe('RepositorySourceControlAiEnablement', () => { }) }) +describe('RepositorySourceControlAiActionRows', () => { + it('renders save controls on dirty repository action recipes', () => { + const actionDirtyById = Object.fromEntries( + SOURCE_CONTROL_ACTION_IDS.map((actionId) => [actionId, actionId === 'fixCommitFailure']) + ) as Record + const source = { + enabled: true, + agentId: null, + selectedModelByAgent: {}, + selectedThinkingByModel: {}, + customAgentCommand: '', + instructionsByOperation: {} + } as SourceControlAiSettings + + const markup = renderToStaticMarkup( + React.createElement(RepositorySourceControlAiActionRows, { + repoId: 'repo-1', + repoAi: { + actionOverrides: { + fixCommitFailure: { + agentId: null, + commandInputTemplate: '{basePrompt}\n\nrepo' + } + } + }, + source, + defaultTuiAgent: 'codex', + isSaving: false, + actionDirtyById, + onActionModeChange: () => {}, + onActionAgentChange: () => {}, + onActionTemplateChange: () => {}, + onActionAgentArgsChange: () => {}, + onAppendVariable: () => {}, + onActionDiscard: () => {}, + onActionSave: () => {} + }) + ) + + expect(markup).toContain('Commit failure fixes') + expect(markup).toContain('Unsaved changes') + expect(markup).toContain('Discard') + expect(markup).toContain('Save') + expect(markup).toContain('repo-repo-1-source-control-ai-fixCommitFailure') + }) +}) + describe('dropRepoLegacyInstructionForAction', () => { it('prevents legacy text instructions from remigrating after an action override is cleared', () => { const next = dropRepoLegacyInstructionForAction( diff --git a/src/renderer/src/components/settings/RepositorySourceControlAiSection.tsx b/src/renderer/src/components/settings/RepositorySourceControlAiSection.tsx index 6f5f771379c..5568912edd2 100644 --- a/src/renderer/src/components/settings/RepositorySourceControlAiSection.tsx +++ b/src/renderer/src/components/settings/RepositorySourceControlAiSection.tsx @@ -1,16 +1,18 @@ import { useMemo, useState } from 'react' import type React from 'react' -import type { GlobalSettings, Repo, TuiAgent } from '../../../../shared/types' +import type { Repo, TuiAgent } from '../../../../shared/types' import { CUSTOM_AGENT_ID } from '../../../../shared/commit-message-agent-spec' import type { RepoSourceControlAiOverrides } from '../../../../shared/source-control-ai-types' import { normalizeRepoSourceControlAiOverrides, - normalizeSourceControlAiSettings, - resolveSourceControlActionRecipe + normalizeSourceControlAiSettings } from '../../../../shared/source-control-ai' import { toSourceControlAiRepoUpdate } from '../../../../shared/source-control-ai-recipe-save' import type { SourceControlAiRepoUpdate } from '../../../../shared/source-control-ai-recipe-save' -import type { SourceControlActionId } from '../../../../shared/source-control-ai-actions' +import { + SOURCE_CONTROL_ACTION_IDS, + type SourceControlActionId +} from '../../../../shared/source-control-ai-actions' import { Button } from '../ui/button' import { useAppStore } from '../../store' import { useMountedRef } from '@/hooks/useMountedRef' @@ -20,18 +22,21 @@ import { RepositorySourceControlAiCustomCommand } from './RepositorySourceContro import { RepositorySourceControlAiEnablement } from './RepositorySourceControlAiEnablement' import { RepositorySourceControlAiHostedReviewDefaults } from './RepositorySourceControlAiHostedReviewDefaults' import { + buildActionScopedRepoAiSave, createRepoAiDraftState, dropRepoLegacyInstructionForAction, hasOwnActionOverride, normalizeRepoAiDraft, + readCompleteRecipeForDraft, resolveRepoAiDraftState, + serializeActionOverride, serializeRepoAiDraft, + setActionOverride, type RepoAiDraftState } from './repository-source-control-ai-draft' import { ACTION_MODE_INHERIT, DEFAULT_AGENT_VALUE, - completeRepoActionRecipe, readInheritedCommandTemplate } from './repository-source-control-ai-labels' import { getSettingOwnershipSummary } from './setting-ownership' @@ -50,40 +55,6 @@ type RepositorySourceControlAiSectionProps = { type HostedReviewDefaultKey = keyof NonNullable -function readCompleteRecipeForDraft( - current: RepoSourceControlAiOverrides, - settings: GlobalSettings | null, - actionId: SourceControlActionId -): NonNullable< - NonNullable[SourceControlActionId] -> { - const recipe = resolveSourceControlActionRecipe({ - settings, - repo: { sourceControlAi: current }, - actionId - }) - return completeRepoActionRecipe(recipe, actionId) -} - -function setActionOverride( - current: RepoSourceControlAiOverrides, - actionId: SourceControlActionId, - recipe: NonNullable< - NonNullable[SourceControlActionId] - > -): RepoSourceControlAiOverrides { - return dropRepoLegacyInstructionForAction( - { - ...current, - actionOverrides: { - ...current.actionOverrides, - [actionId]: recipe - } - }, - actionId - ) -} - export function RepositorySourceControlAiSection({ repo, updateRepo @@ -130,6 +101,17 @@ export function RepositorySourceControlAiSection({ const draftSerialized = useMemo(() => serializeRepoAiDraft(repoAi), [repoAi]) const isDirty = resolvedDraftState.repoId !== repo.id || draftSerialized !== resolvedDraftState.baseSerialized + const actionDirtyById = useMemo( + () => + Object.fromEntries( + SOURCE_CONTROL_ACTION_IDS.map((actionId) => [ + actionId, + serializeActionOverride(repoAi, actionId) !== + serializeActionOverride(persistedRepoAi, actionId) + ]) + ) as Record, + [persistedRepoAi, repoAi] + ) const updateDraftRepoAi = ( update: (current: RepoSourceControlAiOverrides) => RepoSourceControlAiOverrides @@ -198,6 +180,50 @@ export function RepositorySourceControlAiSection({ setSaveError(null) } + const discardActionDraft = (actionId: SourceControlActionId): void => { + updateDraftRepoAi((current) => { + const nextActionOverrides = { ...current.actionOverrides } + if (hasOwnActionOverride(persistedRepoAi.actionOverrides, actionId)) { + nextActionOverrides[actionId] = persistedRepoAi.actionOverrides?.[actionId] + } else { + delete nextActionOverrides[actionId] + } + return dropRepoLegacyInstructionForAction( + { ...current, actionOverrides: nextActionOverrides }, + actionId + ) + }) + } + + const saveActionDraft = async (actionId: SourceControlActionId): Promise => { + if (isSaving || !actionDirtyById[actionId]) { + return + } + // Why: a per-action Save persists only that action's override (its Discard is + // action-scoped, so its Save must be too) — never other rows' pending edits. + const next = buildActionScopedRepoAiSave(persistedRepoAi, repoAi, actionId) + const repoUpdate = toSourceControlAiRepoUpdate(next) + setIsSaving(true) + setSaveError(null) + try { + const result = await updateRepo(repo.id, repoUpdate) + if (!mountedRef.current) { + return + } + if (result === false) { + setSaveError('Failed to save Source Control AI settings.') + } + } catch { + if (mountedRef.current) { + setSaveError('Failed to save Source Control AI settings.') + } + } finally { + if (mountedRef.current) { + setIsSaving(false) + } + } + } + const updateEnablement = (value: boolean | undefined): void => { updateDraftRepoAi((current) => ({ ...current, enabled: value })) } @@ -364,14 +390,19 @@ export function RepositorySourceControlAiSection({ onChange={updateCustomCommand} /> void saveActionDraft(actionId)} /> + updateSettings: (updates: Partial) => void +} + +export function RichMarkdownSpellcheckSetting({ + settings, + updateSettings +}: RichMarkdownSpellcheckSettingProps): React.JSX.Element { + const enabled = settings.richMarkdownSpellcheckEnabled ?? true + + return ( + + updateSettings({ richMarkdownSpellcheckEnabled: !enabled })} + /> + + ) +} diff --git a/src/renderer/src/components/settings/SourceControlActionRecipeRow.tsx b/src/renderer/src/components/settings/SourceControlActionRecipeRow.tsx index 5eb93a689ac..cb0b4c8dc5a 100644 --- a/src/renderer/src/components/settings/SourceControlActionRecipeRow.tsx +++ b/src/renderer/src/components/settings/SourceControlActionRecipeRow.tsx @@ -32,6 +32,7 @@ type SourceControlActionRecipeRowProps = { baseValue: ActionRecipeDraftValue defaultTuiAgent: GlobalSettings['defaultTuiAgent'] isSavingTemplate: boolean + repoOverrideNote?: React.ReactNode onAgentChange: (actionId: SourceControlActionId, value: string) => void onTemplateChange: (actionId: SourceControlActionId, value: string) => void onAgentArgsChange: (actionId: SourceControlActionId, value: string) => void @@ -57,6 +58,7 @@ export function SourceControlActionRecipeRow({ baseValue, defaultTuiAgent, isSavingTemplate, + repoOverrideNote, onAgentChange, onTemplateChange, onAgentArgsChange, @@ -163,50 +165,53 @@ export function SourceControlActionRecipeRow({ /> -
-

- {templateDirty - ? translate( - 'auto.components.settings.SourceControlAiActionRecipeDefaults.817128d94e', - 'Unsaved changes' - ) - : translate( - 'auto.components.settings.SourceControlAiActionRecipeDefaults.9d3cc627f8', - 'Saved' - )} -

-
- {templateDirty ? ( - - ) : null} - +

+
+ {templateDirty ? ( + + ) : null} + +
diff --git a/src/renderer/src/components/settings/SourceControlActionRepoOverrideNote.test.tsx b/src/renderer/src/components/settings/SourceControlActionRepoOverrideNote.test.tsx new file mode 100644 index 00000000000..095cfc4b550 --- /dev/null +++ b/src/renderer/src/components/settings/SourceControlActionRepoOverrideNote.test.tsx @@ -0,0 +1,106 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TooltipProvider } from '../ui/tooltip' +import { SourceControlActionRepoOverrideNote } from './SourceControlActionRepoOverrideNote' +import type { SourceControlActionRecipeOverrideSummary } from '@/lib/source-control-launch-agent-selection' + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() +}) + +function renderNote( + summary: SourceControlActionRecipeOverrideSummary, + onReviewRepo = vi.fn() +): void { + act(() => { + root.render( + + + + ) + }) +} + +describe('SourceControlActionRepoOverrideNote', () => { + it('renders nothing when no repo overrides the action', () => { + renderNote({ count: 0, overrides: [] }) + expect(container.textContent).toBe('') + }) + + it('shows the singular count when one repo overrides the action', () => { + renderNote({ + count: 1, + overrides: [{ repoId: 'r1', repoName: 'App', fields: ['commandTemplate'] }] + }) + expect(container.textContent).toContain("Global saves won't change 1 repository") + expect(container.textContent).toContain('Review') + }) + + it('shows the plural count when several repos override the action', () => { + renderNote({ + count: 2, + overrides: [ + { repoId: 'r1', repoName: 'App', fields: ['agent'] }, + { repoId: 'r2', repoName: 'Web', fields: ['agentArgs'] } + ] + }) + expect(container.textContent).toContain("Global saves won't change 2 repositories") + expect(container.textContent).toContain('Review first') + }) + + it('shows a "+N more" note when overrides exceed the visible limit', async () => { + renderNote({ + count: 6, + overrides: Array.from({ length: 6 }, (_, index) => ({ + repoId: `r${index}`, + repoName: `Repo ${index}`, + fields: ['commandTemplate'] + })) + }) + + // The overflow note lives in the tooltip content, only mounted once Radix + // opens the tooltip — drive its pointer-move open path and flush the timer. + const trigger = container.querySelector('[data-slot="tooltip-trigger"]') + await act(async () => { + trigger?.dispatchEvent( + new PointerEvent('pointermove', { bubbles: true, pointerType: 'mouse' }) + ) + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + + expect(document.body.textContent).toContain('+1 more') + }) + + it('opens the first repo override from the review action', () => { + const onReviewRepo = vi.fn() + renderNote( + { + count: 2, + overrides: [ + { repoId: 'r1', repoName: 'App', fields: ['agent'] }, + { repoId: 'r2', repoName: 'Web', fields: ['agentArgs'] } + ] + }, + onReviewRepo + ) + + act(() => { + container.querySelector('button')?.click() + }) + + expect(onReviewRepo).toHaveBeenCalledWith('r1') + }) +}) diff --git a/src/renderer/src/components/settings/SourceControlActionRepoOverrideNote.tsx b/src/renderer/src/components/settings/SourceControlActionRepoOverrideNote.tsx new file mode 100644 index 00000000000..ec389927e42 --- /dev/null +++ b/src/renderer/src/components/settings/SourceControlActionRepoOverrideNote.tsx @@ -0,0 +1,135 @@ +import type React from 'react' +import { Info } from 'lucide-react' +import { Button } from '../ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' +import type { + SourceControlActionRecipeOverrideField, + SourceControlActionRecipeOverrideSummary +} from '@/lib/source-control-launch-agent-selection' +import { translate } from '@/i18n/i18n' + +type SourceControlActionRepoOverrideNoteProps = { + summary: SourceControlActionRecipeOverrideSummary + onReviewRepo: (repoId: string) => void +} + +const MAX_VISIBLE_REPOS = 5 + +function getRecipeOverrideFieldLabel(field: SourceControlActionRecipeOverrideField): string { + switch (field) { + case 'agent': + return translate( + 'auto.components.settings.SourceControlActionRepoOverrideNote.agent', + 'Agent' + ) + case 'agentArgs': + return translate( + 'auto.components.settings.SourceControlActionRepoOverrideNote.agentArgs', + 'CLI arguments' + ) + case 'commandTemplate': + return translate( + 'auto.components.settings.SourceControlActionRepoOverrideNote.commandTemplate', + 'Command template' + ) + default: { + // Fail at compile time if the override-field union grows a new variant. + const _exhaustive: never = field + return _exhaustive + } + } +} + +function getRecipeOverrideFieldSummary(fields: SourceControlActionRecipeOverrideField[]): string { + if (fields.length === 0) { + return translate( + 'auto.components.settings.SourceControlActionRepoOverrideNote.recipe', + 'Recipe' + ) + } + return fields.map(getRecipeOverrideFieldLabel).join(', ') +} + +export function SourceControlActionRepoOverrideNote({ + summary, + onReviewRepo +}: SourceControlActionRepoOverrideNoteProps): React.JSX.Element | null { + if (summary.count === 0) { + return null + } + const firstOverride = summary.overrides[0] + if (!firstOverride) { + return null + } + const visibleOverrides = summary.overrides.slice(0, MAX_VISIBLE_REPOS) + const hiddenOverrideCount = Math.max(0, summary.overrides.length - visibleOverrides.length) + const noteText = + summary.count === 1 + ? translate( + 'auto.components.settings.SourceControlActionRepoOverrideNote.singular', + "Global saves won't change 1 repository with its own recipe." + ) + : translate( + 'auto.components.settings.SourceControlActionRepoOverrideNote.plural', + "Global saves won't change {{count}} repositories with their own recipes.", + { count: summary.count } + ) + return ( +
+ + + + + {noteText} + + + +
+

+ {translate( + 'auto.components.settings.SourceControlActionRepoOverrideNote.tooltipTitle', + 'Repository overrides' + )} +

+
    + {visibleOverrides.map((override) => ( +
  • +
    {override.repoName}
    +
    + {getRecipeOverrideFieldSummary(override.fields)} +
    +
  • + ))} +
+ {hiddenOverrideCount > 0 ? ( +

+ {translate( + 'auto.components.settings.SourceControlActionRepoOverrideNote.more', + '+{{count}} more', + { count: hiddenOverrideCount } + )} +

+ ) : null} +
+
+
+ +
+ ) +} diff --git a/src/renderer/src/components/settings/SourceControlAiActionRecipeDefaults.tsx b/src/renderer/src/components/settings/SourceControlAiActionRecipeDefaults.tsx index c54390e2a8d..56906be96d3 100644 --- a/src/renderer/src/components/settings/SourceControlAiActionRecipeDefaults.tsx +++ b/src/renderer/src/components/settings/SourceControlAiActionRecipeDefaults.tsx @@ -1,6 +1,8 @@ +import { useMemo } from 'react' import type React from 'react' import { toast } from 'sonner' import type { GlobalSettings, TuiAgent } from '../../../../shared/types' +import { isFolderRepo } from '../../../../shared/repo-kind' import type { SourceControlAiSettings, SourceControlAiSettingsPatch @@ -13,9 +15,17 @@ import { type SourceControlActionId } from '../../../../shared/source-control-ai-actions' import { Label } from '../ui/label' +import { useAppStore } from '@/store' +import { useRepos } from '@/store/selectors' +import { + summarizeReposOverridingActionRecipe, + type SourceControlActionRecipeOverrideSummary +} from '@/lib/source-control-launch-agent-selection' import { SearchableSetting } from './SearchableSetting' +import { getRepositorySourceControlAiActionRecipeSectionId } from './repository-settings-targets' import { matchesSettingsSearch } from './settings-search' import { SourceControlActionRecipeRow } from './SourceControlActionRecipeRow' +import { SourceControlActionRepoOverrideNote } from './SourceControlActionRepoOverrideNote' import { useSourceControlActionRecipeDraftState } from './source-control-action-recipe-draft-state' import { translate } from '@/i18n/i18n' @@ -84,6 +94,22 @@ export function SourceControlAiActionRecipeDefaults({ searchQuery, writeConfig }: SourceControlAiActionRecipeDefaultsProps): React.JSX.Element | null { + const allRepos = useRepos() + // Recomputes on every keystroke in the sibling recipe textareas otherwise, so + // memoize the filter and the O(actions × repos) override scan on `repos`. + const repos = useMemo(() => allRepos.filter((repo) => !isFolderRepo(repo)), [allRepos]) + const overrideSummaries = useMemo( + () => + Object.fromEntries( + SOURCE_CONTROL_ACTION_IDS.map((actionId) => [ + actionId, + summarizeReposOverridingActionRecipe({ repos, actionId }) + ]) + ) as Record, + [repos] + ) + const openSettingsPage = useAppStore((state) => state.openSettingsPage) + const openSettingsTarget = useAppStore((state) => state.openSettingsTarget) const { actionRecipeDraftState, savingActionTemplateIds, @@ -134,6 +160,18 @@ export function SourceControlAiActionRecipeDefaults({ } } + const openRepoSourceControlAiSettings = ( + repoId: string, + actionId: SourceControlActionId + ): void => { + openSettingsTarget({ + pane: 'repo', + repoId, + sectionId: getRepositorySourceControlAiActionRecipeSectionId(repoId, actionId) + }) + openSettingsPage() + } + if (!config.enabled || !matchesSettingsSearch(searchQuery, ACTION_RECIPES_SEARCH_ENTRY)) { return null } @@ -158,6 +196,7 @@ export function SourceControlAiActionRecipeDefaults({ {SOURCE_CONTROL_ACTION_IDS.map((actionId) => { const recipe = config.actions?.[actionId] const selectedAgent = recipe?.agentId ?? null + const overrideSummary = overrideSummaries[actionId] return ( openRepoSourceControlAiSettings(repoId, actionId)} + /> + } onAgentChange={(id, value) => void onActionAgentChange(id, value)} onTemplateChange={onActionTemplateChange} onAgentArgsChange={onActionAgentArgsChange} diff --git a/src/renderer/src/components/settings/SshPane.tsx b/src/renderer/src/components/settings/SshPane.tsx index cc95ed9caa2..3a00df1d04e 100644 --- a/src/renderer/src/components/settings/SshPane.tsx +++ b/src/renderer/src/components/settings/SshPane.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useState } from 'react' import { toast } from 'sonner' import { Plus, Upload } from 'lucide-react' -import { MAX_SSH_RELAY_GRACE_PERIOD_SECONDS, type SshTarget } from '../../../../shared/ssh-types' +import type { SshTarget } from '../../../../shared/ssh-types' import { SSH_TERMINATE_RECONNECT_REQUIRED } from '../../../../shared/constants' import { useAppStore } from '@/store' import { useMountedRef } from '@/hooks/useMountedRef' @@ -10,12 +10,8 @@ import { removeSshTargetWithBestEffortCleanup } from './ssh-target-remove' import { SshTargetCard } from './SshTargetCard' import { SshTargetDestructiveActions } from './SshTargetDestructiveActions' import { SshTargetForm, EMPTY_FORM, type EditingTarget } from './SshTargetForm' -import { - getEditingTargetForSshTarget, - getSshTargetDraftConnectionFields, - isRelayGracePeriodValid, - parseRelayGracePeriodSeconds -} from './ssh-target-draft' +import { getEditingTargetForSshTarget } from './ssh-target-draft' +import { buildSshTargetSavePayload } from './ssh-target-save-payload' import { translate } from '@/i18n/i18n' export { getSshPaneSearchEntries } from './ssh-search' @@ -77,65 +73,16 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element { }, [loadTargets]) const handleSave = async (): Promise => { - const { host, configHost, username, port } = getSshTargetDraftConnectionFields(form) - if (!host) { - toast.error( - translate( - 'auto.components.settings.SshPane.0e5aa04161', - 'Host or SSH config alias is required' - ) - ) + const savePayload = buildSshTargetSavePayload(form) + if (!savePayload.ok) { + toast.error(savePayload.error) return } - if (isNaN(port) || port < 1 || port > 65535) { - toast.error( - translate('auto.components.settings.SshPane.4db9afce1c', 'Port must be between 1 and 65535') - ) - return - } - - const graceSeconds = parseRelayGracePeriodSeconds(form) - if (!isRelayGracePeriodValid(form, graceSeconds)) { - toast.error( - translate( - 'auto.components.settings.SshPane.3879cbaa52', - 'Terminal timeout must be between 60 and {{value0}} seconds, or keep terminals alive until reset.', - { value0: MAX_SSH_RELAY_GRACE_PERIOD_SECONDS } - ) - ) - return - } - - const identityFile = form.identityFile.trim() || undefined - const proxyCommand = form.proxyCommand.trim() || undefined - const jumpHost = form.jumpHost.trim() || undefined - - const target = { - label: form.label.trim() || (username ? `${username}@${host}` : configHost), - configHost, - host, - port, - username, - relayGracePeriodSeconds: graceSeconds, - ...(identityFile ? { identityFile } : {}), - ...(proxyCommand ? { proxyCommand } : {}), - ...(jumpHost ? { jumpHost } : {}) - } - try { await (editingId - ? // Why: an explicit edit takes ownership of the target, so mark it - // `manual` — otherwise the next ~/.ssh/config sync would silently - // revert the user's change back to the config value. Carry the - // optional fields even when cleared (undefined) so removing e.g. a - // config-derived ProxyCommand actually deletes it — updateTarget - // merges partially, so an omitted key would keep the stale value. - window.api.ssh.updateTarget({ - id: editingId, - updates: { ...target, identityFile, proxyCommand, jumpHost, source: 'manual' } - }) - : window.api.ssh.addTarget({ target })) + ? window.api.ssh.updateTarget({ id: editingId, updates: savePayload.payload.updates }) + : window.api.ssh.addTarget({ target: savePayload.payload.target })) recordFeatureInteraction('ssh') if (!mountedRef.current) { return diff --git a/src/renderer/src/components/settings/SshTargetAdvancedConnectionSection.tsx b/src/renderer/src/components/settings/SshTargetAdvancedConnectionSection.tsx new file mode 100644 index 00000000000..0bdbd3d3505 --- /dev/null +++ b/src/renderer/src/components/settings/SshTargetAdvancedConnectionSection.tsx @@ -0,0 +1,106 @@ +import { Input } from '../ui/input' +import { Label } from '../ui/label' +import { SettingsSwitch } from './SettingsFormControls' +import { SshTargetFormCollapsibleSection } from './SshTargetFormCollapsibleSection' +import type { EditingTarget } from './ssh-target-draft' +import { translate } from '@/i18n/i18n' + +type SshTargetAdvancedConnectionSectionProps = { + open: boolean + onOpenChange: (open: boolean) => void + form: EditingTarget + onFormChange: (updater: (prev: EditingTarget) => EditingTarget) => void +} + +export function SshTargetAdvancedConnectionSection({ + open, + onOpenChange, + form, + onFormChange +}: SshTargetAdvancedConnectionSectionProps): React.JSX.Element { + return ( + +
+ + onFormChange((f) => ({ ...f, proxyCommand: e.target.value }))} + placeholder={translate( + 'auto.components.settings.SshTargetForm.f42d844544', + 'e.g. cloudflared access ssh --hostname %h' + )} + /> +

+ {translate( + 'auto.components.settings.SshTargetForm.3b01ca44a0', + 'Optional. Used for tunneling (e.g. Cloudflare Access, ProxyCommand).' + )} +

+
+
+ + onFormChange((f) => ({ ...f, jumpHost: e.target.value }))} + placeholder={translate( + 'auto.components.settings.SshTargetForm.11bcb4507a', + 'bastion.example.com' + )} + /> +

+ {translate( + 'auto.components.settings.SshTargetForm.feae1d1e69', + 'Optional. Equivalent to ProxyJump / ssh -J.' + )} +

+
+
+
+ +

+ {translate( + 'auto.components.settings.SshTargetForm.53e9aabfc0', + 'Uses OpenSSH multiplexing when available. Turn off for hosts with custom SSH restrictions.' + )} +

+
+ + onFormChange((f) => ({ + ...f, + systemSshConnectionReuse: !f.systemSshConnectionReuse + })) + } + ariaLabel={translate( + 'auto.components.settings.SshTargetForm.8c922dffba', + 'Reuse SSH connection for faster setup' + )} + /> +
+
+ ) +} + +export function hasAdvancedConnectionValues(form: EditingTarget): boolean { + return ( + form.proxyCommand.trim().length > 0 || + form.jumpHost.trim().length > 0 || + !form.systemSshConnectionReuse + ) +} diff --git a/src/renderer/src/components/settings/SshTargetForm.tsx b/src/renderer/src/components/settings/SshTargetForm.tsx index df8cf36c071..fbba5918e50 100644 --- a/src/renderer/src/components/settings/SshTargetForm.tsx +++ b/src/renderer/src/components/settings/SshTargetForm.tsx @@ -1,12 +1,13 @@ +import { useEffect, useRef, useState } from 'react' import { FileKey } from 'lucide-react' -import { - DEFAULT_BOUNDED_SSH_RELAY_GRACE_PERIOD_SECONDS, - MAX_SSH_RELAY_GRACE_PERIOD_SECONDS, - MIN_SSH_RELAY_GRACE_PERIOD_SECONDS -} from '../../../../shared/ssh-types' import { Button } from '../ui/button' import { Input } from '../ui/input' import { Label } from '../ui/label' +import { + SshTargetAdvancedConnectionSection, + hasAdvancedConnectionValues +} from './SshTargetAdvancedConnectionSection' +import { SshTargetTerminalPersistenceSection } from './SshTargetTerminalPersistenceSection' import { applyParsedSshHostInput, type EditingTarget } from './ssh-target-draft' import { translate } from '@/i18n/i18n' export { EMPTY_FORM, type EditingTarget } from './ssh-target-draft' @@ -26,6 +27,20 @@ export function SshTargetForm({ onSave, onCancel }: SshTargetFormProps): React.JSX.Element { + const hasAdvancedConnectionFields = hasAdvancedConnectionValues(form) + const [advancedConnectionOpen, setAdvancedConnectionOpen] = useState(hasAdvancedConnectionFields) + const [terminalPersistenceOpen, setTerminalPersistenceOpen] = useState(true) + const lastEditingIdRef = useRef(editingId) + + useEffect(() => { + if (lastEditingIdRef.current === editingId) { + return + } + lastEditingIdRef.current = editingId + setAdvancedConnectionOpen(hasAdvancedConnectionFields) + setTerminalPersistenceOpen(true) + }, [editingId, hasAdvancedConnectionFields]) + return (
-
- - onFormChange((f) => ({ ...f, proxyCommand: e.target.value }))} - placeholder={translate( - 'auto.components.settings.SshTargetForm.f42d844544', - 'e.g. cloudflared access ssh --hostname %h' - )} - /> -

- {translate( - 'auto.components.settings.SshTargetForm.3b01ca44a0', - 'Optional. Used for tunneling (e.g. Cloudflare Access, ProxyCommand).' - )} -

-
-
- - onFormChange((f) => ({ ...f, jumpHost: e.target.value }))} - placeholder={translate( - 'auto.components.settings.SshTargetForm.11bcb4507a', - 'bastion.example.com' - )} - /> -

- {translate( - 'auto.components.settings.SshTargetForm.feae1d1e69', - 'Optional. Equivalent to ProxyJump / ssh -J.' - )} -

-
-
-
- -

- {translate( - 'auto.components.settings.SshTargetForm.137e88ce8d', - 'Remote terminals keep running after Orca disconnects from this host.' - )} -

-
- -
- - - onFormChange((f) => ({ ...f, relayGracePeriodSeconds: e.target.value })) - } - placeholder={String(DEFAULT_BOUNDED_SSH_RELAY_GRACE_PERIOD_SECONDS)} - min={MIN_SSH_RELAY_GRACE_PERIOD_SECONDS} - max={MAX_SSH_RELAY_GRACE_PERIOD_SECONDS} - disabled={form.relayKeepAliveUntilReset} - /> -
-

- {translate( - 'auto.components.settings.SshTargetForm.1b19b00e93', - 'Bounded timeouts must be between 60 seconds and 7 days.' - )} -

-
+ +
diff --git a/src/renderer/src/components/settings/SshTargetFormCollapsibleSection.tsx b/src/renderer/src/components/settings/SshTargetFormCollapsibleSection.tsx new file mode 100644 index 00000000000..13d9b3e784e --- /dev/null +++ b/src/renderer/src/components/settings/SshTargetFormCollapsibleSection.tsx @@ -0,0 +1,50 @@ +import { ChevronDown } from 'lucide-react' +import { Button } from '../ui/button' +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../ui/collapsible' +import { cn } from '@/lib/utils' + +type SshTargetFormCollapsibleSectionProps = { + open: boolean + onOpenChange: (open: boolean) => void + title: string + description: string + children: React.ReactNode +} + +export function SshTargetFormCollapsibleSection({ + open, + onOpenChange, + title, + description, + children +}: SshTargetFormCollapsibleSectionProps): React.JSX.Element { + return ( + + + + + +
{children}
+
+
+ ) +} diff --git a/src/renderer/src/components/settings/SshTargetTerminalPersistenceSection.tsx b/src/renderer/src/components/settings/SshTargetTerminalPersistenceSection.tsx new file mode 100644 index 00000000000..0306bc3d3d8 --- /dev/null +++ b/src/renderer/src/components/settings/SshTargetTerminalPersistenceSection.tsx @@ -0,0 +1,98 @@ +import { + DEFAULT_BOUNDED_SSH_RELAY_GRACE_PERIOD_SECONDS, + MAX_SSH_RELAY_GRACE_PERIOD_SECONDS, + MIN_SSH_RELAY_GRACE_PERIOD_SECONDS +} from '../../../../shared/ssh-types' +import { Input } from '../ui/input' +import { Label } from '../ui/label' +import { SettingsSwitch } from './SettingsFormControls' +import { SshTargetFormCollapsibleSection } from './SshTargetFormCollapsibleSection' +import type { EditingTarget } from './ssh-target-draft' +import { translate } from '@/i18n/i18n' + +type SshTargetTerminalPersistenceSectionProps = { + open: boolean + onOpenChange: (open: boolean) => void + form: EditingTarget + onFormChange: (updater: (prev: EditingTarget) => EditingTarget) => void +} + +export function SshTargetTerminalPersistenceSection({ + open, + onOpenChange, + form, + onFormChange +}: SshTargetTerminalPersistenceSectionProps): React.JSX.Element { + return ( + +
+
+ +

+ {translate( + 'auto.components.settings.SshTargetForm.b574994adc', + 'Use End Remote Terminals or Reset Relay when you want to stop them.' + )} +

+
+ + onFormChange((f) => ({ + ...f, + relayKeepAliveUntilReset: !f.relayKeepAliveUntilReset + })) + } + ariaLabel={translate( + 'auto.components.settings.SshTargetForm.71fc546097', + 'Keep terminals alive until reset' + )} + /> +
+
+ + onFormChange((f) => ({ ...f, relayGracePeriodSeconds: e.target.value }))} + placeholder={String(DEFAULT_BOUNDED_SSH_RELAY_GRACE_PERIOD_SECONDS)} + min={MIN_SSH_RELAY_GRACE_PERIOD_SECONDS} + max={MAX_SSH_RELAY_GRACE_PERIOD_SECONDS} + disabled={form.relayKeepAliveUntilReset} + /> +

+ {translate( + 'auto.components.settings.SshTargetForm.1b19b00e93', + 'Bounded timeouts must be between 60 seconds and 7 days.' + )} +

+
+
+ ) +} diff --git a/src/renderer/src/components/settings/TerminalThemeSections.lifecycle.test.ts b/src/renderer/src/components/settings/TerminalThemeSections.lifecycle.test.ts index 5e8b643e111..0114ea3ebf4 100644 --- a/src/renderer/src/components/settings/TerminalThemeSections.lifecycle.test.ts +++ b/src/renderer/src/components/settings/TerminalThemeSections.lifecycle.test.ts @@ -8,12 +8,15 @@ vi.mock('react', async () => { const actual = await vi.importActual('react') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() return { ...actual, - useState: (initial: T) => [ - themeTarget ?? initial, - (value: T) => { - themeTarget = value as 'dark' | 'light' - } - ] + useState: (initial: T | (() => T)) => { + const initialValue = typeof initial === 'function' ? (initial as () => T)() : initial + return [ + themeTarget ?? initialValue, + (value: T) => { + themeTarget = value as 'dark' | 'light' + } + ] + } } }) @@ -23,7 +26,10 @@ vi.mock('./TerminalSettingsPreview', () => ({ } })) -import { TerminalThemeCatalogSection } from './TerminalThemeSections' +import { + resetTerminalThemeTargetMemoryForTests, + TerminalThemeCatalogSection +} from './TerminalThemeSections' type ReactElementLike = { type: unknown @@ -50,6 +56,7 @@ const warpThemesMock: UseWarpThemeImportReturn = { function makeSettings(overrides: Partial = {}): GlobalSettings { return { + theme: 'system', terminalUseSeparateLightTheme: false, terminalThemeDark: 'Ghostty Default Style Dark', terminalThemeLight: 'Builtin Tango Light', @@ -64,12 +71,13 @@ function renderCatalog( settings = makeSettings(), updateSettings = vi.fn(), target?: 'dark' | 'light', - preferredTarget?: 'dark' | 'light' + preferredTarget?: 'dark' | 'light', + systemPrefersDark = true ): React.JSX.Element { themeTarget = target return TerminalThemeCatalogSection({ settings, - systemPrefersDark: true, + systemPrefersDark, themeSearch: '', setThemeSearch: () => {}, updateSettings, @@ -167,6 +175,7 @@ function findButtonTexts(node: unknown): string[] { describe('TerminalThemeCatalogSection', () => { beforeEach(() => { themeTarget = 'dark' + resetTerminalThemeTargetMemoryForTests() vi.clearAllMocks() }) @@ -180,6 +189,72 @@ describe('TerminalThemeCatalogSection', () => { ) }) + it('opens on the active light theme when the system appearance is light', () => { + const updateSettings = vi.fn() + const element = renderCatalog( + makeSettings({ terminalUseSeparateLightTheme: true }), + updateSettings, + undefined, + undefined, + false + ) + const picker = findElementByTypeName(element, 'ThemePicker') + const preview = findElementByTypeName(element, 'TerminalSettingsPreview') + const selectTheme = picker?.props?.onSelectTheme as (theme: string) => void + + expect(picker?.props?.selectedTheme).toBe('Builtin Tango Light') + expect(preview?.props?.modeOverride).toBe('light') + + selectTheme('GitHub Light') + + expect(updateSettings).toHaveBeenCalledWith({ terminalThemeLight: 'GitHub Light' }) + }) + + it('reopens the manually edited light target while the active appearance is dark', () => { + const firstOpen = renderCatalog( + makeSettings({ terminalUseSeparateLightTheme: true }), + vi.fn(), + undefined, + undefined, + true + ) + const targetControl = findElementByTypeName(firstOpen, 'SettingsSegmentedControl') + const selectTarget = targetControl?.props?.onChange as (target: 'dark' | 'light') => void + + selectTarget('light') + + const reopened = renderCatalog( + makeSettings({ terminalUseSeparateLightTheme: true }), + vi.fn(), + undefined, + undefined, + true + ) + const picker = findElementByTypeName(reopened, 'ThemePicker') + const preview = findElementByTypeName(reopened, 'TerminalSettingsPreview') + + expect(picker?.props?.selectedTheme).toBe('Builtin Tango Light') + expect(preview?.props?.modeOverride).toBe('light') + }) + + it('opens a customized light-only theme after reload even when the active appearance is dark', () => { + const element = renderCatalog( + makeSettings({ + terminalUseSeparateLightTheme: true, + terminalThemeLight: 'GitHub Light' + }), + vi.fn(), + undefined, + undefined, + true + ) + const picker = findElementByTypeName(element, 'ThemePicker') + const preview = findElementByTypeName(element, 'TerminalSettingsPreview') + + expect(picker?.props?.selectedTheme).toBe('GitHub Light') + expect(preview?.props?.modeOverride).toBe('light') + }) + it('keeps the light target enabled while separate light theme is disabled', () => { const element = renderCatalog(makeSettings({ terminalUseSeparateLightTheme: false }), vi.fn()) const targetControl = findElementByTypeName(element, 'SettingsSegmentedControl') diff --git a/src/renderer/src/components/settings/TerminalThemeSections.tsx b/src/renderer/src/components/settings/TerminalThemeSections.tsx index 9479b5f1738..1fa765bb271 100644 --- a/src/renderer/src/components/settings/TerminalThemeSections.tsx +++ b/src/renderer/src/components/settings/TerminalThemeSections.tsx @@ -12,12 +12,56 @@ import { TerminalSettingsPreview } from './TerminalSettingsPreview' import { WarpThemeImportButton } from './WarpThemeImportButton' import { YamlThemeImportButton } from './YamlThemeImportButton' import type { UseWarpThemeImportReturn } from './useWarpThemeImport' -import { getAvailableTerminalThemeOptions } from '@/lib/terminal-theme' +import { + DEFAULT_TERMINAL_THEME_DARK, + DEFAULT_TERMINAL_THEME_LIGHT, + getAvailableTerminalThemeOptions, + resolveEffectiveTerminalAppearance +} from '@/lib/terminal-theme' import { translate } from '@/i18n/i18n' import { cn } from '@/lib/utils' type TerminalThemeTarget = 'dark' | 'light' +let lastEditedTerminalThemeTarget: TerminalThemeTarget | null = null + +function rememberTerminalThemeTarget(target: TerminalThemeTarget): void { + lastEditedTerminalThemeTarget = target +} + +export function resetTerminalThemeTargetMemoryForTests(): void { + lastEditedTerminalThemeTarget = null +} + +function isCustomizedTheme(themeName: string, defaultThemeName: string): boolean { + const trimmed = themeName.trim() + return trimmed.length > 0 && trimmed !== defaultThemeName +} + +function getInitialTerminalThemeTarget( + settings: GlobalSettings, + systemPrefersDark: boolean, + preferredTarget?: TerminalThemeTarget +): TerminalThemeTarget { + if (preferredTarget) { + return preferredTarget + } + if (lastEditedTerminalThemeTarget) { + return lastEditedTerminalThemeTarget + } + const customizedDark = isCustomizedTheme(settings.terminalThemeDark, DEFAULT_TERMINAL_THEME_DARK) + const customizedLight = isCustomizedTheme( + settings.terminalThemeLight, + DEFAULT_TERMINAL_THEME_LIGHT + ) + if (customizedDark !== customizedLight) { + return customizedDark ? 'dark' : 'light' + } + // Why: the picker edits mode-specific slots. Defaulting to the active + // terminal mode makes the selected theme apply immediately in system-light. + return resolveEffectiveTerminalAppearance(settings, systemPrefersDark).mode +} + type TerminalThemeCatalogSectionProps = { settings: GlobalSettings systemPrefersDark: boolean @@ -45,7 +89,13 @@ export function TerminalThemeCatalogSection({ preferredTarget, advancedContent }: TerminalThemeCatalogSectionProps): React.JSX.Element { - const [target, setTarget] = useState(preferredTarget ?? 'dark') + const [target, setTargetState] = useState(() => + getInitialTerminalThemeTarget(settings, systemPrefersDark, preferredTarget) + ) + const setTarget = (nextTarget: TerminalThemeTarget): void => { + rememberTerminalThemeTarget(nextTarget) + setTargetState(nextTarget) + } const themeOptions = getAvailableTerminalThemeOptions(settings) const isLightTarget = target === 'light' const matchDarkMode = !settings.terminalUseSeparateLightTheme @@ -200,11 +250,12 @@ export function TerminalThemeCatalogSection({ themeOptions={themeOptions} query={themeSearch} onQueryChange={setThemeSearch} - onSelectTheme={(theme) => + onSelectTheme={(theme) => { + rememberTerminalThemeTarget(target) updateSettings( isLightTarget ? { terminalThemeLight: theme } : { terminalThemeDark: theme } ) - } + }} importedHighlightSignal={importedHighlightSignal} /> diff --git a/src/renderer/src/components/settings/WorkspaceDirectorySetting.tsx b/src/renderer/src/components/settings/WorkspaceDirectorySetting.tsx index 70fa7e3eb7f..8633b735a96 100644 --- a/src/renderer/src/components/settings/WorkspaceDirectorySetting.tsx +++ b/src/renderer/src/components/settings/WorkspaceDirectorySetting.tsx @@ -14,6 +14,7 @@ import { Label } from '../ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' import { SearchableSetting } from './SearchableSetting' import { useSidebarHostScopeOptions } from '../sidebar/use-sidebar-host-scope-options' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { buildHostScopeChoices, CLIENT_DEFAULT_SCOPE, @@ -197,7 +198,9 @@ export function WorkspaceDirectorySetting({ }} onBlur={handleBlur} onKeyDown={(e) => { - if (isComposingKeyboardEvent(e)) { + // Why: an Enter that only confirms a CJK IME candidate must not + // commit the rename; wait for a non-composition Enter. + if (isImeCompositionKeyDown(e)) { return } if (e.key === 'Enter') { @@ -270,8 +273,3 @@ export function WorkspaceDirectorySetting({ ) } - -function isComposingKeyboardEvent(event: React.KeyboardEvent): boolean { - const nativeEvent = event.nativeEvent - return nativeEvent.isComposing || nativeEvent.keyCode === 229 -} diff --git a/src/renderer/src/components/settings/accounts-search.test.ts b/src/renderer/src/components/settings/accounts-search.test.ts new file mode 100644 index 00000000000..7958045b8e1 --- /dev/null +++ b/src/renderer/src/components/settings/accounts-search.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +vi.mock('@/i18n/localized-catalog', () => ({ + createLocalizedCatalog: + (loader: () => T) => + () => + loader() +})) + +vi.mock('./settings-search-keywords', () => ({ + translateSearchKeyword: (_key: string, fallback: string) => [fallback] +})) + +import { getAccountsMiniMaxSearchEntries, getAccountsPaneSearchEntries } from './accounts-search' + +describe('getAccountsMiniMaxSearchEntries', () => { + it('returns a single entry that targets the MiniMax session cookie flow', () => { + const entries = getAccountsMiniMaxSearchEntries() + expect(entries).toHaveLength(1) + const [entry] = entries + expect(entry.title).toBe('MiniMax Usage') + expect(entry.description).toContain('platform.minimax.io') + expect(entry.description.toLowerCase()).toContain('cookie') + }) + + it('exposes the keywords that drive the Settings search index', () => { + const [entry] = getAccountsMiniMaxSearchEntries() + // Why: the Settings search needs at least one of these tokens to + // surface the MiniMax section when the user types a related term. + expect(entry.keywords).toEqual( + expect.arrayContaining(['minimax', 'cookie', 'session', 'rate limit', 'status bar']) + ) + }) + + it('is included in the rolled-up pane search entries', () => { + const allEntries = getAccountsPaneSearchEntries() + const titles = allEntries.map((entry) => entry.title) + expect(titles).toContain('MiniMax Usage') + }) +}) diff --git a/src/renderer/src/components/settings/accounts-search.ts b/src/renderer/src/components/settings/accounts-search.ts index 1d4e1564553..7f0d68a8387 100644 --- a/src/renderer/src/components/settings/accounts-search.ts +++ b/src/renderer/src/components/settings/accounts-search.ts @@ -171,10 +171,31 @@ export const getAccountsOpencodeSearchEntries = createLocalizedCatalog(() => [ } ]) +export const getAccountsMiniMaxSearchEntries = createLocalizedCatalog(() => [ + { + title: translate('auto.components.settings.accounts.search.733f9e2a93', 'MiniMax Usage'), + description: translate( + 'auto.components.settings.accounts.search.f8374c3151', + 'Paste your platform.minimax.io session cookie for local rate-limit fetching.' + ), + keywords: [ + ...translateSearchKeyword('auto.components.settings.accounts.search.d16378a88f', 'minimax'), + ...translateSearchKeyword('auto.components.settings.accounts.search.61f7d1fcbe', 'cookie'), + ...translateSearchKeyword('auto.components.settings.accounts.search.9c4e40cf6b', 'session'), + ...translateSearchKeyword( + 'auto.components.settings.accounts.search.e949b08ffb', + 'rate limit' + ), + ...translateSearchKeyword('auto.components.settings.accounts.search.86edc96bc9', 'status bar') + ] + } +]) + export const getAccountsPaneSearchEntries = createLocalizedCatalog((): SettingsSearchEntry[] => [ ...getAccountsLocationSearchEntries(), ...getAccountsClaudeSearchEntries(), ...getAccountsCodexSearchEntries(), ...getAccountsGeminiSearchEntries(), - ...getAccountsOpencodeSearchEntries() + ...getAccountsOpencodeSearchEntries(), + ...getAccountsMiniMaxSearchEntries() ]) diff --git a/src/renderer/src/components/settings/appearance-status-bar-search.test.ts b/src/renderer/src/components/settings/appearance-status-bar-search.test.ts new file mode 100644 index 00000000000..18a218aca4c --- /dev/null +++ b/src/renderer/src/components/settings/appearance-status-bar-search.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +vi.mock('@/i18n/localized-catalog', () => ({ + createLocalizedCatalog: + (loader: () => T) => + () => + loader() +})) + +vi.mock('./settings-search-keywords', () => ({ + translateSearchKeyword: (_key: string, fallback: string) => [fallback] +})) + +import { getStatusBarToggles } from './appearance-status-bar-search' + +describe('getStatusBarToggles', () => { + it('includes MiniMax usage so Appearance can toggle the default-on status item', () => { + const miniMaxToggle = getStatusBarToggles().find((entry) => entry.id === 'minimax') + + expect(miniMaxToggle).toMatchObject({ + title: 'MiniMax Usage', + description: 'Show MiniMax subscription usage in the status bar.', + toggleDescription: 'Show MiniMax subscription usage for the active workspace.' + }) + expect(miniMaxToggle?.keywords).toEqual( + expect.arrayContaining(['status bar', 'minimax', 'usage', 'subscription', 'cookie']) + ) + }) +}) diff --git a/src/renderer/src/components/settings/appearance-status-bar-search.ts b/src/renderer/src/components/settings/appearance-status-bar-search.ts index c97f198da24..673deda4b54 100644 --- a/src/renderer/src/components/settings/appearance-status-bar-search.ts +++ b/src/renderer/src/components/settings/appearance-status-bar-search.ts @@ -161,6 +161,41 @@ export const getStatusBarToggles = createLocalizedCatalog( 'Show Kimi subscription usage for the active workspace.' ) }, + { + id: 'minimax', + title: translate('auto.components.settings.appearance.search.0f08f6b483', 'MiniMax Usage'), + description: translate( + 'auto.components.settings.appearance.search.e46178eb1b', + 'Show MiniMax subscription usage in the status bar.' + ), + keywords: [ + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.896eb53fd4', + 'status bar' + ), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.d16378a88f', + 'minimax' + ), + ...translateSearchKeyword('auto.components.settings.appearance.search.00a028f25f', 'usage'), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.de586def95', + 'subscription' + ), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.d9e7cef86f', + 'cookie' + ), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.25e51b62ee', + 'rate limit' + ) + ], + toggleDescription: translate( + 'settings.appearance.statusBar.minimaxToggleDescription', + 'Show MiniMax subscription usage for the active workspace.' + ) + }, { id: 'ssh', title: translate('auto.components.settings.appearance.search.57fb424c56', 'Remote Hosts'), diff --git a/src/renderer/src/components/settings/browser-cookie-import-label.test.ts b/src/renderer/src/components/settings/browser-cookie-import-label.test.ts new file mode 100644 index 00000000000..108a2e4c8d2 --- /dev/null +++ b/src/renderer/src/components/settings/browser-cookie-import-label.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it } from 'vitest' + +import en from '@/i18n/locales/en.json' +import { i18n, translate } from '@/i18n/i18n' + +// Keys for the "From " import-cookies menu entries. The label is built +// by interpolating the browser name into a single localized string; a regression +// where the name was concatenated directly onto "From" dropped the space and +// rendered e.g. "FromGoogle Chrome". +const IMPORT_FROM_KEYS = [ + 'auto.components.settings.BrowserProfileRow.c5a273a809', + 'auto.components.settings.BrowserUsePane.5301857d88' +] +const LAST_IMPORTED_FROM_KEY = 'auto.components.settings.BrowserUsePane.112f70adc4' + +function catalogEntry(key: string): unknown { + return key.split('.').reduce((node, part) => { + if (typeof node === 'object' && node !== null) { + return (node as Record)[part] + } + return undefined + }, en) +} + +describe('import-cookies "From " label', () => { + beforeEach(async () => { + await i18n.changeLanguage('en') + }) + + it('keeps a space between "From" and the browser name', () => { + for (const key of IMPORT_FROM_KEYS) { + expect(translate(key, 'From {{value0}}', { value0: 'Google Chrome' })).toBe( + 'From Google Chrome' + ) + expect(translate(key, 'From {{value0}}', { value0: 'Safari' })).toBe('From Safari') + } + }) + + it('stores the catalog entry with an interpolation placeholder', () => { + for (const key of IMPORT_FROM_KEYS) { + expect(catalogEntry(key)).toBe('From {{value0}}') + } + expect(catalogEntry(LAST_IMPORTED_FROM_KEY)).toBe('Last imported from {{value0}}') + }) + + it('keeps a space in the last-imported source label', () => { + expect( + translate(LAST_IMPORTED_FROM_KEY, 'Last imported from {{value0}}', { + value0: 'Google Chrome' + }) + ).toBe('Last imported from Google Chrome') + }) +}) diff --git a/src/renderer/src/components/settings/general-editor-search.ts b/src/renderer/src/components/settings/general-editor-search.ts index 0a13d7c5dd8..8f93eea9544 100644 --- a/src/renderer/src/components/settings/general-editor-search.ts +++ b/src/renderer/src/components/settings/general-editor-search.ts @@ -79,6 +79,29 @@ export const getGeneralEditorSearchEntries = createLocalizedCatalog(() => [ ...translateSearchKeyword('auto.components.settings.general.search.a0014961ae', 'scroll') ] }, + { + title: translate( + 'auto.components.settings.general.search.d2d2d929c0', + 'Rich Markdown Spellcheck' + ), + description: translate( + 'auto.components.settings.general.search.4497e2e2bb', + 'Show browser spelling underlines and suggestions while editing rich Markdown.' + ), + keywords: [ + ...translateSearchKeyword('auto.components.settings.general.search.f96cdaf37d', 'spellcheck'), + ...translateSearchKeyword( + 'auto.components.settings.general.search.a51d23f4a1', + 'spell check' + ), + ...translateSearchKeyword('auto.components.settings.general.search.641358460a', 'spelling'), + ...translateSearchKeyword('auto.components.settings.general.search.d05f629d2c', 'markdown'), + ...translateSearchKeyword( + 'auto.components.settings.general.search.d755962089', + 'red underline' + ) + ] + }, { title: translate('auto.components.settings.general.search.128bc09325', 'Markdown Review Notes'), description: translate( diff --git a/src/renderer/src/components/settings/repository-icon-github.test.ts b/src/renderer/src/components/settings/repository-icon-github.test.ts new file mode 100644 index 00000000000..d4ddecc22a5 --- /dev/null +++ b/src/renderer/src/components/settings/repository-icon-github.test.ts @@ -0,0 +1,163 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../../../shared/types' +import { + buildRepositoryGitHubAvatarUpdate, + resolveRepositoryGitHubAvatar +} from './repository-icon-github' + +vi.mock('@/runtime/runtime-rpc-client', () => ({ + callRuntimeRpc: vi.fn() +})) + +const apiMocks = { + repoSlug: vi.fn(), + repoUpstream: vi.fn() +} + +// @ts-expect-error test window mock +globalThis.window = { api: { gh: apiMocks } } + +function makeRepo(overrides: Partial = {}): Repo { + return { + id: 'repo-1', + path: '/workspace/orca', + displayName: 'orca', + badgeColor: '#2563eb', + addedAt: 1, + kind: 'git', + ...overrides + } +} + +describe('repository GitHub avatar resolution', () => { + beforeEach(() => { + apiMocks.repoSlug.mockReset() + apiMocks.repoUpstream.mockReset() + }) + + it('uses stored upstream by default to avoid unnecessary live checks', async () => { + const repo = makeRepo({ upstream: { owner: 'stablyai', repo: 'orca' } }) + + await expect(resolveRepositoryGitHubAvatar({ kind: 'local' }, repo)).resolves.toEqual({ + repoIcon: { + type: 'image', + src: 'https://github.com/stablyai.png?size=64', + source: 'github', + label: 'stablyai/orca' + }, + upstream: { owner: 'stablyai', repo: 'orca' } + }) + + expect(apiMocks.repoUpstream).not.toHaveBeenCalled() + expect(apiMocks.repoSlug).not.toHaveBeenCalled() + }) + + it('force-resolves the live origin owner when a non-fork repo was transferred', async () => { + // Non-fork repo (upstream resolved to null) transferred stablyai -> parkerrex. + // The cached avatar is stale; forceLive must consult the live origin slug. + const repo = makeRepo({ + upstream: null, + repoIcon: { + type: 'image', + src: 'https://github.com/stablyai.png?size=64', + source: 'github', + label: 'stablyai/orca' + } + }) + apiMocks.repoUpstream.mockResolvedValueOnce(null) + apiMocks.repoSlug.mockResolvedValueOnce({ owner: 'parkerrex', repo: 'orca' }) + + const resolution = await resolveRepositoryGitHubAvatar({ kind: 'local' }, repo, { + forceLive: true + }) + + expect(resolution).toEqual({ + repoIcon: { + type: 'image', + src: 'https://github.com/parkerrex.png?size=64', + source: 'github', + label: 'parkerrex/orca' + }, + upstream: null + }) + expect(apiMocks.repoUpstream).toHaveBeenCalledExactlyOnceWith({ + repoPath: '/workspace/orca', + repoId: 'repo-1' + }) + expect(apiMocks.repoSlug).toHaveBeenCalledExactlyOnceWith({ + repoPath: '/workspace/orca', + repoId: 'repo-1' + }) + // upstream stays null (unchanged); only the avatar advances to the new owner. + expect(buildRepositoryGitHubAvatarUpdate(repo, resolution)).toEqual({ + repoIcon: { + type: 'image', + src: 'https://github.com/parkerrex.png?size=64', + source: 'github', + label: 'parkerrex/orca' + } + }) + }) + + it('does not clear a GitHub avatar on passive refresh when live slug is unavailable', async () => { + const repo = makeRepo({ + repoIcon: { + type: 'image', + src: 'https://github.com/stablyai.png?size=64', + source: 'github', + label: 'stablyai/orca' + } + }) + + expect(buildRepositoryGitHubAvatarUpdate(repo, { repoIcon: null, upstream: null })).toEqual({ + upstream: null + }) + expect( + buildRepositoryGitHubAvatarUpdate( + repo, + { repoIcon: null, upstream: null }, + { + clearMissingIcon: true + } + ) + ).toEqual({ + upstream: null, + repoIcon: null + }) + }) + + it('preserves a known fork identity when the live upstream lookup fails', async () => { + // A fork whose avatar tracks its parent org. The live upstream probe fails + // (offline/unauthed → null), which must NOT downgrade to the origin slug. + const repo = makeRepo({ + upstream: { owner: 'stablyai', repo: 'orca' }, + repoIcon: { + type: 'image', + src: 'https://github.com/stablyai.png?size=64', + source: 'github', + label: 'stablyai/orca' + } + }) + apiMocks.repoUpstream.mockResolvedValueOnce(null) + // The fork's own origin owner — the value we must NOT persist over the parent. + apiMocks.repoSlug.mockResolvedValueOnce({ owner: 'parkerrex', repo: 'orca' }) + + const resolution = await resolveRepositoryGitHubAvatar({ kind: 'local' }, repo, { + forceLive: true + }) + + expect(resolution).toEqual({ + repoIcon: { + type: 'image', + src: 'https://github.com/stablyai.png?size=64', + source: 'github', + label: 'stablyai/orca' + }, + upstream: { owner: 'stablyai', repo: 'orca' } + }) + // The origin slug must never be consulted once we fall back to the known parent. + expect(apiMocks.repoSlug).not.toHaveBeenCalled() + // Nothing changed, so no repo write is produced (no sticky null clobber). + expect(buildRepositoryGitHubAvatarUpdate(repo, resolution)).toBeNull() + }) +}) diff --git a/src/renderer/src/components/settings/repository-icon-github.ts b/src/renderer/src/components/settings/repository-icon-github.ts index 10546f676f2..02f962fb670 100644 --- a/src/renderer/src/components/settings/repository-icon-github.ts +++ b/src/renderer/src/components/settings/repository-icon-github.ts @@ -3,40 +3,116 @@ import { githubAvatarIcon, type RepoIcon } from '../../../../shared/repo-icon' import { callRuntimeRpc, type getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' type RuntimeTarget = ReturnType +type ResolveRepositoryGitHubAvatarOptions = { + forceLive?: boolean +} -export async function resolveRepositoryUpstreamLive( +export type RepositoryGitHubAvatarResolution = { + repoIcon: RepoIcon | null + upstream: GitHubRepositoryIdentity | null +} + +function resolveRepositoryIdentityLive( runtimeTarget: RuntimeTarget, - repo: Repo + repo: Repo, + method: 'github.repoUpstream' | 'github.repoSlug', + localCall: (args: { + repoPath: string + repoId: string + }) => Promise ): Promise { return runtimeTarget.kind === 'environment' - ? await callRuntimeRpc( + ? callRuntimeRpc( runtimeTarget, - 'github.repoUpstream', + method, { repo: repo.id }, { timeoutMs: 30_000 } ) - : await window.api.gh.repoUpstream({ repoPath: repo.path, repoId: repo.id }) + : localCall({ repoPath: repo.path, repoId: repo.id }) } -export async function resolveRepositoryGitHubAvatarIcon( +export function resolveRepositoryUpstreamLive( runtimeTarget: RuntimeTarget, repo: Repo -): Promise { +): Promise { + return resolveRepositoryIdentityLive(runtimeTarget, repo, 'github.repoUpstream', (args) => + window.api.gh.repoUpstream(args) + ) +} + +function resolveRepositorySlugLive( + runtimeTarget: RuntimeTarget, + repo: Repo +): Promise { + return resolveRepositoryIdentityLive(runtimeTarget, repo, 'github.repoSlug', (args) => + window.api.gh.repoSlug(args) + ) +} + +export async function resolveRepositoryGitHubAvatar( + runtimeTarget: RuntimeTarget, + repo: Repo, + options: ResolveRepositoryGitHubAvatarOptions = {} +): Promise { const upstream = - repo.upstream !== undefined + !options.forceLive && repo.upstream !== undefined ? repo.upstream : await resolveRepositoryUpstreamLive(runtimeTarget, repo).catch(() => null) if (upstream) { - return githubAvatarIcon(upstream) + return { repoIcon: githubAvatarIcon(upstream), upstream } } - const slug = - runtimeTarget.kind === 'environment' - ? await callRuntimeRpc<{ owner: string; repo: string } | null>( - runtimeTarget, - 'github.repoSlug', - { repo: repo.id }, - { timeoutMs: 30_000 } - ) - : await window.api.gh.repoSlug({ repoPath: repo.path, repoId: repo.id }) - return slug ? githubAvatarIcon(slug) : null + // Why: a null live upstream is ambiguous (offline/unauthed vs. not-a-fork). Keep + // the last-known parent avatar so a transient failure can't clobber fork identity. + if (repo.upstream) { + return { repoIcon: githubAvatarIcon(repo.upstream), upstream: repo.upstream } + } + const slug = await resolveRepositorySlugLive(runtimeTarget, repo) + return { repoIcon: slug ? githubAvatarIcon(slug) : null, upstream: null } +} + +function sameRepositoryIdentity( + a: GitHubRepositoryIdentity | null | undefined, + b: GitHubRepositoryIdentity | null | undefined +): boolean { + if (!a || !b) { + return a === b + } + return a.owner === b.owner && a.repo === b.repo +} + +function sameRepoIcon(a: RepoIcon | null | undefined, b: RepoIcon | null | undefined): boolean { + if (!a || !b) { + return a === b + } + if (a.type !== b.type) { + return false + } + if (a.type === 'image' && b.type === 'image') { + return a.src === b.src && a.source === b.source && a.label === b.label + } + if (a.type === 'emoji' && b.type === 'emoji') { + return a.emoji === b.emoji + } + return a.type === 'lucide' && b.type === 'lucide' && a.name === b.name +} + +export function buildRepositoryGitHubAvatarUpdate( + repo: Repo, + resolution: RepositoryGitHubAvatarResolution, + options: { clearMissingIcon?: boolean } = {} +): Partial | null { + const updates: Partial = {} + + if (!sameRepositoryIdentity(repo.upstream, resolution.upstream)) { + updates.upstream = resolution.upstream + } + + if ( + (resolution.repoIcon || options.clearMissingIcon) && + !sameRepoIcon(repo.repoIcon, resolution.repoIcon) + ) { + updates.repoIcon = resolution.repoIcon + } + + return Object.keys(updates).length > 0 ? updates : null } diff --git a/src/renderer/src/components/settings/repository-settings-targets.ts b/src/renderer/src/components/settings/repository-settings-targets.ts index 2ab4fd8d5d5..32bcd1f4510 100644 --- a/src/renderer/src/components/settings/repository-settings-targets.ts +++ b/src/renderer/src/components/settings/repository-settings-targets.ts @@ -1,3 +1,5 @@ +import type { SourceControlActionId } from '../../../../shared/source-control-ai-actions' + export function getRepositoryLocalCommandsSectionId(repoId: string): string { return `repo-${repoId}-local-commands` } @@ -9,3 +11,10 @@ export function getRepositoryIconSectionId(repoId: string): string { export function getRepositorySourceControlAiSectionId(repoId: string): string { return `repo-${repoId}-source-control-ai` } + +export function getRepositorySourceControlAiActionRecipeSectionId( + repoId: string, + actionId: SourceControlActionId +): string { + return `repo-${repoId}-source-control-ai-${actionId}` +} diff --git a/src/renderer/src/components/settings/repository-source-control-ai-draft.test.ts b/src/renderer/src/components/settings/repository-source-control-ai-draft.test.ts new file mode 100644 index 00000000000..1972afde88b --- /dev/null +++ b/src/renderer/src/components/settings/repository-source-control-ai-draft.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' +import type { RepoSourceControlAiOverrides } from '../../../../shared/source-control-ai-types' +import { buildActionScopedRepoAiSave } from './repository-source-control-ai-draft' + +describe('buildActionScopedRepoAiSave', () => { + it('persists only the target action, keeping siblings at their saved values', () => { + const persisted: RepoSourceControlAiOverrides = { + actionOverrides: { + resolveConflicts: { + agentId: 'claude', + commandInputTemplate: '{basePrompt}', + agentArgs: '' + }, + fixCommitFailure: { agentId: 'codex', commandInputTemplate: '{basePrompt}', agentArgs: '' } + } + } + const draft: RepoSourceControlAiOverrides = { + actionOverrides: { + resolveConflicts: { + agentId: 'codex', + commandInputTemplate: '{basePrompt}\n\nedited', + agentArgs: '' + }, + // A half-finished edit on a different row that must NOT be flushed. + fixCommitFailure: { + agentId: 'gemini', + commandInputTemplate: '{basePrompt}\n\nunsaved', + agentArgs: '' + } + } + } + + const next = buildActionScopedRepoAiSave(persisted, draft, 'resolveConflicts') + + expect(next.actionOverrides?.resolveConflicts?.agentId).toBe('codex') + expect(next.actionOverrides?.resolveConflicts?.commandInputTemplate).toBe( + '{basePrompt}\n\nedited' + ) + // Sibling keeps the persisted value, not the draft's pending edit. + expect(next.actionOverrides?.fixCommitFailure?.agentId).toBe('codex') + expect(next.actionOverrides?.fixCommitFailure?.commandInputTemplate).toBe('{basePrompt}') + }) + + it('removes the action override when the draft inherits it', () => { + const persisted: RepoSourceControlAiOverrides = { + actionOverrides: { + resolveConflicts: { agentId: 'claude', commandInputTemplate: '{basePrompt}', agentArgs: '' } + } + } + const draft: RepoSourceControlAiOverrides = { actionOverrides: {} } + + const next = buildActionScopedRepoAiSave(persisted, draft, 'resolveConflicts') + + expect(next.actionOverrides?.resolveConflicts).toBeUndefined() + }) +}) diff --git a/src/renderer/src/components/settings/repository-source-control-ai-draft.ts b/src/renderer/src/components/settings/repository-source-control-ai-draft.ts index 8eb2875a477..a085821f23f 100644 --- a/src/renderer/src/components/settings/repository-source-control-ai-draft.ts +++ b/src/renderer/src/components/settings/repository-source-control-ai-draft.ts @@ -1,8 +1,17 @@ -import { normalizeRepoSourceControlAiOverrides } from '../../../../shared/source-control-ai' +import { + normalizeRepoSourceControlAiOverrides, + resolveSourceControlActionRecipe +} from '../../../../shared/source-control-ai' import type { SourceControlActionId } from '../../../../shared/source-control-ai-actions' import type { RepoSourceControlAiOverrides } from '../../../../shared/source-control-ai-types' +import type { GlobalSettings } from '../../../../shared/types' +import { completeRepoActionRecipe } from './repository-source-control-ai-labels' import { SOURCE_CONTROL_TEXT_ACTION_ID_SET } from './source-control-action-recipe-options' +type RepoActionRecipe = NonNullable< + NonNullable[SourceControlActionId] +> + export type RepoAiDraftState = { repoId: string value: RepoSourceControlAiOverrides @@ -93,3 +102,66 @@ export function dropRepoLegacyInstructionForAction( Object.keys(instructionsByOperation).length > 0 ? instructionsByOperation : undefined } } + +export function readCompleteRecipeForDraft( + current: RepoSourceControlAiOverrides, + settings: GlobalSettings | null, + actionId: SourceControlActionId +): RepoActionRecipe { + const recipe = resolveSourceControlActionRecipe({ + settings, + repo: { sourceControlAi: current }, + actionId + }) + return completeRepoActionRecipe(recipe, actionId) +} + +export function setActionOverride( + current: RepoSourceControlAiOverrides, + actionId: SourceControlActionId, + recipe: RepoActionRecipe +): RepoSourceControlAiOverrides { + return dropRepoLegacyInstructionForAction( + { + ...current, + actionOverrides: { + ...current.actionOverrides, + [actionId]: recipe + } + }, + actionId + ) +} + +export function serializeActionOverride( + value: RepoSourceControlAiOverrides, + actionId: SourceControlActionId +): string { + return JSON.stringify({ + hasOverride: hasOwnActionOverride(value.actionOverrides, actionId), + recipe: value.actionOverrides?.[actionId] ?? null + }) +} + +/** + * Layer only one action's draft override onto the last-saved repo settings, so a + * per-action save persists that recipe without flushing other rows' edits. + */ +export function buildActionScopedRepoAiSave( + persisted: RepoSourceControlAiOverrides, + draft: RepoSourceControlAiOverrides, + actionId: SourceControlActionId +): RepoSourceControlAiOverrides { + const nextActionOverrides = { ...persisted.actionOverrides } + if (hasOwnActionOverride(draft.actionOverrides, actionId)) { + nextActionOverrides[actionId] = draft.actionOverrides?.[actionId] + } else { + delete nextActionOverrides[actionId] + } + return normalizeRepoAiDraft( + dropRepoLegacyInstructionForAction( + { ...persisted, actionOverrides: nextActionOverrides }, + actionId + ) + ) +} diff --git a/src/renderer/src/components/settings/setting-labels.ts b/src/renderer/src/components/settings/setting-labels.ts index cdc459d9790..0f108fadeae 100644 --- a/src/renderer/src/components/settings/setting-labels.ts +++ b/src/renderer/src/components/settings/setting-labels.ts @@ -4,6 +4,7 @@ export const SETTING_LABELS: Partial> = { terminalFontSize: 'Font Size', terminalFontFamily: 'Font Family', terminalFontWeight: 'Font Weight', + terminalLineHeight: 'Line Height', terminalScrollSensitivity: 'Normal Scroll Speed', terminalFastScrollSensitivity: 'Fast Scroll Speed', terminalTuiScrollSensitivity: 'TUI Scroll Speed', diff --git a/src/renderer/src/components/settings/ssh-target-draft.test.ts b/src/renderer/src/components/settings/ssh-target-draft.test.ts index 8be60068d6f..5c988b29638 100644 --- a/src/renderer/src/components/settings/ssh-target-draft.test.ts +++ b/src/renderer/src/components/settings/ssh-target-draft.test.ts @@ -157,6 +157,7 @@ describe('getSshTargetDraftConnectionFields', () => { describe('getEditingTargetForSshTarget', () => { it('defaults new SSH targets to keep terminals alive until reset', () => { expect(EMPTY_FORM.relayKeepAliveUntilReset).toBe(true) + expect(EMPTY_FORM.relayGracePeriodSeconds).toBe('86400') expect(parseRelayGracePeriodSeconds(EMPTY_FORM)).toBe(0) }) @@ -202,6 +203,19 @@ describe('getEditingTargetForSshTarget', () => { }) }) + it('preserves explicit system SSH connection reuse opt-outs while editing', () => { + const draft = getEditingTargetForSshTarget({ + id: 'ssh-1', + label: 'Restricted appliance', + host: 'appliance.example.com', + port: 22, + username: 'admin', + systemSshConnectionReuse: false + }) + + expect(draft.systemSshConnectionReuse).toBe(false) + }) + it('uses the default persistence for targets without an explicit grace period', () => { const draft = getEditingTargetForSshTarget({ id: 'ssh-1', @@ -212,6 +226,21 @@ describe('getEditingTargetForSshTarget', () => { }) expect(draft.relayKeepAliveUntilReset).toBe(true) + expect(draft.relayGracePeriodSeconds).toBe('86400') expect(parseRelayGracePeriodSeconds(draft)).toBe(0) }) + + it('preserves explicit bounded relay grace periods when editing', () => { + const draft = getEditingTargetForSshTarget({ + id: 'ssh-1', + label: 'Bounded server', + host: 'server.example.com', + port: 22, + username: 'deploy', + relayGracePeriodSeconds: 600 + }) + + expect(draft.relayKeepAliveUntilReset).toBe(false) + expect(draft.relayGracePeriodSeconds).toBe('600') + }) }) diff --git a/src/renderer/src/components/settings/ssh-target-draft.ts b/src/renderer/src/components/settings/ssh-target-draft.ts index bac2dc100a3..a33e6eb5abc 100644 --- a/src/renderer/src/components/settings/ssh-target-draft.ts +++ b/src/renderer/src/components/settings/ssh-target-draft.ts @@ -15,6 +15,7 @@ export type EditingTarget = { identityFile: string proxyCommand: string jumpHost: string + systemSshConnectionReuse: boolean relayGracePeriodSeconds: string relayKeepAliveUntilReset: boolean } @@ -28,6 +29,7 @@ export const EMPTY_FORM: EditingTarget = { identityFile: '', proxyCommand: '', jumpHost: '', + systemSshConnectionReuse: true, relayGracePeriodSeconds: String(DEFAULT_BOUNDED_SSH_RELAY_GRACE_PERIOD_SECONDS), relayKeepAliveUntilReset: DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS === 0 } @@ -45,6 +47,7 @@ export function getEditingTargetForSshTarget(target: SshTarget): EditingTarget { identityFile: target.identityFile ?? '', proxyCommand: target.proxyCommand ?? '', jumpHost: target.jumpHost ?? '', + systemSshConnectionReuse: target.systemSshConnectionReuse !== false, relayGracePeriodSeconds: String( target.relayGracePeriodSeconds === 0 ? DEFAULT_BOUNDED_SSH_RELAY_GRACE_PERIOD_SECONDS diff --git a/src/renderer/src/components/settings/ssh-target-save-payload.test.ts b/src/renderer/src/components/settings/ssh-target-save-payload.test.ts new file mode 100644 index 00000000000..11ee0c4888e --- /dev/null +++ b/src/renderer/src/components/settings/ssh-target-save-payload.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest' +import { EMPTY_FORM } from './ssh-target-draft' +import { buildSshTargetSavePayload } from './ssh-target-save-payload' + +describe('buildSshTargetSavePayload', () => { + it('rejects empty hosts', () => { + const result = buildSshTargetSavePayload({ ...EMPTY_FORM, host: '' }) + + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.error).toContain('Host or SSH config alias is required') + } + }) + + it('omits default SSH connection reuse from new targets but clears it on update', () => { + const result = buildSshTargetSavePayload({ + ...EMPTY_FORM, + label: 'Production', + host: 'prod.example.com', + username: 'deploy', + port: '2202' + }) + + expect(result.ok).toBe(true) + if (!result.ok) { + throw new Error(result.error) + } + expect(result.payload.target).toMatchObject({ + label: 'Production', + configHost: 'prod.example.com', + host: 'prod.example.com', + port: 2202, + username: 'deploy', + relayGracePeriodSeconds: 0 + }) + expect(result.payload.target).not.toHaveProperty('systemSshConnectionReuse') + expect(result.payload.updates).toMatchObject({ + source: 'manual', + identityFile: undefined, + proxyCommand: undefined, + jumpHost: undefined, + systemSshConnectionReuse: undefined + }) + }) + + it('persists explicit SSH connection reuse opt-outs and bounded relay timeouts', () => { + const result = buildSshTargetSavePayload({ + ...EMPTY_FORM, + host: 'appliance.example.com', + username: 'admin', + identityFile: '~/.ssh/appliance', + proxyCommand: 'cloudflared access ssh --hostname %h', + jumpHost: 'bastion.example.com', + systemSshConnectionReuse: false, + relayKeepAliveUntilReset: false, + relayGracePeriodSeconds: '600' + }) + + expect(result.ok).toBe(true) + if (!result.ok) { + throw new Error(result.error) + } + expect(result.payload.target).toMatchObject({ + label: 'admin@appliance.example.com', + host: 'appliance.example.com', + relayGracePeriodSeconds: 600, + identityFile: '~/.ssh/appliance', + proxyCommand: 'cloudflared access ssh --hostname %h', + jumpHost: 'bastion.example.com', + systemSshConnectionReuse: false + }) + expect(result.payload.updates).toMatchObject({ + source: 'manual', + systemSshConnectionReuse: false + }) + }) + + it('rejects invalid bounded relay timeouts', () => { + const result = buildSshTargetSavePayload({ + ...EMPTY_FORM, + host: 'appliance.example.com', + relayKeepAliveUntilReset: false, + relayGracePeriodSeconds: '59' + }) + + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.error).toContain('Terminal timeout') + } + }) +}) diff --git a/src/renderer/src/components/settings/ssh-target-save-payload.ts b/src/renderer/src/components/settings/ssh-target-save-payload.ts new file mode 100644 index 00000000000..e12a7a9df92 --- /dev/null +++ b/src/renderer/src/components/settings/ssh-target-save-payload.ts @@ -0,0 +1,87 @@ +import { MAX_SSH_RELAY_GRACE_PERIOD_SECONDS, type SshTarget } from '../../../../shared/ssh-types' +import { + getSshTargetDraftConnectionFields, + isRelayGracePeriodValid, + parseRelayGracePeriodSeconds, + type EditingTarget +} from './ssh-target-draft' +import { translate } from '../../i18n/i18n' + +type SshTargetSavePayload = { + target: Omit + updates: Partial> +} + +type SshTargetSavePayloadResult = + | { ok: true; payload: SshTargetSavePayload } + | { ok: false; error: string } + +export function buildSshTargetSavePayload(form: EditingTarget): SshTargetSavePayloadResult { + const { host, configHost, username, port } = getSshTargetDraftConnectionFields(form) + if (!host) { + return { + ok: false, + error: translate( + 'auto.components.settings.SshPane.0e5aa04161', + 'Host or SSH config alias is required' + ) + } + } + + if (isNaN(port) || port < 1 || port > 65535) { + return { + ok: false, + error: translate( + 'auto.components.settings.SshPane.4db9afce1c', + 'Port must be between 1 and 65535' + ) + } + } + + const graceSeconds = parseRelayGracePeriodSeconds(form) + if (!isRelayGracePeriodValid(form, graceSeconds)) { + return { + ok: false, + error: translate( + 'auto.components.settings.SshPane.3879cbaa52', + 'Terminal timeout must be between 60 and {{value0}} seconds, or keep terminals alive until reset.', + { value0: MAX_SSH_RELAY_GRACE_PERIOD_SECONDS } + ) + } + } + + const identityFile = form.identityFile.trim() || undefined + const proxyCommand = form.proxyCommand.trim() || undefined + const jumpHost = form.jumpHost.trim() || undefined + const systemSshConnectionReuse = form.systemSshConnectionReuse ? undefined : false + + const target: Omit = { + label: form.label.trim() || (username ? `${username}@${host}` : configHost), + configHost, + host, + port, + username, + relayGracePeriodSeconds: graceSeconds, + ...(identityFile ? { identityFile } : {}), + ...(proxyCommand ? { proxyCommand } : {}), + ...(jumpHost ? { jumpHost } : {}), + ...(systemSshConnectionReuse === false ? { systemSshConnectionReuse } : {}) + } + + return { + ok: true, + payload: { + target, + updates: { + ...target, + // Why: updateTarget merges partially, so explicit undefined values are + // required to clear optional fields inherited from ~/.ssh/config. + identityFile, + proxyCommand, + jumpHost, + systemSshConnectionReuse, + source: 'manual' + } + } + } +} diff --git a/src/renderer/src/components/sidebar/AddRepoDialog.tsx b/src/renderer/src/components/sidebar/AddRepoDialog.tsx index 9fe63453d67..1c5f27c629b 100644 --- a/src/renderer/src/components/sidebar/AddRepoDialog.tsx +++ b/src/renderer/src/components/sidebar/AddRepoDialog.tsx @@ -193,22 +193,26 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { onGitRepoReady: completeGitRepoAdd, setAddProjectBusyLabel }) - const { handleImportNestedRepos, resetNestedImportFlow, trackNestedBackAction } = - useAddRepoNestedImportFlow({ - nestedAttemptId, - nestedScan, - nestedSelectedPaths, - nestedRuntimeKind, - nestedConnectionId, - nestedGroupName, - nestedImportScanId, - activeRuntimeEnvironmentId: selectedRuntimeEnvironmentId, - fetchWorktrees, - importNestedRepos, - getNestedRepoRuntimeKind, - onGitRepoReady: completeGitRepoAdd, - setIsAdding - }) + const { + handleImportNestedRepos, + handleOpenNestedRootFolder, + resetNestedImportFlow, + trackNestedBackAction + } = useAddRepoNestedImportFlow({ + nestedAttemptId, + nestedScan, + nestedSelectedPaths, + nestedRuntimeKind, + nestedConnectionId, + nestedGroupName, + nestedImportScanId, + activeRuntimeEnvironmentId: selectedRuntimeEnvironmentId, + fetchWorktrees, + importNestedRepos, + getNestedRepoRuntimeKind, + onGitRepoReady: completeGitRepoAdd, + setIsAdding + }) const resetState = useCallback(() => { // Why: kill the git clone process if one is running, so backing out @@ -381,6 +385,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { onNestedGroupNameChange={setNestedGroupName} onNestedSelectedPathsChange={setNestedSelectedPaths} onImportNestedRepos={(mode) => void handleImportNestedRepos(mode)} + onOpenNestedRootFolder={() => void handleOpenNestedRootFolder()} onCreateNameChange={(value) => { setCreateName(value) setCreateError(null) diff --git a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx index 26ec195d440..b70c299be36 100644 --- a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx +++ b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx @@ -79,6 +79,7 @@ function renderStepContent(overrides: Partial): string { onNestedGroupNameChange: vi.fn(), onNestedSelectedPathsChange: vi.fn(), onImportNestedRepos: vi.fn(), + onOpenNestedRootFolder: vi.fn(), onCreateNameChange: vi.fn(), onCreateParentChange: vi.fn(), onPickCreateParent: vi.fn(), @@ -120,6 +121,15 @@ describe('AddRepoDialogStepContent nested imports', () => { expect(html).not.toContain('>Import') }) + it('offers opening the parent folder when nested import selection is empty', () => { + const html = renderStepContent({ nestedSelectedPaths: new Set() }) + + expect(html).toContain('No repositories are selected') + expect(html).toContain('Open as Folder') + expect(html).toMatch(/]*disabled=""[^>]*>No, import separately<\/button>/) + expect(html).toMatch(/]*disabled=""[^>]*>Yes, import as group<\/button>/) + }) + it('offers host browsing for remote create project locations', () => { const html = renderStepContent({ step: 'create', diff --git a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx index 655a21de509..ee05b70b433 100644 --- a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx +++ b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx @@ -72,6 +72,7 @@ type AddRepoDialogStepContentProps = { onNestedGroupNameChange: (name: string) => void onNestedSelectedPathsChange: Dispatch>> onImportNestedRepos: (mode: 'group' | 'separate') => void + onOpenNestedRootFolder: () => void onCreateNameChange: (name: string) => void onCreateParentChange: (parent: string) => void onPickCreateParent: () => void @@ -140,6 +141,7 @@ export function AddRepoDialogStepContent({ onNestedGroupNameChange, onNestedSelectedPathsChange, onImportNestedRepos, + onOpenNestedRootFolder, onCreateNameChange, onCreateParentChange, onPickCreateParent, @@ -237,6 +239,7 @@ export function AddRepoDialogStepContent({ onGroupNameChange={onNestedGroupNameChange} onSelectedPathsChange={onNestedSelectedPathsChange} onImport={onImportNestedRepos} + onOpenAsFolder={onOpenNestedRootFolder} onStopScan={onStopNestedScan} /> ) diff --git a/src/renderer/src/components/sidebar/AddRepoNestedImportStep.test.tsx b/src/renderer/src/components/sidebar/AddRepoNestedImportStep.test.tsx index ba9022ca742..ff243a3a5cc 100644 --- a/src/renderer/src/components/sidebar/AddRepoNestedImportStep.test.tsx +++ b/src/renderer/src/components/sidebar/AddRepoNestedImportStep.test.tsx @@ -41,6 +41,7 @@ function renderStepMarkup( onGroupNameChange={vi.fn()} onSelectedPathsChange={vi.fn()} onImport={vi.fn()} + onOpenAsFolder={vi.fn()} onStopScan={vi.fn()} {...overrides} /> @@ -122,6 +123,7 @@ describe('AddRepoNestedImportStep', () => { onGroupNameChange={vi.fn()} onSelectedPathsChange={vi.fn()} onImport={onImport} + onOpenAsFolder={vi.fn()} onStopScan={vi.fn()} /> @@ -162,6 +164,7 @@ describe('AddRepoNestedImportStep', () => { onImport(mode) setIsAdding(true) }} + onOpenAsFolder={vi.fn()} onStopScan={vi.fn()} /> @@ -181,4 +184,44 @@ describe('AddRepoNestedImportStep', () => { expect(findButton(host, 'Yes, import as group').querySelector('.animate-spin')).not.toBeNull() expect(findButton(host, 'No, import separately').querySelector('.animate-spin')).toBeNull() }) + + it('offers opening the parent folder when no repositories are selected', () => { + const onOpenAsFolder = vi.fn() + const host = document.createElement('div') + container = host + document.body.appendChild(host) + root = createRoot(host) + + act(() => { + root?.render( + + + + + + ) + }) + + expect(host.textContent).toContain('No repositories are selected') + expect(findButton(host, 'No, import separately').disabled).toBe(true) + expect(findButton(host, 'Yes, import as group').disabled).toBe(true) + expect(findButton(host, 'Open as Folder').disabled).toBe(false) + + act(() => { + findButton(host, 'Open as Folder').click() + }) + + expect(onOpenAsFolder).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/renderer/src/components/sidebar/AddRepoNestedImportStep.tsx b/src/renderer/src/components/sidebar/AddRepoNestedImportStep.tsx index ac7db3b2958..ef41edbf1a1 100644 --- a/src/renderer/src/components/sidebar/AddRepoNestedImportStep.tsx +++ b/src/renderer/src/components/sidebar/AddRepoNestedImportStep.tsx @@ -20,6 +20,7 @@ type AddRepoNestedImportStepProps = { onGroupNameChange: (value: string) => void onSelectedPathsChange: Dispatch>> onImport: (mode: 'group' | 'separate') => void + onOpenAsFolder: () => void onStopScan: () => void } @@ -32,11 +33,16 @@ export function AddRepoNestedImportStep({ onGroupNameChange, onSelectedPathsChange, onImport, + onOpenAsFolder, onStopScan }: AddRepoNestedImportStepProps): React.JSX.Element { const folderName = getRuntimePathBasename(scan.selectedPath) || scan.selectedPath const groupNameInputId = useId() - const [pendingImportMode, setPendingImportMode] = useState<'group' | 'separate' | null>(null) + const [pendingImportMode, setPendingImportMode] = useState< + 'folder' | 'group' | 'separate' | null + >(null) + const noRepositoriesSelected = selectedPaths.size === 0 + const showOpenAsFolderSpinner = isAdding && pendingImportMode === 'folder' const showSeparateSpinner = isAdding && pendingImportMode === 'separate' const showGroupSpinner = isAdding && pendingImportMode === 'group' @@ -50,6 +56,10 @@ export function AddRepoNestedImportStep({ setPendingImportMode(mode) onImport(mode) } + const handleOpenAsFolder = (): void => { + setPendingImportMode('folder') + onOpenAsFolder() + } const repoCountLabel = scan.repos.length === 1 ? translate('auto.components.sidebar.AddRepoNestedImportStep.8401a7a0d0', '1 repository') @@ -137,10 +147,31 @@ export function AddRepoNestedImportStep({ placeholder={folderName} />
+ {noRepositoriesSelected ? ( +

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

+ ) : null}
+ {noRepositoriesSelected ? ( + + ) : null} ), @@ -96,9 +118,48 @@ vi.mock('./SidebarFeedbackDialog', () => ({ SidebarFeedbackDialog: () =>
})) +function installWindowApi(): void { + Object.assign(window, { + api: { + app: { + restart: mocks.appRestart + }, + shell: { + openUrl: mocks.shellOpenUrl + }, + updater: { + check: mocks.updaterCheck + } + } + }) +} + +async function renderMenu(): Promise { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + roots.push(root) + + await act(async () => { + root.render() + }) + + return container +} + +function findMenuItem(container: HTMLElement, label: string): HTMLButtonElement { + const button = Array.from( + container.querySelectorAll('[data-testid="menu-item"]') + ).find((element) => element.textContent?.includes(label)) + expect(button).toBeDefined() + return button as HTMLButtonElement +} + describe('SidebarSettingsHelpMenu', () => { beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true vi.clearAllMocks() + installWindowApi() mocks.useShortcutKeyDetails.mockReturnValue({ keys: ['⌘', ','], doubleTap: false }) updateStatus = { state: 'idle' } mocks.setupProgress = { @@ -109,6 +170,13 @@ describe('SidebarSettingsHelpMenu', () => { } }) + afterEach(() => { + roots.splice(0).forEach((root) => { + act(() => root.unmount()) + }) + document.body.replaceChildren() + }) + it('renders the help button with correct aria-label', () => { const html = renderToStaticMarkup() expect(html).toContain('Help') @@ -177,6 +245,19 @@ describe('SidebarSettingsHelpMenu', () => { it('renders Discord link', () => { const html = renderToStaticMarkup() expect(html).toContain('Discord') + expect(html).toContain('viewBox="0 0 20 20"') + expect(html).toContain('M16.0742 4.45014C14.9244 3.92097 13.7106 3.54556 12.4638 3.3335') + }) + + it('opens Discord invite through the shell bridge', async () => { + const container = await renderMenu() + const discordButton = findMenuItem(container, 'Discord') + + await act(async () => { + discordButton.click() + }) + + expect(mocks.shellOpenUrl).toHaveBeenCalledWith('https://discord.gg/fzjDKHxv8Q') }) it('renders X link', () => { @@ -187,6 +268,43 @@ describe('SidebarSettingsHelpMenu', () => { it('renders Check for Updates menu item', () => { const html = renderToStaticMarkup() expect(html).toContain('Check for Updates') + expect(html).toMatch(/(⇧\+click|Shift\+click) checks the latest RC/) + expect(html).toMatch(/(⌘\+click|Ctrl\+click) checks the latest perf build/) + }) + + it('passes update-check modifier options through the updater bridge', async () => { + const container = await renderMenu() + const checkButton = findMenuItem(container, 'Check for Updates') + const primaryModifier = navigator.userAgent.includes('Mac') + ? { metaKey: true } + : { ctrlKey: true } + + await act(async () => { + checkButton.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, shiftKey: true })) + checkButton.click() + }) + await act(async () => { + checkButton.dispatchEvent( + new MouseEvent('pointerdown', { bubbles: true, ...primaryModifier }) + ) + checkButton.click() + }) + await act(async () => { + checkButton.click() + }) + + expect(mocks.updaterCheck).toHaveBeenNthCalledWith(1, { + includePrerelease: true, + includePerfPrerelease: false + }) + expect(mocks.updaterCheck).toHaveBeenNthCalledWith(2, { + includePrerelease: false, + includePerfPrerelease: true + }) + expect(mocks.updaterCheck).toHaveBeenNthCalledWith(3, { + includePrerelease: false, + includePerfPrerelease: false + }) }) it('renders shortcut keys in the settings tooltip', () => { diff --git a/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx b/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx index f5bf3250c6f..1c6e282be74 100644 --- a/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx +++ b/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx @@ -33,12 +33,14 @@ import { SetupGuideProgressRing } from '../setup-guide/SetupGuideProgressRing' import { useSetupGuideProgress } from '../setup-guide/use-setup-guide-progress' import { SidebarFeedbackDialog } from './SidebarFeedbackDialog' import { translate } from '@/i18n/i18n' +import { getUpdateCheckClickOptions, getUpdateCheckHint } from '@/lib/update-check-click-options' const DOCS_URL = 'https://www.onorca.dev/docs' const CHANGELOG_URL = 'https://onorca.dev/changelog' const GITHUB_URL = 'https://github.com/stablyai/orca' const DISCORD_URL = 'https://discord.gg/fzjDKHxv8Q' const X_URL = 'https://x.com/orca_build' +const NO_UPDATE_CHECK_MODIFIERS = { ctrlKey: false, metaKey: false, shiftKey: false } function openExternalUrl(url: string): void { void window.api.shell.openUrl(url) @@ -46,8 +48,8 @@ function openExternalUrl(url: string): void { function DiscordIcon(): React.JSX.Element { return ( -
+ {branchName} +
+ ) : null + const workspaceIdentity = + workspaceTitle && workspaceTitle !== branchName ? ( + onRenameWorkspaceTitle ? ( + + ) : ( +
+ {workspaceTitle} +
+ ) + ) : null + + if (!branchIdentity && !workspaceIdentity) { + return null + } + + return ( + // Why: detail sections keep the left rule; the hover title stays flush so + // it reads as the panel heading rather than another inset section. +
+ {identityOrder === 'branch-first' ? branchIdentity : workspaceIdentity} + {identityOrder === 'branch-first' ? workspaceIdentity : branchIdentity} +
+ ) +} diff --git a/src/renderer/src/components/sidebar/WorktreeCardMeta.interaction.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardMeta.interaction.test.tsx index 7464b6f5477..2b2cabb5947 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardMeta.interaction.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardMeta.interaction.test.tsx @@ -138,6 +138,26 @@ describe('WorktreeCardDetailsHover interactions', () => { return onUnlinkReview } + function renderEditableHover(onRenameWorkspaceTitle = vi.fn()): ReturnType { + container = document.createElement('div') + root = createRoot(container) + act(() => { + root.render( + + Workspace card + + ) + }) + return onRenameWorkspaceTitle + } + it('defers hover close while the review menu is open', () => { renderHover() @@ -177,6 +197,44 @@ describe('WorktreeCardDetailsHover interactions', () => { expect(container.textContent).not.toContain('More PR actions') }) + it('keeps the hover mounted while the workspace title is being edited', () => { + renderEditableHover() + + act(() => { + interactionMocks.onHoverOpenChange?.(true) + }) + const title = container.querySelector('[data-worktree-title-inline-rename]') + + act(() => { + title?.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true })) + }) + const input = container.querySelector('[data-worktree-title-rename-input]') + + expect(input).not.toBeNull() + expect(input?.className).toContain('bg-input/40') + expect(input?.className).toContain('rounded-sm') + expect(input?.className).toContain('selection:bg-[Highlight]') + expect(input?.className).toContain('focus-visible:ring-[1px]') + + act(() => { + interactionMocks.onHoverOpenChange?.(false) + }) + + expect(container.querySelector('[data-hover-open]')?.getAttribute('data-hover-open')).toBe( + 'true' + ) + + act(() => { + input?.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }) + ) + }) + + expect(container.querySelector('[data-hover-open]')?.getAttribute('data-hover-open')).toBe( + 'false' + ) + }) + it('invokes unlink and closes the hover from the menu item', () => { const onUnlinkReview = renderHover() diff --git a/src/renderer/src/components/sidebar/WorktreeCardMeta.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardMeta.test.tsx index 895efe1151a..2098cce6e1b 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardMeta.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardMeta.test.tsx @@ -78,6 +78,39 @@ describe('WorktreeCardDetailsHover', () => { expect(markup.indexOf('feature/local-branch')).toBeLessThan(markup.indexOf('PR #456')) }) + it('keeps the hover title unruled and inline editable while section bodies stay inset', () => { + const markup = renderToStaticMarkup( + + Fix stale GH PR + + ) + const identityHeaderTag = + markup.match(/]*data-worktree-hover-identity-header=""[^>]*>/)?.[0] ?? '' + + expect(identityHeaderTag).not.toContain('border-l') + expect(identityHeaderTag).not.toContain('pl-2') + expect(markup).toContain('data-worktree-title-inline-rename=""') + expect(markup).toContain('cursor-text text-[13px] font-semibold') + expect(markup).toContain('Fix stale GH PR') + expect(markup).toContain('border-l border-border/70 pl-3') + }) + it('puts unlink behind the first PR actions menu and keeps GitHub last', () => { const markup = renderToStaticMarkup( { + setWorkspaceTitleEditing(editing) + onWorkspaceTitleEditingChange?.(editing) + if (!editing && pendingWorkspaceTitleCloseRef.current) { + pendingWorkspaceTitleCloseRef.current = false + handleHoverOpenChange(false) + } + }, + [handleHoverOpenChange, onWorkspaceTitleEditingChange] + ) + const handleEffectiveHoverOpenChange = React.useCallback( + (next: boolean): void => { + if (!next && workspaceTitleEditing) { + pendingWorkspaceTitleCloseRef.current = true + return + } + pendingWorkspaceTitleCloseRef.current = false + handleHoverOpenChange(next) + }, + [handleHoverOpenChange, workspaceTitleEditing] + ) const dismissAndRun = React.useCallback( (handler: ((event: React.MouseEvent) => void) | undefined) => (event: React.MouseEvent) => { closeHover() @@ -219,34 +247,10 @@ export function WorktreeCardDetailsHover({ return children } - const branchIdentity = branchName ? ( -
- {branchName} -
- ) : null - const workspaceIdentity = - workspaceTitle && workspaceTitle !== branchName ? ( -
- {workspaceTitle} -
- ) : null - return ( @@ -262,12 +266,14 @@ export function WorktreeCardDetailsHover({ > {showIdentityHeader && ( -
- {/* Why: the closed card no longer carries a branch row; custom-titled - worktrees still need their git branch available in the hover. */} - {identityOrder === 'branch-first' ? branchIdentity : workspaceIdentity} - {identityOrder === 'branch-first' ? workspaceIdentity : branchIdentity} -
+ )} ({ state: {} as Record @@ -508,7 +509,7 @@ function setPinnedDuplicateFixtureState(): void { } function setLineageFixtureState( - groupBy: 'none' | 'repo' = 'none', + groupBy: 'none' | 'repo' | 'workspace-status' = 'none', options: { childWorktreeOverrides?: Partial deletingWorktreeIds?: string[] @@ -631,7 +632,7 @@ function setLineageFixtureState( // Why: multi-host added a host scope filter; 'all' (the store default) // bypasses it so the fixture's worktrees aren't dropped before rendering. workspaceHostScope: 'all', - workspaceStatuses: [], + workspaceStatuses: groupBy === 'workspace-status' ? cloneDefaultWorkspaceStatuses() : [], worktreeCardProperties: ['status', 'inline-agents'], worktreeLineageById: { [child.id]: makeLineage(child, parent), @@ -884,6 +885,16 @@ describe('WorktreeList lineage child card renderer', () => { expect(markup).not.toContain('data-repo-header-collapse-affordance=""') }) + it('renders a collapse chevron on status group headers with worktrees', async () => { + setLineageFixtureState('workspace-status') + const markup = await renderWorktreeListMarkup() + + expect(markup).toContain('In progress') + expect(markup).toContain('data-workspace-status-drop-target=""') + expect(markup).toContain('data-repo-header-collapse-affordance=""') + expect(markup).toContain('aria-expanded="true"') + }) + it('renders a collapse chevron on grouped repo headers with worktrees', async () => { setLineageFixtureState('repo') const markup = await renderWorktreeListMarkup() diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index ac3c2d6a088..dbf77ecf0f9 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -34,6 +34,11 @@ import { useWorktreeMap } from '@/store/selectors' import WorktreeCard, { type ActiveSurfaceVariant } from './WorktreeCard' +import { WorktreeSidebarDropIndicator } from './WorktreeSidebarDropIndicator' +import { + getProjectGroupHeaderSectionEndByGroupId, + getRepoHeaderSectionEndByRepoId +} from './worktree-header-section-boundaries' import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree' import { PendingWorktreeRow } from './PendingWorktreeRow' import { SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT } from './WorktreeCardAgents' @@ -63,7 +68,7 @@ import type { WorkspaceStatusDefinition } from '../../../../shared/types' import { DEFAULT_SHOW_SLEEPING_WORKSPACES } from '../../../../shared/constants' -import { buildWorktreeComparator } from './smart-sort' +import { buildWorktreeComparator, compareWorktreeSortLabel } from './smart-sort' import { buildAttentionByWorktree, type SmartClass, @@ -135,6 +140,8 @@ import { } from '@/lib/scroll-to-current-workspace-status' import { isRepoHeaderActionTarget, useRepoHeaderDrag } from './project-header-drag' import { getSidebarOrderedRepoHeaderIdsByBucket } from './project-header-drop' +import { useProjectGroupHeaderDrag } from './project-group-header-drag' +import { getSidebarOrderedProjectGroupHeaderIdsByBucket } from './project-group-header-drop' import { buildManualOrderUpdatesForGroupDrop, buildManualOrderUpdatesForVisibleGroups, @@ -180,6 +187,10 @@ import { type WorktreeSidebarTrackedStatusDropTarget, type WorktreeSidebarDropPreview } from './worktree-sidebar-drop-preview' +import { + getReorderedWorktreeIdsToUnnest, + getWorktreeLineageDropTargetId +} from './worktree-lineage-drag-drop' import { resolveProjectGroupHeaderColor } from './project-header-color' import { REPO_HEADER_ACTION_BUTTON_CLASS, @@ -257,7 +268,7 @@ import { buildSidebarHostOptions } from './sidebar-host-options' import { HostSectionHeaderMenu } from './HostSectionHeaderMenu' import { ProjectHeaderActions } from './ProjectHeaderActions' import { translate } from '@/i18n/i18n' -import { folderWorkspaceKey } from '../../../../shared/workspace-scope' +import { folderWorkspaceKey, parseWorkspaceKey } from '../../../../shared/workspace-scope' import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides' import { isConfirmedStaleFolderPathStatus, @@ -394,6 +405,22 @@ 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[] = [] @@ -984,11 +1011,11 @@ function getPointerDropStatusTarget(args: { if (pinTarget && args.container.contains(pinTarget)) { return { status: null, isPinDrop: true, lineageParentId: null } } - const rowTarget = target.closest('[data-worktree-drag-id]') - let lineageParentId: string | null = null - if (rowTarget && args.container.contains(rowTarget)) { - lineageParentId = rowTarget.dataset.worktreeDragId ?? null - } + const lineageParentId = getWorktreeLineageDropTargetId({ + container: args.container, + target, + pointerY: args.y + }) const statusTarget = target.closest('[data-workspace-status-drop-target]') return { status: @@ -1314,6 +1341,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp const [highlightedRevealRowKey, setHighlightedRevealRowKey] = useState(null) const setRenamingWorktreeId = useAppStore((s) => s.setRenamingWorktreeId) const assignWorktreeParent = useAppStore((s) => s.assignWorktreeParent) + const updateWorktreeLineage = useAppStore((s) => s.updateWorktreeLineage) const worktreeDragSessionRef = useRef(null) const worktreePointerDragRef = useRef(null) const worktreePointerAutoscrollFrameIdRef = useRef(null) @@ -1372,7 +1400,9 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp const suppressWorktreeClickUntilRef = useRef(0) const hasProjectGroups = projectGroups.length > 0 const canReorderRepoHeaders = groupBy === 'repo' && projectOrderBy === 'manual' + const canReorderProjectGroupHeaders = groupBy === 'repo' && hasProjectGroups const moveProjectToGroup = useAppStore((s) => s.moveProjectToGroup) + const updateProjectGroup = useAppStore((s) => s.updateProjectGroup) const lastVisibleRefreshKeyRef = useRef('') const reportVisibleGitHubPRRefreshCandidates = useAppStore( (s) => s.reportVisibleGitHubPRRefreshCandidates @@ -1394,6 +1424,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp ), [projectGroups] ) + const projectGroupByIdForHeaderDrag = useMemo( + () => new Map(projectGroups.map((group) => [group.id, group])), + [projectGroups] + ) useEffect( () => @@ -1494,6 +1528,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp groupKey: string rects: readonly WorktreeSidebarDragRect[] draggedIds: readonly string[] + draggingWorktreeId?: string | null }): WorktreeSidebarDropPreview | null => { const container = scrollRef.current if (!container) { @@ -1510,7 +1545,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp scrollTop: container.scrollTop, rects: args.rects, groupIds: group.worktreeIds, - draggedIds: args.draggedIds + draggedIds: args.draggedIds, + draggingWorktreeId: args.draggingWorktreeId }) }, [worktreeDragUnitGroups] @@ -1525,7 +1561,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp pointerY, groupKey: session.sourceGroupKey, rects: session.rects, - draggedIds: session.reorderUnitDraggedIds + draggedIds: session.reorderUnitDraggedIds, + draggingWorktreeId: session.draggingWorktreeId }) }, [computeWorktreeDropForGroup] @@ -1545,7 +1582,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp pointerY: args.pointerY, groupKey, rects: getWorktreeSidebarDragRectsForGroup(container, groupKey), - draggedIds: args.draggedIds + draggedIds: args.draggedIds, + draggingWorktreeId: worktreeDragSessionRef.current?.draggingWorktreeId ?? null }) }, [computeWorktreeDropForGroup] @@ -1558,6 +1596,14 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp ), [rows] ) + const sidebarProjectGroupHeaderIdsByBucket = useMemo( + () => + getSidebarOrderedProjectGroupHeaderIdsByBucket( + rows.filter((row): row is Row => row.type !== 'host-header'), + projectGroupByIdForHeaderDrag + ), + [projectGroupByIdForHeaderDrag, rows] + ) const repoHeaderIndexByRepoId = useMemo(() => { const map = new Map() for (const repoIds of sidebarRepoHeaderIdsByBucket.values()) { @@ -1576,12 +1622,43 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp } return map }, [sidebarRepoHeaderIdsByBucket]) + const projectGroupHeaderIndexByGroupId = useMemo(() => { + const map = new Map() + for (const groupIds of sidebarProjectGroupHeaderIdsByBucket.values()) { + groupIds.forEach((groupId, index) => { + map.set(groupId, index) + }) + } + return map + }, [sidebarProjectGroupHeaderIdsByBucket]) + const projectGroupHeaderBucketByGroupId = useMemo(() => { + const map = new Map() + for (const [bucketKey, groupIds] of sidebarProjectGroupHeaderIdsByBucket) { + for (const groupId of groupIds) { + map.set(groupId, bucketKey) + } + } + return map + }, [sidebarProjectGroupHeaderIdsByBucket]) const commitProjectGroupOrder = useCallback( (repoId: string, projectGroupId: string | null, order: number) => { void moveProjectToGroup(repoId, projectGroupId, order) }, [moveProjectToGroup] ) + const commitProjectGroupHeaderOrder = useCallback( + (groupId: string, tabOrder: number) => { + if (!Number.isFinite(tabOrder)) { + return + } + const suppressUntil = + window.performance.now() + USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS + suppressMeasurementAdjustmentUntilRef.current = suppressUntil + directScrollInputUntilRef.current = suppressUntil + void updateProjectGroup(groupId, { tabOrder }) + }, + [updateProjectGroup] + ) // Drag is only meaningful when repo headers are using manual order. The // controller is still constructed for hook order stability when inert. const repoDrag = useRepoHeaderDrag({ @@ -1593,6 +1670,12 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp onCommitProjectGroupOrder: commitProjectGroupOrder, getScrollContainer: () => scrollRef.current }) + const projectGroupDrag = useProjectGroupHeaderDrag({ + sidebarProjectGroupHeaderIdsByBucket, + projectGroupById: projectGroupByIdForHeaderDrag, + onCommitProjectGroupTabOrder: commitProjectGroupHeaderOrder, + getScrollContainer: () => scrollRef.current + }) const [primaryActiveWorktreeRow, setPrimaryActiveWorktreeRow] = useState<{ worktreeId: string rowKey: string @@ -1649,6 +1732,31 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp () => renderRows.findIndex((row) => row.type === 'header' || row.type === 'host-header'), [renderRows] ) + const repoHeaderSectionEndByRepoId = useMemo( + () => + getRepoHeaderSectionEndByRepoId({ + rows: renderRows, + firstHeaderIndex, + sidebarRepoHeaderIdsByBucket, + repoHeaderBucketByRepoId + }), + [firstHeaderIndex, renderRows, repoHeaderBucketByRepoId, sidebarRepoHeaderIdsByBucket] + ) + const projectGroupHeaderSectionEndByGroupId = useMemo( + () => + getProjectGroupHeaderSectionEndByGroupId({ + rows: renderRows, + firstHeaderIndex, + sidebarProjectGroupHeaderIdsByBucket, + projectGroupHeaderBucketByGroupId + }), + [ + firstHeaderIndex, + projectGroupHeaderBucketByGroupId, + renderRows, + sidebarProjectGroupHeaderIdsByBucket + ] + ) const firstHeaderIndexRef = useRef(firstHeaderIndex) firstHeaderIndexRef.current = firstHeaderIndex const stickyHeaderIndexes = useMemo(() => getStickyHeaderIndexes(renderRows), [renderRows]) @@ -2587,6 +2695,37 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp [assignWorktreeParent, getEligibleLineageDropTarget] ) + const clearReorderedWorktreeParents = useCallback( + (args: { draggedIds: readonly string[]; sourceGroupKey: string }) => { + const sourceGroup = worktreeDragGroups.find((group) => group.key === args.sourceGroupKey) + if (!sourceGroup) { + return + } + const ids = getReorderedWorktreeIdsToUnnest({ + draggedIds: args.draggedIds, + sourceGroupIds: sourceGroup.worktreeIds, + lineageById: worktreeLineageById + }) + if (ids.length === 0) { + return + } + // Why: dropping a nested card onto a reorder line is the escape hatch + // from accidental nesting, so clear only the directly dragged children. + void Promise.all(ids.map((id) => updateWorktreeLineage(id, { noParent: true }))).catch( + (err) => { + console.error('Failed to unnest workspace:', err) + toast.error( + translate( + 'auto.components.sidebar.WorktreeList.failedUnnestWorkspace', + 'Failed to unnest workspace' + ) + ) + } + ) + }, + [updateWorktreeLineage, worktreeDragGroups, worktreeLineageById] + ) + const flushWorktreePointerDrag = useCallback(() => { const drag = worktreePointerDragRef.current if (!drag) { @@ -3101,6 +3240,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp dropIndex: drop.dropIndex }) }) + clearReorderedWorktreeParents({ + draggedIds: drag.draggedIds, + sourceGroupKey: drag.sourceGroupKey + }) } else if (scrollRef.current) { const currentTarget = preferredStatusTarget const currentPreview = currentTarget.status @@ -3157,6 +3300,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp }, [ beginWorktreePointerDrag, clearWorktreeDrag, + clearReorderedWorktreeParents, commitWorktreeLineageParentDrop, computeWorktreeDrop, computeWorktreeStatusDrop, @@ -3504,10 +3648,15 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp dropIndex: drop.dropIndex }) }) + clearReorderedWorktreeParents({ + draggedIds: session.draggedIds, + sourceGroupKey: session.sourceGroupKey + }) clearWorktreeDrag() }, [ clearWorktreeDrag, + clearReorderedWorktreeParents, commitWorktreeLineageParentDrop, computeWorktreeDrop, computeWorktreeStatusDrop, @@ -3757,6 +3906,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp dropIndex: drop.dropIndex }) }) + clearReorderedWorktreeParents({ + draggedIds: session.draggedIds, + sourceGroupKey: session.sourceGroupKey + }) clearWorktreeDrag() } @@ -3764,6 +3917,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp return () => document.removeEventListener('drop', handleDocumentDrop, true) }, [ clearWorktreeDrag, + clearReorderedWorktreeParents, commitWorktreeLineageParentDrop, computeWorktreeDrop, computeWorktreeStatusDrop, @@ -3845,37 +3999,19 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp {canReorderRepoHeaders && repoDrag.state.draggingRepoId !== null && repoDrag.state.dropIndicatorY !== null ? ( -
+ + ) : null} + {canReorderProjectGroupHeaders && + projectGroupDrag.state.draggingGroupId !== null && + projectGroupDrag.state.dropIndicatorY !== null ? ( + ) : null} {hostDrag.state.draggingHostId !== null && hostDrag.state.dropIndicatorY !== null ? ( -
- - - -
+ ) : null} {worktreeDragState.draggingWorktreeId !== null && worktreeDragState.dropIndicatorY !== null ? ( -
- - - -
+ ) : null} {virtualItems.map((vItem) => { const row = renderRows[vItem.index] @@ -3943,6 +4079,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp const isRepoHeader = groupBy === 'repo' && row.repo !== undefined const isProjectGroupHeader = groupBy === 'repo' && row.projectGroup !== undefined const projectIdForHeader = isRepoHeader ? row.repo!.id : undefined + const projectGroupIdForHeader = + isProjectGroupHeader && !row.repo && typeof row.projectGroup?.id === 'string' + ? row.projectGroup.id + : undefined const repoHeaderIndex = projectIdForHeader !== undefined ? repoHeaderIndexByRepoId.get(projectIdForHeader) @@ -3951,6 +4091,14 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp projectIdForHeader !== undefined ? repoHeaderBucketByRepoId.get(projectIdForHeader) : undefined + const projectGroupHeaderIndex = + projectGroupIdForHeader !== undefined + ? projectGroupHeaderIndexByGroupId.get(projectGroupIdForHeader) + : undefined + const projectGroupHeaderBucketKey = + projectGroupIdForHeader !== undefined + ? projectGroupHeaderBucketByGroupId.get(projectGroupIdForHeader) + : undefined const isDraggableRepoHeader = Boolean( canReorderRepoHeaders && isRepoHeader && @@ -3958,10 +4106,21 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp repoHeaderBucketKey && (sidebarRepoHeaderIdsByBucket.get(repoHeaderBucketKey)?.length ?? 0) > 1 ) + const isDraggableProjectGroupHeader = Boolean( + canReorderProjectGroupHeaders && + projectGroupIdForHeader && + projectGroupHeaderBucketKey && + (sidebarProjectGroupHeaderIdsByBucket.get(projectGroupHeaderBucketKey)?.length ?? + 0) > 1 + ) const isDraggingThis = canReorderRepoHeaders && repoDrag.state.draggingRepoId !== null && repoDrag.state.draggingRepoId === projectIdForHeader + const isDraggingThisProjectGroup = + canReorderProjectGroupHeaders && + projectGroupDrag.state.draggingGroupId !== null && + projectGroupDrag.state.draggingGroupId === projectGroupIdForHeader const headerWorkspaceStatus = groupBy === 'workspace-status' ? getWorkspaceStatusFromGroupKey(row.key, workspaceStatuses) @@ -3997,10 +4156,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp projectGroupPathStatus.reason === 'ambiguous-connection') const projectGroupDepth = row.projectGroupDepth ?? 0 const isHeaderCollapsed = collapsedGroups.has(row.key) - // Why: repo/project headers already reveal actions on hover; tuck - // the collapse chevron into that cluster instead of a new surface. + // Why: repo/project and status headers use the same compact + // section chrome; flat "All" stays a simple label. const showHeaderCollapseAffordance = - row.count > 0 && (isRepoHeader || isProjectGroupHeader) + row.count > 0 && + (isRepoHeader || isProjectGroupHeader || headerWorkspaceStatus !== null) // Why: non-project section headers like "All" are labels for the // flat list, so they should not reserve project hierarchy indent. const headerPaddingLeft = @@ -4044,15 +4204,34 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp data-repo-header-id={projectIdForHeader} data-repo-header-index={repoHeaderIndex} data-repo-header-bucket={repoHeaderBucketKey} + data-repo-header-section-end={ + projectIdForHeader + ? repoHeaderSectionEndByRepoId.get(projectIdForHeader) + : undefined + } + data-repo-header-drag-handle={isDraggableRepoHeader ? '' : undefined} + data-project-group-header-id={projectGroupIdForHeader} + data-project-group-header-index={projectGroupHeaderIndex} + data-project-group-header-bucket={projectGroupHeaderBucketKey} + data-project-group-header-section-end={ + projectGroupIdForHeader + ? projectGroupHeaderSectionEndByGroupId.get(projectGroupIdForHeader) + : undefined + } + data-project-group-header-drag-handle={ + isDraggableProjectGroupHeader ? '' : undefined + } data-workspace-status-drop-target={headerWorkspaceStatus ? '' : undefined} data-workspace-status={headerWorkspaceStatus ?? undefined} data-workspace-pin-drop-target={isPinnedHeader ? '' : undefined} className={cn( 'group relative flex h-7 w-full items-center gap-1.5 pr-2 text-left transition-all', - 'cursor-pointer', + isDraggableRepoHeader || isDraggableProjectGroupHeader + ? 'cursor-grab active:cursor-grabbing' + : 'cursor-pointer', highlightedRevealRowKey === row.key && 'rounded-md bg-worktree-sidebar-accent ring-1 ring-worktree-sidebar-ring/50', - isDraggingThis && + (isDraggingThis || isDraggingThisProjectGroup) && 'bg-accent/80 ring-1 ring-ring/40 shadow-md rounded-md scale-[1.01]', headerWorkspaceStatus && dragOverStatus === headerWorkspaceStatus && @@ -4082,6 +4261,14 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp ? (event) => handleWorkspaceStatusDrop(event, headerWorkspaceStatus) : undefined } + onPointerDown={ + isDraggableRepoHeader && projectIdForHeader + ? (event) => repoDrag.onHandlePointerDown(event, projectIdForHeader) + : isDraggableProjectGroupHeader && projectGroupIdForHeader + ? (event) => + projectGroupDrag.onHandlePointerDown(event, projectGroupIdForHeader) + : undefined + } onClick={(event) => { if (shouldIgnoreRepoHeaderToggle(event)) { return @@ -4101,16 +4288,15 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp {row.icon ? (
repoDrag.onHandlePointerDown(event, projectIdForHeader) - : undefined - } > {row.repo ? ( - nested - ? undefined - : handleWorktreeRowPointerDown(event, itemRow.worktree.id, itemRow.rowKey) - } + onPointerDown={(event) => { + if (nested) { + event.stopPropagation() + } + handleWorktreeRowPointerDown(event, itemRow.worktree.id, itemRow.rowKey) + }} style={{ paddingLeft: surfaceInset > 0 ? `${surfaceInset}px` : undefined }} @@ -4980,7 +5167,11 @@ const WorktreeList = React.memo(function WorktreeList({ const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) const detectedWorktreesByRepo = useAppStore((s) => s.detectedWorktreesByRepo) const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) - const currentSidebarWorktreeId = activeWorktreeId + const activeWorkspaceKey = useAppStore((s) => s.activeWorkspaceKey) + const currentSidebarWorktreeId = useMemo( + () => getActiveSidebarWorkspaceId(activeWorkspaceKey, activeWorktreeId), + [activeWorkspaceKey, activeWorktreeId] + ) const groupBy = useAppStore((s) => s.groupBy) const setGroupBy = useAppStore((s) => s.setGroupBy) const workspaceHostScope = useAppStore((s) => s.workspaceHostScope) @@ -5192,7 +5383,7 @@ const WorktreeList = React.memo(function WorktreeList({ sessionHasHadPty.current = true } else { nonArchivedWorktrees.sort( - (a, b) => b.sortOrder - a.sortOrder || a.displayName.localeCompare(b.displayName) + (a, b) => b.sortOrder - a.sortOrder || compareWorktreeSortLabel(a, b) ) lastAttentionByWorktreeRef.current = null return nonArchivedWorktrees.map((w) => w.id) @@ -6471,31 +6662,31 @@ const WorktreeList = React.memo(function WorktreeList({ }) return } - if (!activeWorktreeId) { + if (!currentSidebarWorktreeId) { return } const activeWorktree = getKnownSidebarWorktreeById( - activeWorktreeId, + currentSidebarWorktreeId, worktreeMap, folderWorkspaces ) if (!activeWorktree || activeWorktree.isArchived) { return } - if (!renderedWorktreeIds.includes(activeWorktreeId)) { + if (!renderedWorktreeIds.includes(currentSidebarWorktreeId)) { // Why: the toolbar action promises to reveal the current workspace; when // sidebar filters hide it, relax those filters before queuing the reveal. clearFilters() } - revealWorktreeInSidebar(activeWorktreeId, { + revealWorktreeInSidebar(currentSidebarWorktreeId, { behavior: 'smooth', highlight: true, beginRename: (detail as { beginRename?: boolean } | undefined)?.beginRename === true }) }, [ - activeWorktreeId, clearFilters, + currentSidebarWorktreeId, folderWorkspaces, revealSidebarRow, renderedWorktreeIds, diff --git a/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx b/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx index 217d2fd6735..5163a839ebf 100644 --- a/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx +++ b/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx @@ -11,8 +11,11 @@ import { import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { parseGitHubIssueOrPRNumber } from '@/lib/github-links' -import { buildWorktreeMetaUpdates, type WorktreeMetaSavedPayload } from './worktree-meta-updates' +import { + buildWorktreeMetaUpdates, + parseGitHubWorkItemNumberForMetaField, + type WorktreeMetaSavedPayload +} from './worktree-meta-updates' import { useWorktreeIssueLink } from './use-worktree-issue-link' import { getScreenSubmitShortcutLabel, isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' import { ExternalLink, LoaderCircle } from 'lucide-react' @@ -95,8 +98,10 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { } const trimmedIssue = issueInput.trim() const trimmedPR = prInput.trim() - const issueValid = trimmedIssue === '' || parseGitHubIssueOrPRNumber(trimmedIssue) !== null - const prValid = trimmedPR === '' || parseGitHubIssueOrPRNumber(trimmedPR) !== null + const issueValid = + trimmedIssue === '' || parseGitHubWorkItemNumberForMetaField(trimmedIssue, 'issue') !== null + const prValid = + trimmedPR === '' || parseGitHubWorkItemNumberForMetaField(trimmedPR, 'pr') !== null return issueValid && prValid }, [worktreeId, issueInput, prInput]) diff --git a/src/renderer/src/components/sidebar/WorktreeSidebarDropIndicator.tsx b/src/renderer/src/components/sidebar/WorktreeSidebarDropIndicator.tsx new file mode 100644 index 00000000000..e79e6b82ff0 --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeSidebarDropIndicator.tsx @@ -0,0 +1,28 @@ +import type { ReactElement } from 'react' + +import { cn } from '@/lib/utils' + +type WorktreeSidebarDropIndicatorProps = { + y: number + className?: string +} + +export function WorktreeSidebarDropIndicator({ + y, + className +}: WorktreeSidebarDropIndicatorProps): ReactElement { + return ( +
+ + + +
+ ) +} diff --git a/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.begin-editing.test.tsx b/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.begin-editing.test.tsx index 7b017362b1d..8958dd58543 100644 --- a/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.begin-editing.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.begin-editing.test.tsx @@ -68,6 +68,7 @@ async function renderTitleRename(props: { beginEditing: boolean disabled?: boolean onBeginEditingConsumed: () => void + onRename?: (displayName: string) => Promise | void }): Promise { reactHookRuntime.index = 0 const module = await import('./WorktreeTitleInlineRename') @@ -120,6 +121,27 @@ function findElementsByType(node: unknown, typeName: string): ReactElementLike[] return results } +function pressInputKey( + input: ReactElementLike, + key: string, + options?: { isComposing?: boolean; keyCode?: number } +): { + preventDefault: ReturnType + stopPropagation: ReturnType +} { + const event = { + key, + nativeEvent: { + isComposing: options?.isComposing ?? false, + keyCode: options?.keyCode ?? 13 + }, + preventDefault: vi.fn(), + stopPropagation: vi.fn() + } + ;(input.props.onKeyDown as (nextEvent: typeof event) => void)(event) + return event +} + describe('WorktreeTitleInlineRename beginEditing', () => { beforeEach(() => { reactHookRuntime.states = [] @@ -152,4 +174,32 @@ describe('WorktreeTitleInlineRename beginEditing', () => { expect(onBeginEditingConsumed).toHaveBeenCalledTimes(1) expect(findElementsByType(rerender, 'input')).toHaveLength(0) }) + + it('ignores IME composition Enter before committing the edited title', async () => { + const onBeginEditingConsumed = vi.fn() + const onRename = vi.fn() + + await renderTitleRename({ beginEditing: true, onBeginEditingConsumed, onRename }) + let rerender = expandNode( + await renderTitleRename({ beginEditing: false, onBeginEditingConsumed, onRename }) + ) + let input = findElementsByType(rerender, 'input')[0] + ;(input.props.onChange as (event: { target: { value: string } }) => void)({ + target: { value: '日本語 workspace' } + }) + rerender = expandNode( + await renderTitleRename({ beginEditing: false, onBeginEditingConsumed, onRename }) + ) + input = findElementsByType(rerender, 'input')[0] + + const composingEvent = pressInputKey(input, 'Enter', { isComposing: true }) + + expect(composingEvent.preventDefault).not.toHaveBeenCalled() + expect(onRename).not.toHaveBeenCalled() + + pressInputKey(input, 'Enter') + await Promise.resolve() + + expect(onRename).toHaveBeenCalledWith('日本語 workspace') + }) }) diff --git a/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.tsx b/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.tsx index 914c3eb2f9a..679460541a0 100644 --- a/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.tsx +++ b/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.tsx @@ -4,6 +4,7 @@ import { toast } from 'sonner' import { Input } from '@/components/ui/input' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { translate } from '@/i18n/i18n' export type WorktreeTitleRenameCommit = { kind: 'cancel' } | { kind: 'save'; displayName: string } @@ -30,10 +31,12 @@ type WorktreeTitleInlineRenameProps = { disabled?: boolean showUnreadEmphasis?: boolean dimReadTitle?: boolean + editingPresentation?: 'text' | 'field' className?: string editingClassName?: string inputClassName?: string titleWrapper?: (title: React.ReactElement) => React.ReactElement + wrapTitle?: boolean onEditingChange?: (editing: boolean) => void onRename: (displayName: string) => Promise | void // Why: lets a parent (e.g. the workspace.rename shortcut via WorktreeCard) @@ -48,10 +51,12 @@ export function WorktreeTitleInlineRename({ disabled = false, showUnreadEmphasis = false, dimReadTitle = false, + editingPresentation = 'text', className, editingClassName, inputClassName, titleWrapper, + wrapTitle = false, onEditingChange, onRename, beginEditing = false, @@ -84,7 +89,9 @@ export function WorktreeTitleInlineRename({ // root owns that stale-write guard without a mount-only Effect. mountedRef.current = node !== null titleElementRef.current = node - if (!node || editingRef.current) { + // Why: wrapped titles render in full and never truncate, so skip the measure + + // ResizeObserver entirely — for that mode it could only churn unused state. + if (!node || editingRef.current || wrapTitle) { measureTitleTruncated(null) return } @@ -104,10 +111,18 @@ export function WorktreeTitleInlineRename({ observer.observe(node) titleResizeObserverRef.current = observer }, - [measureTitleTruncated] + [measureTitleTruncated, wrapTitle] ) const titleElementKey = `${displayName}:${showUnreadEmphasis ? 'unread' : 'read'}` + // Why: the sidebar row needs a text-only editor to avoid layout jumps; the + // hovercard can use a compact field that reads more like native rename UI. + const editingInputClassName = + editingPresentation === 'field' + ? 'h-6 rounded-sm border border-input bg-input/40 px-1.5 py-0 shadow-xs selection:bg-[Highlight] selection:text-[HighlightText] focus-visible:border-ring focus-visible:ring-[1px] focus-visible:ring-ring/50 dark:bg-input/30' + : 'h-[1lh] rounded-none border-0 !border-transparent !bg-transparent p-0 !shadow-none focus-visible:border-transparent focus-visible:ring-0 focus-visible:outline-none dark:!bg-transparent' + const savingInputClassName = editingPresentation === 'field' ? 'pr-6' : 'pr-4' + const savingSpinnerClassName = editingPresentation === 'field' ? 'right-1.5' : 'right-0' const setEditingMode = useCallback( (nextEditing: boolean) => { @@ -211,6 +226,11 @@ export function WorktreeTitleInlineRename({ const handleKeyDown = useCallback( (event: React.KeyboardEvent) => { event.stopPropagation() + // Why: an Enter that only confirms a CJK IME candidate must not commit the + // rename; wait for a non-composition Enter. + if (isImeCompositionKeyDown(event)) { + return + } if (event.key === 'Enter') { event.preventDefault() void commitRename() @@ -246,6 +266,7 @@ export function WorktreeTitleInlineRename({ value={value} style={{ font: 'inherit' }} disabled={saving} + spellCheck={false} aria-label={translate( 'auto.components.sidebar.WorktreeTitleInlineRename.bff3bdd00c', 'Rename workspace' @@ -258,14 +279,19 @@ export function WorktreeTitleInlineRename({ onPointerDown={stopCardEvent} onKeyDown={handleKeyDown} className={cn( - 'col-start-1 row-start-1 h-[1lh] min-w-0 select-text truncate rounded-none border-0 !border-transparent !bg-transparent p-0 text-foreground !shadow-none outline-none dark:!bg-transparent', - 'focus-visible:border-transparent focus-visible:ring-0 focus-visible:outline-none', - saving && 'pr-4', + 'col-start-1 row-start-1 min-w-0 select-text truncate text-foreground outline-none', + editingInputClassName, + saving && savingInputClassName, inputClassName )} /> {saving ? ( - + ) : null} ) @@ -282,7 +308,8 @@ export function WorktreeTitleInlineRename({ key={`title:${titleElementKey}`} ref={handleRootRef} className={cn( - 'block min-w-0 truncate leading-tight focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-worktree-sidebar-ring', + 'block min-w-0 leading-tight focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-worktree-sidebar-ring', + wrapTitle ? 'break-words whitespace-normal' : 'truncate', titleEmphasisClassName, className )} @@ -304,7 +331,7 @@ export function WorktreeTitleInlineRename({ return titleWrapper(title) } - if (!titleTruncated) { + if (wrapTitle || !titleTruncated) { return title } diff --git a/src/renderer/src/components/sidebar/comment-markdown-element-renderers.tsx b/src/renderer/src/components/sidebar/comment-markdown-element-renderers.tsx index 5f2e0b972bf..7940d62783d 100644 --- a/src/renderer/src/components/sidebar/comment-markdown-element-renderers.tsx +++ b/src/renderer/src/components/sidebar/comment-markdown-element-renderers.tsx @@ -2,9 +2,11 @@ import React from 'react' import type { Components } from 'react-markdown' import { isMermaidFence, isMermaidPre, renderMermaidFence } from './comment-mermaid-fence' import { + GitHubUserAttachmentImage, GitHubUserAttachmentVideo, + isGitHubUserAttachmentUrl, isGitHubUserAttachmentVideoLink -} from './comment-markdown-github-attachment-video' +} from './comment-markdown-github-attachment-media' export type CommentMarkdownLinkClickHandler = ( event: React.MouseEvent, @@ -230,6 +232,12 @@ export function createDocumentCommentMarkdownComponents( ), img: ({ alt, src }) => { + if (isGitHubUserAttachmentUrl(src)) { + // Why: private-repo attachment images fail as cross-origin loads; a + // top-level link opens them in a GitHub-authenticated tab, and falls + // back to a text link when the image itself can't render. + return + } const imageClassName = [ 'my-3 max-h-96 max-w-full rounded-md object-contain', 'outline outline-1 outline-black/10 dark:outline-white/10', diff --git a/src/renderer/src/components/sidebar/comment-markdown-github-attachment-media.tsx b/src/renderer/src/components/sidebar/comment-markdown-github-attachment-media.tsx new file mode 100644 index 00000000000..be577385163 --- /dev/null +++ b/src/renderer/src/components/sidebar/comment-markdown-github-attachment-media.tsx @@ -0,0 +1,116 @@ +import React from 'react' + +export function isGitHubUserAttachmentUrl(href: string | undefined): href is string { + if (!href) { + return false + } + try { + const url = new URL(href) + return ( + url.protocol === 'https:' && + url.hostname === 'github.com' && + url.pathname.startsWith('/user-attachments/assets/') + ) + } catch { + return false + } +} + +function isBareAutolink(children: React.ReactNode, href: string): boolean { + const text = React.Children.toArray(children).join('').trim() + return text === href +} + +export function isGitHubUserAttachmentVideoLink( + href: string | undefined, + children: React.ReactNode +): href is string { + return isGitHubUserAttachmentUrl(href) && isBareAutolink(children, href) +} + +// Shared fallback link for attachments that can't render inline (see the image +// note below on why load failures drop to a session-scoped link). +function AttachmentFallbackLink({ + href, + children +}: { + href: string + children: React.ReactNode +}): React.ReactElement { + return ( + e.stopPropagation()} + > + {children} + + ) +} + +export function GitHubUserAttachmentVideo({ + href, + children +}: { + href: string + children: React.ReactNode +}): React.ReactElement { + const [failed, setFailed] = React.useState(false) + + if (failed) { + return {children} + } + + return ( + + ) +} + +export function GitHubUserAttachmentImage({ + src, + alt +}: { + src: string + alt: string | undefined +}): React.ReactElement { + const [failed, setFailed] = React.useState(false) + const label = alt?.trim() || src + + // Why: private-repo attachment images can't load cross-origin without the + // user's GitHub session cookies, so wrap in a top-level link (opening the + // URL where that session exists) and drop to a text link on load error. + if (failed) { + return {label} + } + + return ( + e.stopPropagation()} + > + {alt setFailed(true)} + /> + + ) +} diff --git a/src/renderer/src/components/sidebar/comment-markdown-github-attachment-video.tsx b/src/renderer/src/components/sidebar/comment-markdown-github-attachment-video.tsx deleted file mode 100644 index 4484933894e..00000000000 --- a/src/renderer/src/components/sidebar/comment-markdown-github-attachment-video.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import React from 'react' - -function isGitHubUserAttachmentUrl(href: string | undefined): href is string { - if (!href) { - return false - } - try { - const url = new URL(href) - return ( - url.protocol === 'https:' && - url.hostname === 'github.com' && - url.pathname.startsWith('/user-attachments/assets/') - ) - } catch { - return false - } -} - -function isBareAutolink(children: React.ReactNode, href: string): boolean { - const text = React.Children.toArray(children).join('').trim() - return text === href -} - -export function isGitHubUserAttachmentVideoLink( - href: string | undefined, - children: React.ReactNode -): href is string { - return isGitHubUserAttachmentUrl(href) && isBareAutolink(children, href) -} - -export function GitHubUserAttachmentVideo({ - href, - children -}: { - href: string - children: React.ReactNode -}): React.ReactElement { - const [failed, setFailed] = React.useState(false) - - if (failed) { - return ( - e.stopPropagation()} - > - {children} - - ) - } - - return ( - - ) -} diff --git a/src/renderer/src/components/sidebar/project-group-header-dom.test.ts b/src/renderer/src/components/sidebar/project-group-header-dom.test.ts new file mode 100644 index 00000000000..266c85c4893 --- /dev/null +++ b/src/renderer/src/components/sidebar/project-group-header-dom.test.ts @@ -0,0 +1,25 @@ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +function readWorktreeListSource(): string { + return readFileSync(fileURLToPath(new URL('./WorktreeList.tsx', import.meta.url)), 'utf8') +} + +describe('Project Group header drag DOM source', () => { + it('renders concrete Project Group header drag attributes separately from repo headers', () => { + const source = readWorktreeListSource() + + expect(source).toContain('data-project-group-header-id={projectGroupIdForHeader}') + expect(source).toContain('data-project-group-header-index={projectGroupHeaderIndex}') + expect(source).toContain('data-project-group-header-bucket={projectGroupHeaderBucketKey}') + expect(source).toContain('data-project-group-header-drag-handle=') + }) + + it('commits Project Group manual sorting through updateProjectGroup tabOrder', () => { + const source = readWorktreeListSource() + + expect(source).toContain('const updateProjectGroup = useAppStore((s) => s.updateProjectGroup)') + expect(source).toContain('void updateProjectGroup(groupId, { tabOrder })') + }) +}) diff --git a/src/renderer/src/components/sidebar/project-group-header-drag-commit.test.ts b/src/renderer/src/components/sidebar/project-group-header-drag-commit.test.ts new file mode 100644 index 00000000000..3c1b0384f41 --- /dev/null +++ b/src/renderer/src/components/sidebar/project-group-header-drag-commit.test.ts @@ -0,0 +1,118 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi } from 'vitest' + +import { commitProjectGroupHeaderDragDrop } from './project-group-header-drag-commit' +import type { ProjectGroupHeaderDragSession } from './project-group-header-drag-contract' +import type { ProjectGroup } from '../../../../shared/types' + +function group(id: string, overrides: Partial = {}): ProjectGroup { + return { + id, + name: id, + parentPath: null, + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +function makeSession( + groupId: string, + sidebarProjectGroupHeaderIds: readonly string[] +): ProjectGroupHeaderDragSession { + return { + groupId, + bucketKey: 'root', + sidebarProjectGroupHeaderIds, + pointerId: 1, + headerRects: [], + handleEl: document.createElement('div'), + startX: 0, + startY: 0, + latestPointerY: 0, + promoted: true + } +} + +describe('commitProjectGroupHeaderDragDrop', () => { + it('commits dense tabOrder updates for the affected Project Group siblings', () => { + const onCommitProjectGroupTabOrder = vi.fn() + const groups = [ + group('a', { tabOrder: 0 }), + group('b', { tabOrder: 10 }), + group('c', { tabOrder: 20 }) + ] + const projectGroupById = new Map(groups.map((entry) => [entry.id, entry])) + + commitProjectGroupHeaderDragDrop({ + session: makeSession('c', ['a', 'b', 'c']), + sidebarDropIndex: 0, + projectGroupById, + onCommitProjectGroupTabOrder + }) + + expect(onCommitProjectGroupTabOrder.mock.calls).toEqual([ + ['c', 0], + ['a', 1], + ['b', 2] + ]) + }) + + it('computes order only from the captured sibling bucket', () => { + const onCommitProjectGroupTabOrder = vi.fn() + const root = group('root') + const siblingA = group('sibling-a', { parentGroupId: root.id, tabOrder: 0 }) + const siblingB = group('sibling-b', { parentGroupId: root.id, tabOrder: 10 }) + const otherParentGroup = group('other-parent-group', { tabOrder: -100 }) + const projectGroupById = new Map( + [root, siblingA, siblingB, otherParentGroup].map((entry) => [entry.id, entry]) + ) + + commitProjectGroupHeaderDragDrop({ + session: makeSession('sibling-b', ['sibling-a', 'sibling-b']), + sidebarDropIndex: 0, + projectGroupById, + onCommitProjectGroupTabOrder + }) + + expect(onCommitProjectGroupTabOrder.mock.calls).toEqual([ + ['sibling-b', 0], + ['sibling-a', 1] + ]) + }) + + it('does not commit when the drop keeps the group in the same slot', () => { + const onCommitProjectGroupTabOrder = vi.fn() + const groups = [group('a'), group('b'), group('c')] + const projectGroupById = new Map(groups.map((entry) => [entry.id, entry])) + + commitProjectGroupHeaderDragDrop({ + session: makeSession('b', ['a', 'b', 'c']), + sidebarDropIndex: 2, + projectGroupById, + onCommitProjectGroupTabOrder + }) + + expect(onCommitProjectGroupTabOrder).not.toHaveBeenCalled() + }) + + it('does not commit when a stale session no longer contains the dragged group', () => { + const onCommitProjectGroupTabOrder = vi.fn() + const groups = [group('a'), group('b'), group('c')] + const projectGroupById = new Map(groups.map((entry) => [entry.id, entry])) + + commitProjectGroupHeaderDragDrop({ + session: makeSession('c', ['a', 'b']), + sidebarDropIndex: 0, + projectGroupById, + onCommitProjectGroupTabOrder + }) + + expect(onCommitProjectGroupTabOrder).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/sidebar/project-group-header-drag-commit.ts b/src/renderer/src/components/sidebar/project-group-header-drag-commit.ts new file mode 100644 index 00000000000..6f3db2f1260 --- /dev/null +++ b/src/renderer/src/components/sidebar/project-group-header-drag-commit.ts @@ -0,0 +1,20 @@ +import { getProjectGroupTabOrderUpdatesForSidebarDrop } from './project-group-header-drop' +import type { ProjectGroupHeaderDragSession } from './project-group-header-drag-contract' +import type { ProjectGroup } from '../../../../shared/types' + +export function commitProjectGroupHeaderDragDrop(args: { + session: ProjectGroupHeaderDragSession + sidebarDropIndex: number + projectGroupById: ReadonlyMap + onCommitProjectGroupTabOrder: (groupId: string, tabOrder: number) => void +}): void { + const updates = getProjectGroupTabOrderUpdatesForSidebarDrop({ + sidebarProjectGroupHeaderIds: args.session.sidebarProjectGroupHeaderIds, + draggedGroupId: args.session.groupId, + sidebarDropIndex: args.sidebarDropIndex, + projectGroupById: args.projectGroupById + }) + for (const update of updates) { + args.onCommitProjectGroupTabOrder(update.groupId, update.tabOrder) + } +} diff --git a/src/renderer/src/components/sidebar/project-group-header-drag-contract.ts b/src/renderer/src/components/sidebar/project-group-header-drag-contract.ts new file mode 100644 index 00000000000..7127ce6bd8c --- /dev/null +++ b/src/renderer/src/components/sidebar/project-group-header-drag-contract.ts @@ -0,0 +1,77 @@ +import type { PointerEvent } from 'react' + +import type { + ProjectGroupHeaderDragBucketKey, + ProjectGroupHeaderDragRect +} from './project-group-header-drop' +import type { ProjectGroup } from '../../../../shared/types' + +export type ProjectGroupDragState = { + draggingGroupId: string | null + dropIndex: number | null + dropIndicatorY: number | null +} + +export const INITIAL_PROJECT_GROUP_DRAG_STATE: ProjectGroupDragState = { + draggingGroupId: null, + dropIndex: null, + dropIndicatorY: null +} + +export type UseProjectGroupHeaderDragArgs = { + sidebarProjectGroupHeaderIdsByBucket: ReadonlyMap< + ProjectGroupHeaderDragBucketKey, + readonly string[] + > + projectGroupById: ReadonlyMap + onCommitProjectGroupTabOrder: (groupId: string, tabOrder: number) => void + getScrollContainer: () => HTMLElement | null +} + +export type ProjectGroupHeaderDragController = { + state: ProjectGroupDragState + onHandlePointerDown: (event: PointerEvent, groupId: string) => void +} + +export type ProjectGroupHeaderDragSession = { + groupId: string + bucketKey: ProjectGroupHeaderDragBucketKey + sidebarProjectGroupHeaderIds: readonly string[] + pointerId: number + headerRects: ProjectGroupHeaderDragRect[] + handleEl: HTMLElement + startX: number + startY: number + latestPointerY: number + promoted: boolean +} + +export const PROJECT_GROUP_HEADER_DRAG_THRESHOLD_PX = 4 + +const PROJECT_GROUP_HEADER_DRAG_HANDLE_SELECTOR = '[data-project-group-header-drag-handle]' + +const PROJECT_GROUP_HEADER_ACTION_SELECTOR = + '[data-repo-header-action], [data-repo-header-collapse-affordance], button, a, input, textarea, select, [contenteditable=""], [contenteditable="true"]' + +export function isProjectGroupHeaderDragHandleTarget( + target: EventTarget | null, + currentTarget: HTMLElement +): boolean { + if (!(target instanceof HTMLElement)) { + return false + } + const dragHandle = target.closest(PROJECT_GROUP_HEADER_DRAG_HANDLE_SELECTOR) + return dragHandle !== null && currentTarget.contains(dragHandle) +} + +export function isProjectGroupHeaderActionTarget( + target: EventTarget | null, + currentTarget: HTMLElement +): boolean { + if (!(target instanceof HTMLElement) || target === currentTarget) { + return false + } + return ( + currentTarget.contains(target) && target.closest(PROJECT_GROUP_HEADER_ACTION_SELECTOR) !== null + ) +} diff --git a/src/renderer/src/components/sidebar/project-group-header-drag-start.test.ts b/src/renderer/src/components/sidebar/project-group-header-drag-start.test.ts new file mode 100644 index 00000000000..4755cfaf14f --- /dev/null +++ b/src/renderer/src/components/sidebar/project-group-header-drag-start.test.ts @@ -0,0 +1,90 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi } from 'vitest' + +import { createProjectGroupHeaderDragSession } from './project-group-header-drag-start' +import type { ProjectGroup } from '../../../../shared/types' + +function group(id: string, overrides: Partial = {}): ProjectGroup { + return { + id, + name: id, + parentPath: null, + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +describe('createProjectGroupHeaderDragSession', () => { + it('arms a drag session from plain header text when the row is the drag handle', () => { + const header = document.createElement('div') + header.setAttribute('data-project-group-header-drag-handle', '') + header.setPointerCapture = vi.fn() + const label = document.createElement('span') + header.append(label) + const scrollContainer = document.createElement('div') + document.body.append(scrollContainer, header) + + const projectGroupById = new Map([ + ['group-a', group('group-a')], + ['group-b', group('group-b')] + ]) + const sidebarProjectGroupHeaderIdsByBucket = new Map([['root', ['group-a', 'group-b']]]) + + const session = createProjectGroupHeaderDragSession({ + event: { + button: 0, + pointerId: 1, + clientX: 10, + clientY: 20, + target: label, + currentTarget: header + } as unknown as React.PointerEvent, + groupId: 'group-a', + projectGroupById, + sidebarProjectGroupHeaderIdsByBucket, + getScrollContainer: () => scrollContainer + }) + + expect(session?.groupId).toBe('group-a') + expect(header.setPointerCapture).not.toHaveBeenCalled() + }) + + it('does not arm from nested Project Group header actions', () => { + const header = document.createElement('div') + header.setAttribute('data-project-group-header-drag-handle', '') + const action = document.createElement('button') + action.type = 'button' + header.append(action) + const scrollContainer = document.createElement('div') + document.body.append(scrollContainer, header) + + const projectGroupById = new Map([ + ['group-a', group('group-a')], + ['group-b', group('group-b')] + ]) + const sidebarProjectGroupHeaderIdsByBucket = new Map([['root', ['group-a', 'group-b']]]) + + const session = createProjectGroupHeaderDragSession({ + event: { + button: 0, + pointerId: 1, + clientX: 10, + clientY: 20, + target: action, + currentTarget: header + } as unknown as React.PointerEvent, + groupId: 'group-a', + projectGroupById, + sidebarProjectGroupHeaderIdsByBucket, + getScrollContainer: () => scrollContainer + }) + + expect(session).toBeNull() + }) +}) diff --git a/src/renderer/src/components/sidebar/project-group-header-drag-start.ts b/src/renderer/src/components/sidebar/project-group-header-drag-start.ts new file mode 100644 index 00000000000..89eb5bd9dd0 --- /dev/null +++ b/src/renderer/src/components/sidebar/project-group-header-drag-start.ts @@ -0,0 +1,64 @@ +import type { PointerEvent } from 'react' + +import { + getProjectGroupHeaderDragBucketKey, + measureProjectGroupHeaderDragRects, + type ProjectGroupHeaderDragBucketKey +} from './project-group-header-drop' +import { + isProjectGroupHeaderActionTarget, + isProjectGroupHeaderDragHandleTarget, + type ProjectGroupHeaderDragSession +} from './project-group-header-drag-contract' +import type { ProjectGroup } from '../../../../shared/types' + +export function createProjectGroupHeaderDragSession(args: { + event: PointerEvent + groupId: string + projectGroupById: ReadonlyMap + sidebarProjectGroupHeaderIdsByBucket: ReadonlyMap< + ProjectGroupHeaderDragBucketKey, + readonly string[] + > + getScrollContainer: () => HTMLElement | null +}): ProjectGroupHeaderDragSession | null { + if (args.event.button !== 0) { + return null + } + if (!isProjectGroupHeaderDragHandleTarget(args.event.target, args.event.currentTarget)) { + return null + } + if (isProjectGroupHeaderActionTarget(args.event.target, args.event.currentTarget)) { + return null + } + const group = args.projectGroupById.get(args.groupId) + if (!group) { + return null + } + const bucketKey = getProjectGroupHeaderDragBucketKey(group, args.projectGroupById) + const sidebarProjectGroupHeaderIds = + args.sidebarProjectGroupHeaderIdsByBucket.get(bucketKey) ?? [] + // Why: a lone group in a parent bucket cannot move without reparenting. + if (sidebarProjectGroupHeaderIds.length <= 1) { + return null + } + const container = args.getScrollContainer() + if (!container) { + return null + } + const handleEl = args.event.currentTarget + // Why: defer pointer capture until the threshold so ordinary group header + // clicks still toggle collapse through the row handler. + return { + groupId: args.groupId, + bucketKey, + sidebarProjectGroupHeaderIds, + pointerId: args.event.pointerId, + headerRects: measureProjectGroupHeaderDragRects(container, bucketKey), + handleEl, + startX: args.event.clientX, + startY: args.event.clientY, + latestPointerY: args.event.clientY, + promoted: false + } +} diff --git a/src/renderer/src/components/sidebar/project-group-header-drag.ts b/src/renderer/src/components/sidebar/project-group-header-drag.ts new file mode 100644 index 00000000000..63c2a723d45 --- /dev/null +++ b/src/renderer/src/components/sidebar/project-group-header-drag.ts @@ -0,0 +1,315 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +import { + computeProjectGroupHeaderDropPreview, + measureProjectGroupHeaderDragRects +} from './project-group-header-drop' +import { commitProjectGroupHeaderDragDrop } from './project-group-header-drag-commit' +import { + INITIAL_PROJECT_GROUP_DRAG_STATE, + PROJECT_GROUP_HEADER_DRAG_THRESHOLD_PX, + type ProjectGroupDragState, + type ProjectGroupHeaderDragController, + type ProjectGroupHeaderDragSession, + type UseProjectGroupHeaderDragArgs +} from './project-group-header-drag-contract' +import { createProjectGroupHeaderDragSession } from './project-group-header-drag-start' +import { getWorktreeSidebarDragAutoscroll } from './worktree-sidebar-drag-autoscroll' + +// Why pointer events instead of HTML5 DnD: Project Group rows are virtualized +// and may unmount while scrolling; cached row-model indices keep drops stable. + +export function useProjectGroupHeaderDrag({ + sidebarProjectGroupHeaderIdsByBucket, + projectGroupById, + onCommitProjectGroupTabOrder, + getScrollContainer +}: UseProjectGroupHeaderDragArgs): ProjectGroupHeaderDragController { + const [state, setState] = useState(INITIAL_PROJECT_GROUP_DRAG_STATE) + const [sessionArmed, setSessionArmed] = useState(false) + const latestDropIndexRef = useRef(null) + latestDropIndexRef.current = state.dropIndex + const sidebarProjectGroupHeaderIdsByBucketRef = useRef(sidebarProjectGroupHeaderIdsByBucket) + sidebarProjectGroupHeaderIdsByBucketRef.current = sidebarProjectGroupHeaderIdsByBucket + const projectGroupByIdRef = useRef(projectGroupById) + projectGroupByIdRef.current = projectGroupById + const onCommitProjectGroupTabOrderRef = useRef(onCommitProjectGroupTabOrder) + onCommitProjectGroupTabOrderRef.current = onCommitProjectGroupTabOrder + const getContainerRef = useRef(getScrollContainer) + getContainerRef.current = getScrollContainer + const autoscrollLastFrameTimeRef = useRef(null) + const autoscrollFrameIdRef = useRef(null) + + const dragSessionRef = useRef(null) + const clickSwallowTimeoutRef = useRef | null>(null) + + const refreshHeaderRects = useCallback(() => { + const container = getContainerRef.current() + const session = dragSessionRef.current + if (!container || !session) { + return [] + } + const rects = measureProjectGroupHeaderDragRects(container, session.bucketKey) + session.headerRects = rects + return rects + }, []) + + const computeDrop = useCallback( + (pointerY: number): { dropIndex: number; dropIndicatorY: number } | null => { + const session = dragSessionRef.current + const container = getContainerRef.current() + if (!session || !container) { + return null + } + const containerRect = container.getBoundingClientRect() + return computeProjectGroupHeaderDropPreview({ + pointerY, + containerTop: containerRect.top, + scrollTop: container.scrollTop, + rects: session.headerRects, + sidebarProjectGroupHeaderIds: session.sidebarProjectGroupHeaderIds + }) + }, + [] + ) + + const applyDrop = useCallback( + (groupId: string, drop: { dropIndex: number; dropIndicatorY: number } | null) => { + latestDropIndexRef.current = drop?.dropIndex ?? null + const nextState: ProjectGroupDragState = drop + ? { draggingGroupId: groupId, ...drop } + : { draggingGroupId: groupId, dropIndex: null, dropIndicatorY: null } + setState((prev) => + prev.draggingGroupId === nextState.draggingGroupId && + prev.dropIndex === nextState.dropIndex && + prev.dropIndicatorY === nextState.dropIndicatorY + ? prev + : nextState + ) + }, + [] + ) + + const cancelAutoscroll = useCallback(() => { + if (autoscrollFrameIdRef.current !== null) { + window.cancelAnimationFrame(autoscrollFrameIdRef.current) + autoscrollFrameIdRef.current = null + } + autoscrollLastFrameTimeRef.current = null + }, []) + + const endDrag = useCallback( + (commit: boolean) => { + cancelAutoscroll() + const session = dragSessionRef.current + if (!session) { + setState(INITIAL_PROJECT_GROUP_DRAG_STATE) + setSessionArmed(false) + return + } + try { + session.handleEl.releasePointerCapture(session.pointerId) + } catch { + // capture may already be released (pointercancel, element unmounted) + } + if (session.promoted) { + const handleEl = session.handleEl + const swallow = (event: MouseEvent): void => { + const target = event.target as Node | null + if (target && handleEl.contains(target)) { + event.stopPropagation() + event.preventDefault() + } + window.removeEventListener('click', swallow, true) + } + window.addEventListener('click', swallow, true) + clickSwallowTimeoutRef.current = setTimeout(() => { + window.removeEventListener('click', swallow, true) + clickSwallowTimeoutRef.current = null + }, 0) + } + const sidebarDropIndex = + commit && session.promoted && latestDropIndexRef.current !== null + ? latestDropIndexRef.current + : null + dragSessionRef.current = null + setState(INITIAL_PROJECT_GROUP_DRAG_STATE) + setSessionArmed(false) + if (sidebarDropIndex === null) { + return + } + + commitProjectGroupHeaderDragDrop({ + session, + sidebarDropIndex, + projectGroupById: projectGroupByIdRef.current, + onCommitProjectGroupTabOrder: onCommitProjectGroupTabOrderRef.current + }) + }, + [cancelAutoscroll] + ) + + const runAutoscrollFrame = useCallback( + (frameTime: number) => { + autoscrollFrameIdRef.current = null + const session = dragSessionRef.current + const container = getContainerRef.current() + if (!session?.promoted || !container) { + cancelAutoscroll() + return + } + + const previousFrameTime = autoscrollLastFrameTimeRef.current ?? frameTime + autoscrollLastFrameTimeRef.current = frameTime + const autoscroll = getWorktreeSidebarDragAutoscroll({ + point: { clientX: 0, clientY: session.latestPointerY }, + containerRect: container.getBoundingClientRect(), + scrollTop: container.scrollTop, + scrollHeight: container.scrollHeight, + clientHeight: container.clientHeight, + elapsedMs: frameTime - previousFrameTime + }) + if (autoscroll) { + container.scrollTop = autoscroll.scrollTop + refreshHeaderRects() + } + + applyDrop(session.groupId, computeDrop(session.latestPointerY)) + + autoscrollFrameIdRef.current = window.requestAnimationFrame(runAutoscrollFrame) + }, + [applyDrop, cancelAutoscroll, computeDrop, refreshHeaderRects] + ) + + const ensureAutoscroll = useCallback(() => { + if (autoscrollFrameIdRef.current !== null) { + return + } + autoscrollLastFrameTimeRef.current = null + autoscrollFrameIdRef.current = window.requestAnimationFrame(runAutoscrollFrame) + }, [runAutoscrollFrame]) + + useEffect(() => { + if (!sessionArmed) { + return + } + const onPointerMove = (event: PointerEvent): void => { + const session = dragSessionRef.current + if (!session || event.pointerId !== session.pointerId) { + return + } + session.latestPointerY = event.clientY + if (!session.promoted) { + const dx = event.clientX - session.startX + const dy = event.clientY - session.startY + if ( + dx * dx + dy * dy < + PROJECT_GROUP_HEADER_DRAG_THRESHOLD_PX * PROJECT_GROUP_HEADER_DRAG_THRESHOLD_PX + ) { + return + } + session.promoted = true + // Why: virtualized headers may detach during drag; global listeners + // still keep the operation alive if pointer capture is unavailable. + if (session.handleEl.isConnected) { + try { + session.handleEl.setPointerCapture(session.pointerId) + } catch { + // Ignore capture failure; global listeners will handle the drag. + } + } + refreshHeaderRects() + setState({ draggingGroupId: session.groupId, dropIndex: null, dropIndicatorY: null }) + } + refreshHeaderRects() + applyDrop(session.groupId, computeDrop(event.clientY)) + ensureAutoscroll() + } + const onPointerUp = (event: PointerEvent): void => { + const session = dragSessionRef.current + if (!session || event.pointerId !== session.pointerId) { + return + } + endDrag(true) + } + const onPointerCancel = (event: PointerEvent): void => { + const session = dragSessionRef.current + if (!session || event.pointerId !== session.pointerId) { + return + } + endDrag(false) + } + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key === 'Escape') { + endDrag(false) + } + } + const onBlur = (): void => endDrag(false) + + window.addEventListener('pointermove', onPointerMove) + window.addEventListener('pointerup', onPointerUp) + window.addEventListener('pointercancel', onPointerCancel) + window.addEventListener('keydown', onKeyDown) + window.addEventListener('blur', onBlur) + return () => { + window.removeEventListener('pointermove', onPointerMove) + window.removeEventListener('pointerup', onPointerUp) + window.removeEventListener('pointercancel', onPointerCancel) + window.removeEventListener('keydown', onKeyDown) + window.removeEventListener('blur', onBlur) + cancelAutoscroll() + if (clickSwallowTimeoutRef.current !== null) { + clearTimeout(clickSwallowTimeoutRef.current) + clickSwallowTimeoutRef.current = null + } + } + }, [ + applyDrop, + cancelAutoscroll, + computeDrop, + endDrag, + ensureAutoscroll, + refreshHeaderRects, + sessionArmed + ]) + + useEffect(() => { + if (state.draggingGroupId === null) { + return + } + const body = document.body + const prevCursor = body.style.cursor + const prevUserSelect = body.style.userSelect + body.style.cursor = 'grabbing' + body.style.userSelect = 'none' + return () => { + body.style.cursor = prevCursor + body.style.userSelect = prevUserSelect + } + }, [state.draggingGroupId]) + + const onHandlePointerDown = useCallback( + (event: React.PointerEvent, groupId: string) => { + const session = createProjectGroupHeaderDragSession({ + event, + groupId, + projectGroupById: projectGroupByIdRef.current, + sidebarProjectGroupHeaderIdsByBucket: sidebarProjectGroupHeaderIdsByBucketRef.current, + getScrollContainer: getContainerRef.current + }) + if (!session) { + return + } + dragSessionRef.current = session + setSessionArmed(true) + }, + [] + ) + + return { state, onHandlePointerDown } +} + +export { + isProjectGroupHeaderActionTarget, + isProjectGroupHeaderDragHandleTarget +} from './project-group-header-drag-contract' diff --git a/src/renderer/src/components/sidebar/project-group-header-drop.test.ts b/src/renderer/src/components/sidebar/project-group-header-drop.test.ts new file mode 100644 index 00000000000..58bd015df4a --- /dev/null +++ b/src/renderer/src/components/sidebar/project-group-header-drop.test.ts @@ -0,0 +1,215 @@ +// @vitest-environment happy-dom +import { describe, expect, it } from 'vitest' + +import { + computeProjectGroupHeaderDropPreview, + getProjectGroupHeaderDragBucketKey, + getProjectGroupTabOrderUpdatesForSidebarDrop, + getSidebarOrderedProjectGroupHeaderIdsByBucket, + mapSidebarProjectGroupDropIndexToSiblingInsertIndex +} from './project-group-header-drop' +import type { Row } from './worktree-list-groups' +import type { ProjectGroup, Repo } from '../../../../shared/types' + +function group(id: string, overrides: Partial = {}): ProjectGroup { + return { + id, + name: id, + parentPath: null, + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +describe('getProjectGroupHeaderDragBucketKey', () => { + it('uses root for top-level groups', () => { + expect(getProjectGroupHeaderDragBucketKey(group('root'))).toBe('root') + }) + + it('scopes child groups to their parent bucket', () => { + const root = group('root') + const child = group('child', { parentGroupId: root.id }) + const groupsById = new Map([ + [root.id, root], + [child.id, child] + ]) + + expect(getProjectGroupHeaderDragBucketKey(child, groupsById)).toBe('parent:root') + }) + + it('falls back to root when persisted parent metadata is missing', () => { + const orphan = group('orphan', { parentGroupId: 'missing' }) + + expect(getProjectGroupHeaderDragBucketKey(orphan, new Map([[orphan.id, orphan]]))).toBe('root') + }) +}) + +describe('getSidebarOrderedProjectGroupHeaderIdsByBucket', () => { + it('groups Project Group headers by effective parent bucket', () => { + const rootA = group('root-a') + const rootB = group('root-b') + const childA = group('child-a', { parentGroupId: rootA.id }) + const repo = { id: 'repo-a', projectGroupId: rootA.id } as Repo + const groupsById = new Map([ + [rootA.id, rootA], + [rootB.id, rootB], + [childA.id, childA] + ]) + const rows = [ + { + type: 'header', + key: 'project-group:root-a', + label: 'A', + count: 0, + tone: '', + projectGroup: rootA + }, + { type: 'header', key: 'repo:repo-a', label: 'repo', count: 0, tone: '', repo }, + { + type: 'header', + key: 'project-group:child-a', + label: 'A child', + count: 0, + tone: '', + projectGroup: childA + }, + { + type: 'header', + key: 'project-group:root-b', + label: 'B', + count: 0, + tone: '', + projectGroup: rootB + } + ] as Row[] + + expect(getSidebarOrderedProjectGroupHeaderIdsByBucket(rows, groupsById)).toEqual( + new Map([ + ['root', ['root-a', 'root-b']], + ['parent:root-a', ['child-a']] + ]) + ) + }) +}) + +describe('mapSidebarProjectGroupDropIndexToSiblingInsertIndex', () => { + it('keeps upward drops at the same target index after removing the source', () => { + expect( + mapSidebarProjectGroupDropIndexToSiblingInsertIndex({ + sidebarDropIndex: 0, + sourceIndex: 2, + siblingCount: 2 + }) + ).toBe(0) + }) + + it('shifts downward drops because the source header is removed first', () => { + expect( + mapSidebarProjectGroupDropIndexToSiblingInsertIndex({ + sidebarDropIndex: 3, + sourceIndex: 0, + siblingCount: 2 + }) + ).toBe(2) + }) +}) + +describe('computeProjectGroupHeaderDropPreview', () => { + it('uses row-model header indices instead of mounted subset order', () => { + const preview = computeProjectGroupHeaderDropPreview({ + pointerY: 105, + containerTop: 0, + scrollTop: 0, + sidebarProjectGroupHeaderIds: ['a', 'b', 'c', 'd', 'e'], + rects: [ + { groupId: 'b', bucketKey: 'root', headerIndex: 1, top: 100, bottom: 128 }, + { groupId: 'c', bucketKey: 'root', headerIndex: 2, top: 200, bottom: 228 }, + { groupId: 'd', bucketKey: 'root', headerIndex: 3, top: 300, bottom: 328 } + ] + }) + + expect(preview).toEqual({ dropIndex: 1, dropIndicatorY: 96 }) + }) + + it('does not create a drop slot inside an expanded Project Group section', () => { + const preview = computeProjectGroupHeaderDropPreview({ + pointerY: 350, + containerTop: 0, + scrollTop: 0, + sidebarProjectGroupHeaderIds: ['a', 'b', 'c'], + rects: [ + { + groupId: 'c', + bucketKey: 'root', + headerIndex: 2, + top: 300, + bottom: 328, + sectionBottom: 380 + } + ] + }) + + expect(preview).toBeNull() + }) + + it('uses the whole Project Group section for the final boundary slot', () => { + const preview = computeProjectGroupHeaderDropPreview({ + pointerY: 400, + containerTop: 0, + scrollTop: 0, + sidebarProjectGroupHeaderIds: ['a', 'b', 'c'], + rects: [ + { + groupId: 'c', + bucketKey: 'root', + headerIndex: 2, + top: 300, + bottom: 328, + sectionBottom: 380 + } + ] + }) + + expect(preview).toEqual({ dropIndex: 3, dropIndicatorY: 383 }) + }) +}) + +describe('getProjectGroupTabOrderUpdatesForSidebarDrop', () => { + it('reindexes sibling groups so duplicate legacy tabOrder values can move between siblings', () => { + const groups = [group('a'), group('b'), group('c'), group('d')] + const projectGroupById = new Map(groups.map((entry) => [entry.id, entry])) + + expect( + getProjectGroupTabOrderUpdatesForSidebarDrop({ + sidebarProjectGroupHeaderIds: ['a', 'b', 'c', 'd'], + draggedGroupId: 'd', + sidebarDropIndex: 2, + projectGroupById + }) + ).toEqual([ + { groupId: 'b', tabOrder: 1 }, + { groupId: 'd', tabOrder: 2 }, + { groupId: 'c', tabOrder: 3 } + ]) + }) + + it('returns no updates when the drop keeps the group in the same slot', () => { + const groups = [group('a', { tabOrder: 0 }), group('b', { tabOrder: 1 })] + const projectGroupById = new Map(groups.map((entry) => [entry.id, entry])) + + expect( + getProjectGroupTabOrderUpdatesForSidebarDrop({ + sidebarProjectGroupHeaderIds: ['a', 'b'], + draggedGroupId: 'a', + sidebarDropIndex: 1, + projectGroupById + }) + ).toEqual([]) + }) +}) diff --git a/src/renderer/src/components/sidebar/project-group-header-drop.ts b/src/renderer/src/components/sidebar/project-group-header-drop.ts new file mode 100644 index 00000000000..512b636717f --- /dev/null +++ b/src/renderer/src/components/sidebar/project-group-header-drop.ts @@ -0,0 +1,197 @@ +import { + computeWorktreeSidebarHeaderDropPreview, + type WorktreeSidebarHeaderDropPreview +} from './worktree-sidebar-header-drop-preview' +import type { Row } from './worktree-list-groups' +import type { ProjectGroup } from '../../../../shared/types' + +export type ProjectGroupHeaderDragBucketKey = string + +export type ProjectGroupHeaderDragRect = { + groupId: string + bucketKey: ProjectGroupHeaderDragBucketKey + // Index among sibling Project Group headers in the row model, not mounted DOM. + headerIndex: number + top: number + bottom: number + sectionBottom?: number +} + +export type ProjectGroupHeaderDropPreview = WorktreeSidebarHeaderDropPreview + +export type ProjectGroupTabOrderUpdate = { + groupId: string + tabOrder: number +} + +const ROOT_PROJECT_GROUP_HEADER_BUCKET = 'root' + +type SidebarProjectGroupHeader = ProjectGroup | { id: null } | undefined + +function isConcreteProjectGroup( + projectGroup: SidebarProjectGroupHeader +): projectGroup is ProjectGroup { + return typeof projectGroup?.id === 'string' +} + +export function getProjectGroupHeaderDragBucketKey( + group: Pick, + projectGroupById?: ReadonlyMap +): ProjectGroupHeaderDragBucketKey { + const parentGroupId = group.parentGroupId ?? null + if (!parentGroupId) { + return ROOT_PROJECT_GROUP_HEADER_BUCKET + } + if (projectGroupById && !projectGroupById.has(parentGroupId)) { + return ROOT_PROJECT_GROUP_HEADER_BUCKET + } + return `parent:${parentGroupId}` +} + +export function getSidebarOrderedProjectGroupHeaderIdsByBucket( + rows: readonly Row[], + projectGroupById?: ReadonlyMap +): Map { + const buckets = new Map() + for (const row of rows) { + if (row.type !== 'header' || row.repo || !isConcreteProjectGroup(row.projectGroup)) { + continue + } + const bucketKey = getProjectGroupHeaderDragBucketKey(row.projectGroup, projectGroupById) + const list = buckets.get(bucketKey) ?? [] + list.push(row.projectGroup.id) + buckets.set(bucketKey, list) + } + return buckets +} + +export function mapSidebarProjectGroupDropIndexToSiblingInsertIndex(args: { + sidebarDropIndex: number + sourceIndex: number + siblingCount: number +}): number { + // Why: sidebar drop indices include the dragged header, but tabOrder is + // computed against the sibling list after that header is removed. + const adjustedDropIndex = + args.sourceIndex >= 0 && args.sidebarDropIndex > args.sourceIndex + ? args.sidebarDropIndex - 1 + : args.sidebarDropIndex + return Math.max(0, Math.min(args.siblingCount, adjustedDropIndex)) +} + +export function getProjectGroupTabOrderUpdatesForSidebarDrop(args: { + sidebarProjectGroupHeaderIds: readonly string[] + draggedGroupId: string + sidebarDropIndex: number + projectGroupById: ReadonlyMap +}): ProjectGroupTabOrderUpdate[] { + const sourceIndex = args.sidebarProjectGroupHeaderIds.indexOf(args.draggedGroupId) + if (sourceIndex === -1) { + return [] + } + const siblingIds = args.sidebarProjectGroupHeaderIds.filter( + (groupId) => groupId !== args.draggedGroupId + ) + const siblingDropIndex = mapSidebarProjectGroupDropIndexToSiblingInsertIndex({ + sidebarDropIndex: args.sidebarDropIndex, + sourceIndex, + siblingCount: siblingIds.length + }) + const sourceIndexInSiblings = Math.min(sourceIndex, siblingIds.length) + if (siblingDropIndex === sourceIndexInSiblings) { + return [] + } + + const orderedIds = siblingIds.slice() + orderedIds.splice(siblingDropIndex, 0, args.draggedGroupId) + + const updates: ProjectGroupTabOrderUpdate[] = [] + for (const [index, groupId] of orderedIds.entries()) { + const group = args.projectGroupById.get(groupId) + if (!group) { + continue + } + // Why: legacy groups can all have tabOrder=0, so a one-row midpoint update + // cannot express an insertion inside that equal-rank block. + if (group.tabOrder !== index) { + updates.push({ groupId, tabOrder: index }) + } + } + return updates +} + +function getVirtualRowStart(virtualRow: HTMLElement | null): number | null { + if (!virtualRow) { + return null + } + const rawStart = virtualRow.getAttribute('data-worktree-virtual-row-start') + if (rawStart === null) { + return null + } + const start = Number(rawStart) + return Number.isFinite(start) ? start : null +} + +function getOptionalNumberAttribute(element: HTMLElement, attribute: string): number | undefined { + const rawValue = element.getAttribute(attribute) + if (rawValue === null) { + return undefined + } + const value = Number(rawValue) + return Number.isFinite(value) ? value : undefined +} + +export function measureProjectGroupHeaderDragRects( + container: HTMLElement, + bucketKey?: ProjectGroupHeaderDragBucketKey +): ProjectGroupHeaderDragRect[] { + const containerRect = container.getBoundingClientRect() + const rects: ProjectGroupHeaderDragRect[] = [] + container.querySelectorAll('[data-project-group-header-id]').forEach((element) => { + const groupId = element.getAttribute('data-project-group-header-id') + const elementBucketKey = element.getAttribute('data-project-group-header-bucket') + const rawHeaderIndex = element.getAttribute('data-project-group-header-index') + const headerIndex = rawHeaderIndex === null ? Number.NaN : Number(rawHeaderIndex) + if (!groupId || !elementBucketKey || !Number.isFinite(headerIndex)) { + return + } + if (bucketKey !== undefined && elementBucketKey !== bucketKey) { + return + } + const rect = element.getBoundingClientRect() + const virtualRow = element.closest('[data-worktree-virtual-row]') + const virtualRowStart = getVirtualRowStart(virtualRow) + const top = + virtualRow && virtualRowStart !== null + ? virtualRowStart + rect.top - virtualRow.getBoundingClientRect().top + : rect.top - containerRect.top + container.scrollTop + rects.push({ + groupId, + bucketKey: elementBucketKey, + headerIndex, + top, + bottom: top + rect.height, + sectionBottom: getOptionalNumberAttribute(element, 'data-project-group-header-section-end') + }) + }) + rects.sort((left, right) => left.top - right.top) + return rects +} + +export function computeProjectGroupHeaderDropPreview(args: { + pointerY: number + containerTop: number + scrollTop: number + rects: readonly ProjectGroupHeaderDragRect[] + sidebarProjectGroupHeaderIds: readonly string[] +}): ProjectGroupHeaderDropPreview | null { + const { rects, sidebarProjectGroupHeaderIds } = args + return computeWorktreeSidebarHeaderDropPreview({ + pointerY: args.pointerY, + containerTop: args.containerTop, + scrollTop: args.scrollTop, + rects, + headerCount: sidebarProjectGroupHeaderIds.length, + getId: (rect) => rect.groupId + }) +} diff --git a/src/renderer/src/components/sidebar/project-header-drag-start.test.ts b/src/renderer/src/components/sidebar/project-header-drag-start.test.ts index 98e6fe082d1..0febdaf3cc4 100644 --- a/src/renderer/src/components/sidebar/project-header-drag-start.test.ts +++ b/src/renderer/src/components/sidebar/project-header-drag-start.test.ts @@ -46,12 +46,11 @@ describe('createProjectHeaderDragSession', () => { expect(handleEl.setPointerCapture).not.toHaveBeenCalled() }) - it('does not arm drag when the pointer starts outside the project icon handle', () => { + it('arms a drag session from plain project header text when the row is the drag handle', () => { const header = document.createElement('div') - const handleEl = document.createElement('div') - handleEl.setAttribute('data-repo-header-drag-handle', '') - const chevron = document.createElement('span') - header.append(handleEl, chevron) + header.setAttribute('data-repo-header-drag-handle', '') + const label = document.createElement('span') + header.append(label) const scrollContainer = document.createElement('div') document.body.append(scrollContainer, header) @@ -64,7 +63,7 @@ describe('createProjectHeaderDragSession', () => { pointerId: 1, clientX: 10, clientY: 20, - target: chevron, + target: label, currentTarget: header } as unknown as React.PointerEvent, repoId: 'repo-a', @@ -73,6 +72,6 @@ describe('createProjectHeaderDragSession', () => { getScrollContainer: () => scrollContainer }) - expect(session).toBeNull() + expect(session?.repoId).toBe('repo-a') }) }) diff --git a/src/renderer/src/components/sidebar/project-header-drag.ts b/src/renderer/src/components/sidebar/project-header-drag.ts index 2b666c8bdf6..e4fccc57c9f 100644 --- a/src/renderer/src/components/sidebar/project-header-drag.ts +++ b/src/renderer/src/components/sidebar/project-header-drag.ts @@ -84,6 +84,23 @@ export function useRepoHeaderDrag({ [] ) + const applyDrop = useCallback( + (repoId: string, drop: { dropIndex: number; dropIndicatorY: number } | null) => { + latestDropIndexRef.current = drop?.dropIndex ?? null + const nextState: RepoDragState = drop + ? { draggingRepoId: repoId, ...drop } + : { draggingRepoId: repoId, dropIndex: null, dropIndicatorY: null } + setState((prev) => + prev.draggingRepoId === nextState.draggingRepoId && + prev.dropIndex === nextState.dropIndex && + prev.dropIndicatorY === nextState.dropIndicatorY + ? prev + : nextState + ) + }, + [] + ) + const cancelAutoscroll = useCallback(() => { if (autoscrollFrameIdRef.current !== null) { window.cancelAnimationFrame(autoscrollFrameIdRef.current) @@ -171,18 +188,11 @@ export function useRepoHeaderDrag({ refreshHeaderRects() } - const drop = computeDrop(session.latestPointerY) - if (drop) { - setState((prev) => - prev.dropIndex === drop.dropIndex && prev.dropIndicatorY === drop.dropIndicatorY - ? prev - : { draggingRepoId: session.repoId, ...drop } - ) - } + applyDrop(session.repoId, computeDrop(session.latestPointerY)) autoscrollFrameIdRef.current = window.requestAnimationFrame(runAutoscrollFrame) }, - [cancelAutoscroll, computeDrop, refreshHeaderRects] + [applyDrop, cancelAutoscroll, computeDrop, refreshHeaderRects] ) const ensureAutoscroll = useCallback(() => { @@ -227,14 +237,7 @@ export function useRepoHeaderDrag({ setState({ draggingRepoId: session.repoId, dropIndex: null, dropIndicatorY: null }) } refreshHeaderRects() - const drop = computeDrop(e.clientY) - if (drop) { - setState((prev) => - prev.dropIndex === drop.dropIndex && prev.dropIndicatorY === drop.dropIndicatorY - ? prev - : { draggingRepoId: session.repoId, ...drop } - ) - } + applyDrop(session.repoId, computeDrop(e.clientY)) ensureAutoscroll() } const onPointerUp = (e: PointerEvent): void => { @@ -275,7 +278,15 @@ export function useRepoHeaderDrag({ clickSwallowTimeoutRef.current = null } } - }, [cancelAutoscroll, computeDrop, endDrag, ensureAutoscroll, refreshHeaderRects, sessionArmed]) + }, [ + applyDrop, + cancelAutoscroll, + computeDrop, + endDrag, + ensureAutoscroll, + refreshHeaderRects, + sessionArmed + ]) useEffect(() => { if (state.draggingRepoId === null) { 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 0ab58d0a192..c943ca97594 100644 --- a/src/renderer/src/components/sidebar/project-header-drop.test.ts +++ b/src/renderer/src/components/sidebar/project-header-drop.test.ts @@ -120,14 +120,66 @@ describe('computeProjectHeaderDropPreview', () => { it('supports boundary drops at the end of the full sidebar list', () => { const preview = computeProjectHeaderDropPreview({ - pointerY: 360, + pointerY: 400, containerTop: 0, scrollTop: 0, sidebarRepoHeaderIds: ['a', 'b', 'c'], - rects: [{ repoId: 'c', bucketKey: 'ungrouped', headerIndex: 2, top: 300, bottom: 328 }] + rects: [ + { + repoId: 'c', + bucketKey: 'ungrouped', + headerIndex: 2, + top: 300, + bottom: 328, + sectionBottom: 380 + } + ] }) - expect(preview).toEqual({ dropIndex: 3, dropIndicatorY: 331 }) + expect(preview).toEqual({ dropIndex: 3, dropIndicatorY: 383 }) + }) + + it('does not create a drop slot inside an expanded project section', () => { + const preview = computeProjectHeaderDropPreview({ + pointerY: 350, + containerTop: 0, + scrollTop: 0, + sidebarRepoHeaderIds: ['a', 'b', 'c'], + rects: [ + { + repoId: 'c', + bucketKey: 'ungrouped', + headerIndex: 2, + top: 300, + bottom: 328, + sectionBottom: 380 + } + ] + }) + + expect(preview).toBeNull() + }) + + it('does not create a drop slot in the contents between sibling project headers', () => { + const preview = computeProjectHeaderDropPreview({ + pointerY: 150, + containerTop: 0, + scrollTop: 0, + sidebarRepoHeaderIds: ['a', 'b'], + rects: [ + { + repoId: 'a', + bucketKey: 'ungrouped', + headerIndex: 0, + top: 100, + bottom: 128, + sectionBottom: 220 + }, + { repoId: 'b', bucketKey: 'ungrouped', headerIndex: 1, top: 220, bottom: 248 } + ] + }) + + expect(preview).toBeNull() }) }) diff --git a/src/renderer/src/components/sidebar/project-header-drop.ts b/src/renderer/src/components/sidebar/project-header-drop.ts index c8f988b5736..478b2b1b7f8 100644 --- a/src/renderer/src/components/sidebar/project-header-drop.ts +++ b/src/renderer/src/components/sidebar/project-header-drop.ts @@ -1,5 +1,8 @@ import { getEffectiveProjectGroupManualRank } from '../../../../shared/project-groups' -import { getWorktreeSidebarBoundaryDrop } from './worktree-sidebar-drag-autoscroll' +import { + computeWorktreeSidebarHeaderDropPreview, + type WorktreeSidebarHeaderDropPreview +} from './worktree-sidebar-header-drop-preview' import type { Row } from './worktree-list-groups' import type { Repo } from '../../../../shared/types' @@ -14,14 +17,10 @@ export type ProjectHeaderDragRect = { headerIndex: number top: number bottom: number + sectionBottom?: number } -export type ProjectHeaderDropPreview = { - dropIndex: number - dropIndicatorY: number -} - -const INDICATOR_GAP_PX = 4 +export type ProjectHeaderDropPreview = WorktreeSidebarHeaderDropPreview export function getProjectHeaderDragBucketKey( repo: Pick @@ -115,6 +114,15 @@ function getVirtualRowStart(virtualRow: HTMLElement | null): number | null { return Number.isFinite(start) ? start : null } +function getOptionalNumberAttribute(element: HTMLElement, attribute: string): number | undefined { + const rawValue = element.getAttribute(attribute) + if (rawValue === null) { + return undefined + } + const value = Number(rawValue) + return Number.isFinite(value) ? value : undefined +} + export function measureProjectHeaderDragRects( container: HTMLElement, bucketKey?: ProjectHeaderDragBucketKey @@ -144,7 +152,8 @@ export function measureProjectHeaderDragRects( bucketKey: elementBucketKey, headerIndex, top, - bottom: top + rect.height + bottom: top + rect.height, + sectionBottom: getOptionalNumberAttribute(element, 'data-repo-header-section-end') }) }) rects.sort((left, right) => left.top - right.top) @@ -177,53 +186,14 @@ export function computeProjectHeaderDropPreview(args: { sidebarRepoHeaderIds: readonly string[] }): ProjectHeaderDropPreview | null { const { rects, sidebarRepoHeaderIds } = args - if (rects.length === 0 || sidebarRepoHeaderIds.length === 0) { - return null - } - - const localY = args.pointerY - args.containerTop + args.scrollTop - const first = rects[0]! - const last = rects.at(-1)! - const boundaryDrop = getWorktreeSidebarBoundaryDrop({ - localY, - firstRect: { - worktreeId: first.repoId, - groupIndex: first.headerIndex, - top: first.top, - bottom: first.bottom - }, - lastRect: { - worktreeId: last.repoId, - groupIndex: last.headerIndex, - top: last.top, - bottom: last.bottom - }, - sourceGroupSize: sidebarRepoHeaderIds.length + return computeWorktreeSidebarHeaderDropPreview({ + pointerY: args.pointerY, + containerTop: args.containerTop, + scrollTop: args.scrollTop, + rects, + headerCount: sidebarRepoHeaderIds.length, + getId: (rect) => rect.repoId }) - if (boundaryDrop.kind === 'outside') { - return null - } - - let dropIndex = last.headerIndex + 1 - let indicatorY = last.bottom + INDICATOR_GAP_PX - if (boundaryDrop.kind === 'drop') { - dropIndex = boundaryDrop.dropIndex - indicatorY = boundaryDrop.indicatorY - } else { - for (const rect of rects) { - const mid = (rect.top + rect.bottom) / 2 - if (localY < mid) { - dropIndex = rect.headerIndex - indicatorY = Math.max(0, rect.top - INDICATOR_GAP_PX) - break - } - } - } - - return { - dropIndex, - dropIndicatorY: Math.max(args.scrollTop, indicatorY) - } } export function applyAllRepoInsertAt( diff --git a/src/renderer/src/components/sidebar/smart-sort.test.ts b/src/renderer/src/components/sidebar/smart-sort.test.ts index 34092befe9a..cb61490c90a 100644 --- a/src/renderer/src/components/sidebar/smart-sort.test.ts +++ b/src/renderer/src/components/sidebar/smart-sort.test.ts @@ -458,6 +458,22 @@ describe('sortWorktreesSmart — cold start fallback', () => { expect(sorted.map((w) => w.id)).toEqual(['b', 'a']) }) + it('falls back to the path label when a persisted worktree has no displayName', () => { + const missingDisplayName = { + ...makeWorktree({ + id: 'missing-display-name', + path: '/tmp/alpha-path', + sortOrder: 1 + }), + displayName: undefined + } as unknown as Worktree + const named = makeWorktree({ id: 'named', displayName: 'Zulu', sortOrder: 1 }) + + const sorted = sortWorktreesSmart([named, missingDisplayName], {}, repoMap, {}, {}, {}) + + expect(sorted.map((w) => w.id)).toEqual(['missing-display-name', 'named']) + }) + it('treats slept tabs (tab.ptyId without live entry) as cold start', () => { // Why: tab.ptyId is the wake-hint sessionId preserved under sleep — not a // liveness signal. With slept tabs but no live PTYs, sortWorktreesSmart diff --git a/src/renderer/src/components/sidebar/smart-sort.ts b/src/renderer/src/components/sidebar/smart-sort.ts index 4c87740a944..616e23bc039 100644 --- a/src/renderer/src/components/sidebar/smart-sort.ts +++ b/src/renderer/src/components/sidebar/smart-sort.ts @@ -4,6 +4,7 @@ import type { MigrationUnsupportedPtyEntry } from '../../../../shared/agent-status-types' import { tabHasLivePty } from '@/lib/tab-has-live-pty' +import { basename } from '@/lib/path' import { IDLE, buildAttentionByWorktree, type WorktreeAttention } from './smart-attention' export type SortBy = 'name' | 'smart' | 'recent' | 'repo' | 'manual' @@ -40,6 +41,27 @@ export function effectiveRecentActivity(worktree: Worktree, now: number): number return Math.max(lastActivityAt, createdAt + CREATE_GRACE_MS) } +type WorktreeSortLabelInput = Pick + +export function getWorktreeSortLabel(worktree: WorktreeSortLabelInput): string { + const displayName = typeof worktree.displayName === 'string' ? worktree.displayName.trim() : '' + if (displayName) { + return displayName + } + + // Why: persisted or remote worktree state can briefly omit displayName after + // a custom workspace name is removed; sorting must stay render-safe. + const pathLabel = typeof worktree.path === 'string' ? basename(worktree.path).trim() : '' + return pathLabel || worktree.id +} + +export function compareWorktreeSortLabel( + a: WorktreeSortLabelInput, + b: WorktreeSortLabelInput +): number { + return getWorktreeSortLabel(a).localeCompare(getWorktreeSortLabel(b)) +} + /** * Build a comparator for sorting worktrees based on the current sort mode. * @@ -58,7 +80,7 @@ export function buildWorktreeComparator( return (a, b) => { switch (sortBy) { case 'name': - return a.displayName.localeCompare(b.displayName) + return compareWorktreeSortLabel(a, b) case 'smart': { const aw = attentionByWorktree.get(a.id) ?? IDLE const bw = attentionByWorktree.get(b.id) ?? IDLE @@ -70,7 +92,7 @@ export function buildWorktreeComparator( // Why: idle worktrees fall through to recency (and the create-grace // floor for brand-new worktrees) before alphabetical. effectiveRecentActivity(b, now) - effectiveRecentActivity(a, now) || - a.displayName.localeCompare(b.displayName) + compareWorktreeSortLabel(a, b) ) } case 'recent': @@ -87,13 +109,13 @@ export function buildWorktreeComparator( // events) and by meaningful meta edits (comment, isUnread). return ( effectiveRecentActivity(b, now) - effectiveRecentActivity(a, now) || - a.displayName.localeCompare(b.displayName) + compareWorktreeSortLabel(a, b) ) case 'repo': { const ra = repoMap.get(a.repoId)?.displayName ?? '' const rb = repoMap.get(b.repoId)?.displayName ?? '' const cmp = ra.localeCompare(rb) - return cmp !== 0 ? cmp : a.displayName.localeCompare(b.displayName) + return cmp !== 0 ? cmp : compareWorktreeSortLabel(a, b) } case 'manual': // Why fallback to sortOrder: existing users have a persisted smart-sort @@ -101,7 +123,7 @@ export function buildWorktreeComparator( // restored order instead of alphabetizing every legacy workspace. return ( (b.manualOrder ?? b.sortOrder) - (a.manualOrder ?? a.sortOrder) || - a.displayName.localeCompare(b.displayName) + compareWorktreeSortLabel(a, b) ) } } @@ -143,7 +165,7 @@ export function sortWorktreesSmart( // Cold start: use persisted sortOrder snapshot until the agent-status // snapshot lands and a warm sort runs. return [...worktrees].sort( - (a, b) => b.sortOrder - a.sortOrder || a.displayName.localeCompare(b.displayName) + (a, b) => b.sortOrder - a.sortOrder || compareWorktreeSortLabel(a, b) ) } diff --git a/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts b/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts new file mode 100644 index 00000000000..cd1cd54e514 --- /dev/null +++ b/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts @@ -0,0 +1,155 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as ReactModule from 'react' +import type { NestedRepoScanResult, ProjectGroupImportResult, Repo } from '../../../../shared/types' + +vi.mock('react', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useCallback: unknown>(fn: T) => fn, + useRef: (value: T) => ({ current: value }) + } +}) + +const folderRepo: Repo = { + id: 'platform', + path: '/workspace/platform', + displayName: 'platform', + badgeColor: '#999999', + addedAt: 1, + kind: 'folder' +} + +const mocks = vi.hoisted(() => ({ + state: { + repos: [] as Repo[], + addNonGitFolder: vi.fn(), + closeModal: vi.fn(), + openModal: vi.fn() + } +})) + +vi.mock('@/store', () => { + const useAppStore = Object.assign( + (selector: (state: typeof mocks.state) => unknown) => selector(mocks.state), + { + getState: () => mocks.state + } + ) + return { useAppStore } +}) + +vi.mock('sonner', () => ({ + toast: { + error: vi.fn(), + warning: vi.fn() + } +})) + +vi.mock('@/lib/telemetry', () => ({ + track: vi.fn() +})) + +import { track } from '@/lib/telemetry' +import { useAddRepoNestedImportFlow } from './useAddRepoNestedImportFlow' + +const scan: NestedRepoScanResult = { + selectedPath: '/workspace/platform', + selectedPathKind: 'non_git_folder', + repos: [{ path: '/workspace/platform/app', displayName: 'app', depth: 1 }], + truncated: false, + timedOut: false, + stopped: false, + durationMs: 3, + maxDepth: 3, + maxRepos: 100, + timeoutMs: null +} + +function useTestAddRepoNestedImportFlow( + overrides: Partial[0]> = {} +): ReturnType { + return useAddRepoNestedImportFlow({ + nestedAttemptId: 'attempt-1', + nestedScan: scan, + nestedSelectedPaths: new Set(), + nestedRuntimeKind: 'local', + nestedConnectionId: null, + nestedGroupName: 'platform', + nestedImportScanId: 'scan-1', + activeRuntimeEnvironmentId: null, + fetchWorktrees: vi.fn(), + importNestedRepos: vi.fn<() => Promise>(), + getNestedRepoRuntimeKind: vi.fn(() => 'local' as const), + onGitRepoReady: vi.fn(), + setIsAdding: vi.fn(), + ...overrides + }) +} + +describe('useAddRepoNestedImportFlow open folder fallback', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.state.repos = [] + mocks.state.addNonGitFolder.mockResolvedValue(folderRepo) + }) + + it('opens the scanned local root through the existing non-git folder flow', async () => { + const setIsAdding = vi.fn() + const { handleOpenNestedRootFolder } = useTestAddRepoNestedImportFlow({ setIsAdding }) + + await handleOpenNestedRootFolder() + + expect(mocks.state.addNonGitFolder).toHaveBeenCalledWith('/workspace/platform', { + runtimeEnvironmentId: null + }) + expect(mocks.state.closeModal).toHaveBeenCalledTimes(1) + expect(setIsAdding).toHaveBeenNthCalledWith(1, true) + expect(setIsAdding).toHaveBeenNthCalledWith(2, false) + }) + + it('keeps runtime folder opens on the runtime that produced the scan', async () => { + const { handleOpenNestedRootFolder } = useTestAddRepoNestedImportFlow({ + activeRuntimeEnvironmentId: 'env-1' + }) + + await handleOpenNestedRootFolder() + + expect(mocks.state.addNonGitFolder).toHaveBeenCalledWith('/workspace/platform', { + runtimeEnvironmentId: 'env-1' + }) + }) + + it('tracks the open-as-folder recovery action with zero selection', async () => { + const { handleOpenNestedRootFolder } = useTestAddRepoNestedImportFlow() + + await handleOpenNestedRootFolder() + + expect(track).toHaveBeenCalledWith( + 'add_repo_nested_import_action', + expect.objectContaining({ + action: 'open_as_folder', + surface: 'sidebar', + runtime_kind: 'local', + found_count: 1, + selected_count: 0 + }) + ) + }) + + it('uses the existing SSH non-git folder confirmation for SSH scans', async () => { + const { handleOpenNestedRootFolder } = useTestAddRepoNestedImportFlow({ + nestedConnectionId: 'ssh-builder', + nestedRuntimeKind: 'ssh' + }) + + await handleOpenNestedRootFolder() + + expect(mocks.state.addNonGitFolder).not.toHaveBeenCalled() + expect(mocks.state.closeModal).toHaveBeenCalledTimes(1) + expect(mocks.state.openModal).toHaveBeenCalledWith('confirm-non-git-folder', { + folderPath: '/workspace/platform', + connectionId: 'ssh-builder' + }) + }) +}) diff --git a/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.ts b/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.ts index b71012d50bb..eacb421224f 100644 --- a/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.ts +++ b/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.ts @@ -50,6 +50,7 @@ export function useAddRepoNestedImportFlow({ setIsAdding: (isAdding: boolean) => void }): { handleImportNestedRepos: (mode: 'group' | 'separate') => Promise + handleOpenNestedRootFolder: () => Promise resetNestedImportFlow: () => void trackNestedBackAction: () => void } { @@ -236,5 +237,69 @@ export function useAddRepoNestedImportFlow({ ] ) - return { handleImportNestedRepos, resetNestedImportFlow, trackNestedBackAction } + const handleOpenNestedRootFolder = useCallback(async (): Promise => { + if (!nestedScan) { + return + } + const gen = ++nestedImportGenRef.current + const path = nestedScan.selectedPath + if (nestedAttemptId) { + track( + 'add_repo_nested_import_action', + buildNestedRepoImportActionTelemetry({ + attemptId: nestedAttemptId, + surface: 'sidebar', + runtimeKind: nestedRuntimeKind ?? getNestedRepoRuntimeKind(nestedConnectionId), + action: 'open_as_folder', + foundCount: nestedScan.repos.length, + selectedCount: nestedSelectedPaths.size + }) + ) + } + setIsAdding(true) + try { + const state = useAppStore.getState() + if (nestedConnectionId) { + state.closeModal() + state.openModal('confirm-non-git-folder', { + folderPath: path, + connectionId: nestedConnectionId + }) + return + } + const repo = await state.addNonGitFolder(path, { + runtimeEnvironmentId: activeRuntimeEnvironmentId?.trim() || null + }) + if (gen !== nestedImportGenRef.current) { + return + } + if (repo) { + useAppStore.getState().closeModal() + } + } catch (err) { + if (gen === nestedImportGenRef.current) { + toast.error(err instanceof Error ? err.message : String(err)) + } + } finally { + if (gen === nestedImportGenRef.current) { + setIsAdding(false) + } + } + }, [ + activeRuntimeEnvironmentId, + getNestedRepoRuntimeKind, + nestedAttemptId, + nestedConnectionId, + nestedRuntimeKind, + nestedScan, + nestedSelectedPaths.size, + setIsAdding + ]) + + return { + handleImportNestedRepos, + handleOpenNestedRootFolder, + resetNestedImportFlow, + trackNestedBackAction + } } diff --git a/src/renderer/src/components/sidebar/worktree-card-meta-types.ts b/src/renderer/src/components/sidebar/worktree-card-meta-types.ts index f0393848db6..faf61492483 100644 --- a/src/renderer/src/components/sidebar/worktree-card-meta-types.ts +++ b/src/renderer/src/components/sidebar/worktree-card-meta-types.ts @@ -37,10 +37,13 @@ export type WorktreeCardDetailsHoverProps = WorktreeCardMetaBadgesProps & { branchName?: string workspaceTitle?: string identityOrder?: 'workspace-first' | 'branch-first' + workspaceTitleRenameDisabled?: boolean automationHostId?: ExecutionHostId detailsAfter?: React.ReactNode openDelay?: number closeDelay?: number + onRenameWorkspaceTitle?: (displayName: string) => Promise | void + onWorkspaceTitleEditingChange?: (editing: boolean) => void onEditIssue?: (event: React.MouseEvent) => void onEditComment?: (event: React.MouseEvent) => void onOpenGitHubIssueInOrca?: (event: React.MouseEvent) => void diff --git a/src/renderer/src/components/sidebar/worktree-card-pr-display.test.ts b/src/renderer/src/components/sidebar/worktree-card-pr-display.test.ts index 4b2896852a2..71702a23b5b 100644 --- a/src/renderer/src/components/sidebar/worktree-card-pr-display.test.ts +++ b/src/renderer/src/components/sidebar/worktree-card-pr-display.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest' import type { HostedReviewInfo } from '../../../../shared/hosted-review' -import { getWorktreeCardPrDisplay } from './worktree-card-pr-display' +import type { PRInfo } from '../../../../shared/types' +import { + getWorktreeCardPrDisplay, + isCachedMergedBranchPRCurrentForWorktree +} from './worktree-card-pr-display' const pr: HostedReviewInfo = { provider: 'github', @@ -104,3 +108,38 @@ describe('getWorktreeCardPrDisplay', () => { expect(getWorktreeCardPrDisplay(bitbucketReview, null)).toBe(bitbucketReview) }) }) + +describe('isCachedMergedBranchPRCurrentForWorktree', () => { + const mergedPR: PRInfo = { + number: 55, + title: 'Merged PR', + state: 'merged', + url: 'https://github.com/stablyai/orca/pull/55', + checksStatus: 'success', + updatedAt: '2026-07-03T00:00:00.000Z', + mergeable: 'MERGEABLE', + headSha: 'final-head' + } + + it('matches when the worktree sits exactly on the merged head', () => { + expect(isCachedMergedBranchPRCurrentForWorktree(mergedPR, { head: 'final-head' })).toBe(true) + }) + + it('matches when the worktree head is a confirmed commit of the merged PR', () => { + expect( + isCachedMergedBranchPRCurrentForWorktree( + { ...mergedPR, confirmedContainedHeadOid: 'behind-head' }, + { head: 'behind-head' } + ) + ).toBe(true) + }) + + it('rejects a merged PR when the worktree head is neither the final head nor confirmed', () => { + expect( + isCachedMergedBranchPRCurrentForWorktree( + { ...mergedPR, confirmedContainedHeadOid: 'other-head' }, + { head: 'reused-branch-head' } + ) + ).toBe(false) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-card-pr-display.ts b/src/renderer/src/components/sidebar/worktree-card-pr-display.ts index 406f022902e..1ab80bdf16e 100644 --- a/src/renderer/src/components/sidebar/worktree-card-pr-display.ts +++ b/src/renderer/src/components/sidebar/worktree-card-pr-display.ts @@ -1,7 +1,24 @@ import type { HostedReviewInfo } from '../../../../shared/hosted-review' +import type { PRInfo, Worktree } from '../../../../shared/types' type LinkedReviewMetadataProvider = Exclude +export function isCachedMergedBranchPRCurrentForWorktree( + cachedPR: PRInfo | null | undefined, + worktree: Pick +): boolean { + return ( + cachedPR?.state === 'merged' && + typeof cachedPR.headSha === 'string' && + cachedPR.headSha.length > 0 && + typeof worktree.head === 'string' && + worktree.head.length > 0 && + // Why: a worktree behind its own merged PR (update-branch/web commits) is + // still that PR's line of work; match the main-process visibility rule. + (cachedPR.headSha === worktree.head || cachedPR.confirmedContainedHeadOid === worktree.head) + ) +} + type LinkedReviewNumbers = { linkedPR: number | null linkedGitLabMR: number | null diff --git a/src/renderer/src/components/sidebar/worktree-drag-preview-offsets.ts b/src/renderer/src/components/sidebar/worktree-drag-preview-offsets.ts index 13d5281d1ad..678cee74421 100644 --- a/src/renderer/src/components/sidebar/worktree-drag-preview-offsets.ts +++ b/src/renderer/src/components/sidebar/worktree-drag-preview-offsets.ts @@ -38,18 +38,55 @@ function getFallbackGap(rects: readonly WorktreeDragPreviewRect[]): number { return gaps[Math.floor(gaps.length / 2)]! } +function getPreviewLayoutDraggedIds( + groupIds: readonly string[], + draggedIds: readonly string[], + draggingWorktreeId?: string | null +): readonly string[] { + if (draggedIds.length <= 1) { + return draggedIds + } + const draggedSet = new Set(draggedIds) + if ( + draggingWorktreeId && + draggedSet.has(draggingWorktreeId) && + groupIds.includes(draggingWorktreeId) + ) { + return [draggingWorktreeId] + } + const firstVisibleDraggedId = groupIds.find((id) => draggedSet.has(id)) + return firstVisibleDraggedId ? [firstVisibleDraggedId] : draggedIds.slice(0, 1) +} + export function buildWorktreeDragPreviewOffsets(args: { groupIds: readonly string[] draggedIds: readonly string[] + draggingWorktreeId?: string | null dropIndex: number rects: readonly WorktreeDragPreviewRect[] }): Map { - const nextIds = moveWorktreeIdsWithinGroup(args.groupIds, args.draggedIds, args.dropIndex) + const committedNextIds = moveWorktreeIdsWithinGroup( + args.groupIds, + args.draggedIds, + args.dropIndex + ) + if (arraysEqual(committedNextIds, args.groupIds)) { + return new Map() + } + + // Why: dragging a large multi-select batch should advertise the insertion + // point without opening a giant hole that makes the sidebar jump around. + const layoutDraggedIds = getPreviewLayoutDraggedIds( + args.groupIds, + args.draggedIds, + args.draggingWorktreeId + ) + const nextIds = moveWorktreeIdsWithinGroup(args.groupIds, layoutDraggedIds, args.dropIndex) if (arraysEqual(nextIds, args.groupIds)) { return new Map() } - const draggedSet = new Set(args.draggedIds) + const draggedSet = new Set(layoutDraggedIds) const newIndexById = new Map() nextIds.forEach((id, index) => newIndexById.set(id, index)) diff --git a/src/renderer/src/components/sidebar/worktree-header-section-boundaries.ts b/src/renderer/src/components/sidebar/worktree-header-section-boundaries.ts new file mode 100644 index 00000000000..85ae0b73bf1 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-header-section-boundaries.ts @@ -0,0 +1,131 @@ +import { estimateRenderRowSize, type RenderRow } from './worktree-list-virtual-rows' + +function getEstimatedRenderRowStarts( + rows: readonly RenderRow[], + firstHeaderIndex: number +): number[] { + const starts: number[] = [] + let offset = 0 + for (let index = 0; index < rows.length; index++) { + starts[index] = offset + offset += estimateRenderRowSize(rows, index, firstHeaderIndex, null) + } + starts[rows.length] = offset + return starts +} + +function findRepoHeaderRenderRowIndex(rows: readonly RenderRow[], repoId: string): number { + return rows.findIndex((row) => row.type === 'header' && row.repo?.id === repoId) +} + +function findProjectGroupHeaderRenderRowIndex(rows: readonly RenderRow[], groupId: string): number { + return rows.findIndex( + (row) => + row.type === 'header' && + !row.repo && + typeof row.projectGroup?.id === 'string' && + row.projectGroup.id === groupId + ) +} + +function findNextHeaderRenderRowIndex(rows: readonly RenderRow[], startIndex: number): number { + for (let index = startIndex; index < rows.length; index++) { + const row = rows[index] + if (row?.type === 'header' || row?.type === 'host-header') { + return index + } + } + return rows.length +} + +function findProjectGroupSectionEndIndex( + rows: readonly RenderRow[], + startIndex: number, + depth: number +): number { + for (let index = startIndex; index < rows.length; index++) { + const row = rows[index] + if (!row) { + continue + } + if (row.type === 'host-header') { + return index + } + if (row.type !== 'header') { + continue + } + const rowDepth = row.projectGroupDepth ?? 0 + if (rowDepth <= depth || (!row.repo && !row.projectGroup)) { + return index + } + } + return rows.length +} + +export function getRepoHeaderSectionEndByRepoId(args: { + rows: readonly RenderRow[] + firstHeaderIndex: number + sidebarRepoHeaderIdsByBucket: ReadonlyMap + repoHeaderBucketByRepoId: ReadonlyMap +}): Map { + const rowStarts = getEstimatedRenderRowStarts(args.rows, args.firstHeaderIndex) + const sectionEndByRepoId = new Map() + for (let index = 0; index < args.rows.length; index++) { + const row = args.rows[index] + const repoId = row?.type === 'header' ? row.repo?.id : undefined + if (!repoId) { + continue + } + const bucketKey = args.repoHeaderBucketByRepoId.get(repoId) + const bucketRepoIds = bucketKey ? args.sidebarRepoHeaderIdsByBucket.get(bucketKey) : undefined + const bucketIndex = bucketRepoIds?.indexOf(repoId) ?? -1 + const nextRepoId = bucketIndex >= 0 ? bucketRepoIds?.[bucketIndex + 1] : undefined + const endIndex = nextRepoId + ? findRepoHeaderRenderRowIndex(args.rows, nextRepoId) + : findNextHeaderRenderRowIndex(args.rows, index + 1) + sectionEndByRepoId.set( + repoId, + rowStarts[endIndex >= 0 ? endIndex : args.rows.length] ?? rowStarts[args.rows.length] ?? 0 + ) + } + return sectionEndByRepoId +} + +export function getProjectGroupHeaderSectionEndByGroupId(args: { + rows: readonly RenderRow[] + firstHeaderIndex: number + sidebarProjectGroupHeaderIdsByBucket: ReadonlyMap + projectGroupHeaderBucketByGroupId: ReadonlyMap +}): Map { + const rowStarts = getEstimatedRenderRowStarts(args.rows, args.firstHeaderIndex) + const sectionEndByGroupId = new Map() + for (let index = 0; index < args.rows.length; index++) { + const row = args.rows[index] + const projectGroupHeader = + row?.type === 'header' && + !row.repo && + row.projectGroup && + typeof row.projectGroup.id === 'string' + ? { row, groupId: row.projectGroup.id } + : null + const groupId = projectGroupHeader?.groupId + if (!groupId) { + continue + } + const bucketKey = args.projectGroupHeaderBucketByGroupId.get(groupId) + const bucketGroupIds = bucketKey + ? args.sidebarProjectGroupHeaderIdsByBucket.get(bucketKey) + : undefined + const bucketIndex = bucketGroupIds?.indexOf(groupId) ?? -1 + const nextGroupId = bucketIndex >= 0 ? bucketGroupIds?.[bucketIndex + 1] : undefined + const depth = projectGroupHeader.row.projectGroupDepth ?? 0 + const endIndex = nextGroupId + ? findProjectGroupHeaderRenderRowIndex(args.rows, nextGroupId) + : findProjectGroupSectionEndIndex(args.rows, index + 1, depth) + sectionEndByGroupId.set( + groupId, + rowStarts[endIndex >= 0 ? endIndex : args.rows.length] ?? rowStarts[args.rows.length] ?? 0 + ) + } + return sectionEndByGroupId +} diff --git a/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.test.ts b/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.test.ts new file mode 100644 index 00000000000..0f70912df1c --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest' +import { + getReorderedWorktreeIdsToUnnest, + getWorktreeLineageDropTargetId, + isWorktreeLineageDropZoneHit +} from './worktree-lineage-drag-drop' + +describe('isWorktreeLineageDropZoneHit', () => { + it('keeps the top and bottom of a card available for reorder drops', () => { + const rect = { top: 100, bottom: 200 } as DOMRect + + expect(isWorktreeLineageDropZoneHit({ pointerY: 120, rect })).toBe(false) + expect(isWorktreeLineageDropZoneHit({ pointerY: 150, rect })).toBe(true) + expect(isWorktreeLineageDropZoneHit({ pointerY: 180, rect })).toBe(false) + }) + + it('caps the parent-drop band on tall cards', () => { + const rect = { top: 0, bottom: 180 } as DOMRect + + expect(isWorktreeLineageDropZoneHit({ pointerY: 67, rect })).toBe(false) + expect(isWorktreeLineageDropZoneHit({ pointerY: 90, rect })).toBe(true) + expect(isWorktreeLineageDropZoneHit({ pointerY: 113, rect })).toBe(false) + }) +}) + +describe('getWorktreeLineageDropTargetId', () => { + it('returns the row id only when the pointer is in the card content middle band', () => { + const { container, target } = makeTarget({ worktreeId: 'parent', top: 100, bottom: 200 }) + + expect(getWorktreeLineageDropTargetId({ container, target, pointerY: 120 })).toBeNull() + expect(getWorktreeLineageDropTargetId({ container, target, pointerY: 150 })).toBe('parent') + }) + + it('ignores content targets outside the sidebar container', () => { + const { container, target } = makeTarget({ + worktreeId: 'parent', + top: 100, + bottom: 200, + contained: false + }) + + expect(getWorktreeLineageDropTargetId({ container, target, pointerY: 150 })).toBeNull() + }) +}) + +describe('getReorderedWorktreeIdsToUnnest', () => { + it('clears parents only for directly dragged nested cards', () => { + expect( + getReorderedWorktreeIdsToUnnest({ + draggedIds: ['child', 'child', 'root', 'grandchild'], + sourceGroupIds: ['child', 'root', 'grandchild'], + lineageById: { + child: true, + grandchild: true + } + }) + ).toEqual(['child', 'grandchild']) + }) + + it('does not clear selected nested cards outside the reordered source group', () => { + expect( + getReorderedWorktreeIdsToUnnest({ + draggedIds: ['source-child', 'other-child'], + sourceGroupIds: ['source-child'], + lineageById: { + 'source-child': true, + 'other-child': true + } + }) + ).toEqual(['source-child']) + }) +}) + +function makeTarget(args: { + worktreeId: string + top: number + bottom: number + contained?: boolean +}): { + container: HTMLElement + target: Element +} { + const row = { + getAttribute: (name: string) => (name === 'data-worktree-drag-id' ? args.worktreeId : null) + } as HTMLElement + const content = { + getBoundingClientRect: () => ({ top: args.top, bottom: args.bottom }), + closest: (selector: string) => (selector === '[data-worktree-drag-id]' ? row : null) + } as HTMLElement + const target = { + closest: (selector: string) => + selector === '[data-worktree-card-hover-trigger]' ? content : null + } as Element + const contained = args.contained ?? true + const container = { + contains: (element: Element) => contained && (element === content || element === row) + } as HTMLElement + return { container, target } +} diff --git a/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.ts b/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.ts new file mode 100644 index 00000000000..dc011d33a77 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.ts @@ -0,0 +1,68 @@ +const WORKTREE_CARD_CONTENT_TARGET_SELECTOR = '[data-worktree-card-hover-trigger]' +const WORKTREE_DRAG_ROW_SELECTOR = '[data-worktree-drag-id]' + +const LINEAGE_DROP_ZONE_RATIO = 0.4 +const LINEAGE_DROP_ZONE_MAX_HEIGHT_PX = 44 + +type VerticalRect = Pick + +export function isWorktreeLineageDropZoneHit(args: { + pointerY: number + rect: VerticalRect +}): boolean { + const height = Math.max(0, args.rect.bottom - args.rect.top) + if (height <= 0) { + return false + } + + const zoneHeight = Math.min(height * LINEAGE_DROP_ZONE_RATIO, LINEAGE_DROP_ZONE_MAX_HEIGHT_PX) + const zoneTop = args.rect.top + (height - zoneHeight) / 2 + const zoneBottom = args.rect.bottom - (height - zoneHeight) / 2 + return args.pointerY >= zoneTop && args.pointerY <= zoneBottom +} + +export function getWorktreeLineageDropTargetId(args: { + container: HTMLElement + target: Element + pointerY: number +}): string | null { + const contentTarget = args.target.closest(WORKTREE_CARD_CONTENT_TARGET_SELECTOR) + if (!contentTarget || !args.container.contains(contentTarget)) { + return null + } + + // Why: nesting should be deliberate; the top/bottom of a card stays available + // for reorder drops instead of treating the whole card as a parent target. + if ( + !isWorktreeLineageDropZoneHit({ + pointerY: args.pointerY, + rect: contentTarget.getBoundingClientRect() + }) + ) { + return null + } + + const rowTarget = contentTarget.closest(WORKTREE_DRAG_ROW_SELECTOR) + if (!rowTarget || !args.container.contains(rowTarget)) { + return null + } + return rowTarget.getAttribute('data-worktree-drag-id') +} + +export function getReorderedWorktreeIdsToUnnest(args: { + draggedIds: readonly string[] + sourceGroupIds: readonly string[] + lineageById: Readonly> +}): string[] { + const ids: string[] = [] + const seen = new Set() + const sourceGroupIdSet = new Set(args.sourceGroupIds) + for (const id of args.draggedIds) { + if (seen.has(id) || !sourceGroupIdSet.has(id) || !args.lineageById[id]) { + continue + } + seen.add(id) + ids.push(id) + } + return ids +} diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts index 805aaa4c6de..1ab12104567 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts @@ -629,7 +629,7 @@ describe('buildRows with pinned worktrees', () => { }) }) - it('keeps same-host checkouts of one project under the project header', () => { + it('splits same-host checkouts of one project into separate per-setup groups', () => { const repoB: Repo = { ...repo, id: 'repo-2', path: '/tmp/orca-2', displayName: 'orca-2' } const worktreeB: Worktree = { ...worktree, @@ -676,15 +676,22 @@ describe('buildRows with pinned worktrees', () => { ) const headers = rows.filter((row) => row.type === 'header') - expect(headers).toHaveLength(1) - expect(headers[0]).toMatchObject({ - key: 'project:github:stablyai/orca', - label: 'Orca', - count: 2 - }) + expect(headers).toHaveLength(2) + expect(headers).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: 'project:github:stablyai/orca::setup:repo-1', + label: 'orca' + }), + expect.objectContaining({ + key: 'project:github:stablyai/orca::setup:repo-2', + label: 'orca-2' + }) + ]) + ) }) - it('keeps runtime copies under the project header when one host has duplicate checkouts', () => { + it('splits all project setups when one host has duplicate checkouts', () => { const localRepoB: Repo = { ...repo, id: 'repo-local-b', @@ -738,8 +745,170 @@ describe('buildRows with pinned worktrees', () => { ) const headers = rows.filter((row) => row.type === 'header') - expect(headers.map((row) => row.key)).toEqual(['project:github:stablyai/orca']) - expect(headers[0]).toMatchObject({ label: 'Orca', count: 3 }) + expect(headers.map((row) => row.key)).toEqual([ + 'project:github:stablyai/orca::setup:repo-1', + 'project:github:stablyai/orca::setup:repo-local-b', + 'project:github:stablyai/orca::setup:repo-remote' + ]) + }) + + it('keeps a provisioned runtime copy under the project header alongside a same-host checkout', () => { + const runtimeRepoB: Repo = { + ...repo, + id: 'repo-runtime-b', + path: '/tmp/orca-runtime-b', + displayName: 'orca-runtime-b' + } + const runtimeWorktreeB: Worktree = { + ...worktree, + id: 'wt-runtime-b', + repoId: runtimeRepoB.id, + path: '/tmp/orca-runtime-b-feature', + displayName: 'feature-runtime-b' + } + // Why: a `provisioned` (recipe-created ephemeral) copy shares the project's + // remote identity but must not split the user's real checkout into two + // headers; it nests under the project. See #6320 / #5374. + const runtimeSetupB: ProjectHostSetup = { + ...projectHostSetups[0]!, + id: runtimeRepoB.id, + repoId: runtimeRepoB.id, + path: runtimeRepoB.path, + displayName: runtimeRepoB.displayName, + setupMethod: 'provisioned' + } + const rows = buildRows( + 'repo', + [worktree, runtimeWorktreeB], + new Map([ + [repo.id, repo], + [runtimeRepoB.id, runtimeRepoB] + ]), + null, + new Set(), + undefined, + undefined, + undefined, + {}, + new Map([ + [worktree.id, worktree], + [runtimeWorktreeB.id, runtimeWorktreeB] + ]), + false, + undefined, + [], + new Set(), + new Map(), + new Map(), + [], + { + projects: [{ ...project, sourceRepoIds: [repo.id, runtimeRepoB.id] }], + projectHostSetups: [projectHostSetups[0]!, runtimeSetupB] + } + ) + + const headers = rows.filter((row) => row.type === 'header') + expect(headers).toHaveLength(1) + expect(headers[0]).toMatchObject({ + key: 'project:github:stablyai/orca', + label: 'Orca', + count: 2 + }) + }) + + it('splits duplicate user checkouts while a provisioned copy nests, on one host', () => { + // Why: guards the intersection of #5374 (real same-host checkouts split) and + // #6320 (provisioned copies nest). Two legacy checkouts must each get their own + // header while a provisioned copy of the same project stays under the plain + // project header — all on one host surface, simultaneously. + const localRepoB: Repo = { + ...repo, + id: 'repo-local-b', + path: '/tmp/orca-b', + displayName: 'orca-b' + } + const localWorktreeB: Worktree = { + ...worktree, + id: 'wt-local-b', + repoId: localRepoB.id, + path: '/tmp/orca-b-feature', + displayName: 'feature-b' + } + const localSetupB: ProjectHostSetup = { + ...projectHostSetups[0]!, + id: localRepoB.id, + repoId: localRepoB.id, + path: localRepoB.path, + displayName: localRepoB.displayName + } + const runtimeRepoB: Repo = { + ...repo, + id: 'repo-runtime-b', + path: '/tmp/orca-runtime-b', + displayName: 'orca-runtime-b' + } + const runtimeWorktreeB: Worktree = { + ...worktree, + id: 'wt-runtime-b', + repoId: runtimeRepoB.id, + path: '/tmp/orca-runtime-b-feature', + displayName: 'feature-runtime-b' + } + const runtimeSetupB: ProjectHostSetup = { + ...projectHostSetups[0]!, + id: runtimeRepoB.id, + repoId: runtimeRepoB.id, + path: runtimeRepoB.path, + displayName: runtimeRepoB.displayName, + setupMethod: 'provisioned' + } + const rows = buildRows( + 'repo', + [worktree, localWorktreeB, runtimeWorktreeB], + new Map([ + [repo.id, repo], + [localRepoB.id, localRepoB], + [runtimeRepoB.id, runtimeRepoB] + ]), + null, + new Set(), + undefined, + undefined, + undefined, + {}, + new Map([ + [worktree.id, worktree], + [localWorktreeB.id, localWorktreeB], + [runtimeWorktreeB.id, runtimeWorktreeB] + ]), + false, + undefined, + [], + new Set(), + new Map(), + new Map(), + [], + { + projects: [{ ...project, sourceRepoIds: [repo.id, localRepoB.id, runtimeRepoB.id] }], + projectHostSetups: [projectHostSetups[0]!, localSetupB, runtimeSetupB] + } + ) + + const headers = rows.filter((row) => row.type === 'header') + expect(headers.map((row) => row.key).sort()).toEqual([ + 'project:github:stablyai/orca', + 'project:github:stablyai/orca::setup:repo-1', + 'project:github:stablyai/orca::setup:repo-local-b' + ]) + // The provisioned copy nests under the plain project key with only its own + // worktree; it never gets a path-scoped `::setup:` header like the real + // checkouts do. (buildRows disambiguates its visible label to the repo name.) + expect( + headers.some((row) => row.key === 'project:github:stablyai/orca::setup:repo-runtime-b') + ).toBe(false) + expect(headers.find((row) => row.key === 'project:github:stablyai/orca')).toMatchObject({ + count: 1 + }) }) it('groups Windows host and WSL setups on the same runtime host', () => { @@ -2275,6 +2444,67 @@ describe('project groups', () => { ]) }) + it('orders Project Group siblings by tabOrder within each parent bucket', () => { + const rootA: ProjectGroup = { + id: 'group-root-a', + name: 'Platform', + parentPath: '/platform', + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 20, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } + const rootB: ProjectGroup = { + ...rootA, + id: 'group-root-b', + name: 'Infrastructure', + tabOrder: 10 + } + const childLate: ProjectGroup = { + ...rootA, + id: 'group-child-late', + name: 'late', + parentGroupId: rootB.id, + tabOrder: 30 + } + const childEarly: ProjectGroup = { + ...rootA, + id: 'group-child-early', + name: 'early', + parentGroupId: rootB.id, + tabOrder: 5 + } + + const rows = buildRows( + 'repo', + [], + new Map(), + null, + new Set(), + undefined, + undefined, + 'recent', + {}, + undefined, + false, + undefined, + [rootA, rootB, childLate, childEarly] + ) + + expect(rows.filter((row) => row.type === 'header').map((row) => row.key)).toEqual([ + 'project-group:group-root-b', + 'project-group:group-child-early', + 'project-group:group-child-late', + 'project-group:group-root-a' + ]) + expect(rows.filter((row) => row.type === 'header').map((row) => row.projectGroupDepth)).toEqual( + [0, 1, 1, 0] + ) + }) + it('renders nested Project Groups before repos assigned to their leaf group', () => { const rootGroup: ProjectGroup = { id: 'group-root', diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.ts b/src/renderer/src/components/sidebar/worktree-list-groups.ts index 82a3301d82b..e7fb1749ed4 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.ts @@ -35,6 +35,8 @@ import { getGitHubPRCacheKey, getLegacyGitHubPRCacheKey } from '../../store/slic import { getRepoDisplayLabelsByPath } from '@/lib/repo-display-labels' import { translate } from '@/i18n/i18n' import { getExecutionHostLabel, getRepoExecutionHostId } from '../../../../shared/execution-host' +import { parseWslUncPath } from '../../../../shared/wsl-paths' +import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path' export { branchName } @@ -152,10 +154,30 @@ type WorktreeGroupEntry = { type ProjectGroupingIndex = { projectById: Map setupByRepoId: Map + projectIdsRequiringSetupGroups: Set } const projectGroupingIndexCache = new WeakMap() +// Why: `provisioned` setups are ephemeral recipe-created runtime copies that +// nest under the project header; every other method is a real user checkout. See #5374. +function isDistinctUserCheckout(setup: ProjectHostSetup): boolean { + return setup.setupMethod !== 'provisioned' +} + +function getProjectSetupSurfaceKey(setup: ProjectHostSetup): string { + const wslPath = parseWslUncPath(setup.path) + if (wslPath) { + // Why: Windows host and WSL on one machine are separate execution surfaces; + // only duplicate checkouts within one surface make project grouping ambiguous. + return `${setup.projectId}::${setup.hostId}::wsl:${wslPath.distro.toLowerCase()}` + } + if (isWindowsAbsolutePathLike(setup.path)) { + return `${setup.projectId}::${setup.hostId}::windows-host` + } + return `${setup.projectId}::${setup.hostId}::default` +} + function buildProjectGroupingIndex(model?: ProjectGroupingModel): ProjectGroupingIndex | null { if (!model) { return null @@ -170,9 +192,31 @@ function buildProjectGroupingIndex(model?: ProjectGroupingModel): ProjectGroupin projectGroupingIndexCache.set(model, null) return null } + // Count real user checkouts per host surface (provisioned copies excluded so + // they keep nesting); more than one on a surface makes that project ambiguous. + const checkoutsByProjectSurface = new Map() + for (const setup of projectHostSetups) { + if (!isDistinctUserCheckout(setup)) { + continue + } + const key = getProjectSetupSurfaceKey(setup) + const existing = checkoutsByProjectSurface.get(key) + if (existing) { + existing.count += 1 + } else { + checkoutsByProjectSurface.set(key, { projectId: setup.projectId, count: 1 }) + } + } + const projectIdsRequiringSetupGroups = new Set() + for (const { projectId, count } of checkoutsByProjectSurface.values()) { + if (count > 1) { + projectIdsRequiringSetupGroups.add(projectId) + } + } const index = { projectById: new Map(projects.map((project) => [project.id, project])), - setupByRepoId: new Map(projectHostSetups.map((setup) => [setup.repoId, setup])) + setupByRepoId: new Map(projectHostSetups.map((setup) => [setup.repoId, setup])), + projectIdsRequiringSetupGroups } projectGroupingIndexCache.set(model, index) return index @@ -200,9 +244,21 @@ function getProjectGroupingForRepo( repo } } - // Why: recipe-created runtimes and local worktrees can be separate checkouts - // of the same Git project; the sidebar should follow project identity rather - // than path-scoped setup identity so those workspaces stay in one project. + if ( + projectIndex?.projectIdsRequiringSetupGroups.has(setup.projectId) && + isDistinctUserCheckout(setup) + ) { + // Why: independent user checkouts of one project on the same host surface + // can't be safely merged, so each keeps its own sidebar entry. See #5374. + return { + key: `project:${project.id}::setup:${repoId}`, + label: repo?.displayName ?? setup.displayName, + repo, + projectId: project.id + } + } + // Why: provisioned runtime copies and non-ambiguous checkouts follow project + // identity rather than path-scoped setup identity, so they stay in one project. return { key: `project:${project.id}`, label: project.displayName, diff --git a/src/renderer/src/components/sidebar/worktree-manual-order.test.ts b/src/renderer/src/components/sidebar/worktree-manual-order.test.ts index 7753afd8cd3..3847c2168b6 100644 --- a/src/renderer/src/components/sidebar/worktree-manual-order.test.ts +++ b/src/renderer/src/components/sidebar/worktree-manual-order.test.ts @@ -146,6 +146,64 @@ describe('buildWorktreeDragPreviewOffsets', () => { expect(Array.from(offsets)).toEqual([['parent', 108]]) }) + + it('reserves one card-height slot while previewing a multi-select batch', () => { + const offsets = buildWorktreeDragPreviewOffsets({ + groupIds: ['a', 'b', 'c', 'd', 'e'], + draggedIds: ['b', 'c', 'd'], + draggingWorktreeId: 'b', + dropIndex: 5, + rects: [ + { worktreeId: 'a', groupIndex: 0, top: 0, bottom: 50 }, + { worktreeId: 'b', groupIndex: 1, top: 56, bottom: 106 }, + { worktreeId: 'c', groupIndex: 2, top: 112, bottom: 162 }, + { worktreeId: 'd', groupIndex: 3, top: 168, bottom: 218 }, + { worktreeId: 'e', groupIndex: 4, top: 224, bottom: 274 } + ] + }) + + expect(Array.from(offsets)).toEqual([ + ['c', -56], + ['d', -56], + ['e', -56] + ]) + }) + + it('uses the grabbed selected card as the one preview placeholder', () => { + const offsets = buildWorktreeDragPreviewOffsets({ + groupIds: ['a', 'b', 'c', 'd', 'e'], + draggedIds: ['b', 'c', 'd'], + draggingWorktreeId: 'd', + dropIndex: 5, + rects: [ + { worktreeId: 'a', groupIndex: 0, top: 0, bottom: 50 }, + { worktreeId: 'b', groupIndex: 1, top: 56, bottom: 106 }, + { worktreeId: 'c', groupIndex: 2, top: 112, bottom: 162 }, + { worktreeId: 'd', groupIndex: 3, top: 168, bottom: 218 }, + { worktreeId: 'e', groupIndex: 4, top: 224, bottom: 274 } + ] + }) + + expect(Array.from(offsets)).toEqual([['e', -56]]) + }) + + it('returns no preview offsets for a no-op multi-select hover', () => { + const offsets = buildWorktreeDragPreviewOffsets({ + groupIds: ['a', 'b', 'c', 'd', 'e'], + draggedIds: ['b', 'c', 'd'], + draggingWorktreeId: 'b', + dropIndex: 4, + rects: [ + { worktreeId: 'a', groupIndex: 0, top: 0, bottom: 50 }, + { worktreeId: 'b', groupIndex: 1, top: 56, bottom: 106 }, + { worktreeId: 'c', groupIndex: 2, top: 112, bottom: 162 }, + { worktreeId: 'd', groupIndex: 3, top: 168, bottom: 218 }, + { worktreeId: 'e', groupIndex: 4, top: 224, bottom: 274 } + ] + }) + + expect(offsets.size).toBe(0) + }) }) describe('buildManualOrderUpdatesForVisibleGroups', () => { diff --git a/src/renderer/src/components/sidebar/worktree-meta-updates.test.ts b/src/renderer/src/components/sidebar/worktree-meta-updates.test.ts new file mode 100644 index 00000000000..a53eeae4163 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-meta-updates.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { buildWorktreeMetaUpdates } from './worktree-meta-updates' + +describe('buildWorktreeMetaUpdates', () => { + it('rejects issue URLs in the PR input', () => { + expect( + buildWorktreeMetaUpdates({ + displayNameInput: 'Workspace', + currentDisplayName: 'Workspace', + issueInput: '', + prInput: 'https://github.com/stablyai/orca/issues/6933', + commentInput: '' + }) + ).toEqual({ + comment: '', + linkedIssue: null + }) + }) + + it('accepts PR URLs in the PR input', () => { + expect( + buildWorktreeMetaUpdates({ + displayNameInput: 'Workspace', + currentDisplayName: 'Workspace', + issueInput: '', + prInput: 'https://github.com/stablyai/orca/pull/6934', + commentInput: '' + }) + ).toEqual({ + comment: '', + linkedIssue: null, + linkedPR: 6934 + }) + }) + + it('accepts issue URLs in the issue input', () => { + expect( + buildWorktreeMetaUpdates({ + displayNameInput: 'Workspace', + currentDisplayName: 'Workspace', + issueInput: 'https://github.com/stablyai/orca/issues/6933', + prInput: '', + commentInput: '' + }) + ).toEqual({ + comment: '', + linkedIssue: 6933, + linkedPR: null + }) + }) + + it('rejects PR URLs in the issue input', () => { + expect( + buildWorktreeMetaUpdates({ + displayNameInput: 'Workspace', + currentDisplayName: 'Workspace', + issueInput: 'https://github.com/stablyai/orca/pull/6934', + prInput: '', + commentInput: '' + }) + ).toEqual({ + comment: '', + linkedPR: null + }) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-meta-updates.ts b/src/renderer/src/components/sidebar/worktree-meta-updates.ts index 7f6f5d70935..26769c65061 100644 --- a/src/renderer/src/components/sidebar/worktree-meta-updates.ts +++ b/src/renderer/src/components/sidebar/worktree-meta-updates.ts @@ -16,6 +16,20 @@ export function parseExplicitGitHubIssueUrl(input: string): string | null { return trimmed } +export function parseGitHubWorkItemNumberForMetaField( + input: string, + expectedType: 'issue' | 'pr' +): number | null { + const link = parseGitHubIssueOrPRLink(input) + if (link) { + // Why: issue and PR numbers live in separate GitHub namespaces for refs; + // a URL path mismatch must not silently link the other field. + return link.type === expectedType ? link.number : null + } + + return parseGitHubIssueOrPRNumber(input) +} + /** Pure save-payload builder for the worktree meta dialog: empty inputs clear * the link (null), unparseable inputs leave it untouched (omitted). */ export function buildWorktreeMetaUpdates(args: { @@ -26,11 +40,11 @@ export function buildWorktreeMetaUpdates(args: { commentInput: string }): Partial { const trimmedIssue = args.issueInput.trim() - const linkedIssueNumber = parseGitHubIssueOrPRNumber(trimmedIssue) + const linkedIssueNumber = parseGitHubWorkItemNumberForMetaField(trimmedIssue, 'issue') const finalLinkedIssue = trimmedIssue === '' ? null : linkedIssueNumber !== null ? linkedIssueNumber : undefined const trimmedPR = args.prInput.trim() - const linkedPRNumber = parseGitHubIssueOrPRNumber(trimmedPR) + const linkedPRNumber = parseGitHubWorkItemNumberForMetaField(trimmedPR, 'pr') const finalLinkedPR = trimmedPR === '' ? null : linkedPRNumber !== null ? linkedPRNumber : undefined diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.test.ts b/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.test.ts index 2e3c5582abc..a197759a245 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.test.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.test.ts @@ -183,7 +183,7 @@ describe('refreshWorktreeSidebarDragSession', () => { ).toBeNull() expect( refreshWorktreeSidebarDragSession({ - session: SESSION, + session: { ...SESSION, reorderUnitDraggedIds: ['missing'] }, groups: [{ key: 'repo:one', worktreeIds: ['a', 'b'] }], unitGroups: [{ key: 'repo:one', worktreeIds: ['a'], units: [] }], rects: [] @@ -201,6 +201,39 @@ describe('refreshWorktreeSidebarDragSession', () => { }) ).toEqual({ ...SESSION, rects: [] }) }) + + it('keeps child-card reorder drags even when the child is not a top-level unit', () => { + const childSession: WorktreeSidebarDragSession = { + ...SESSION, + draggingWorktreeId: 'child', + draggedIds: ['child'], + reorderDraggedIds: ['child'], + reorderUnitDraggedIds: ['child'] + } + const rects: WorktreeSidebarDragRect[] = [ + { worktreeId: 'parent', groupIndex: 0, top: 0, bottom: 80 }, + { worktreeId: 'child', groupIndex: 1, top: 88, bottom: 128 }, + { worktreeId: 'sibling', groupIndex: 2, top: 136, bottom: 176 } + ] + + expect( + refreshWorktreeSidebarDragSession({ + session: childSession, + groups: [{ key: 'repo:one', worktreeIds: ['parent', 'child', 'sibling'] }], + unitGroups: [ + { + key: 'repo:one', + worktreeIds: ['parent', 'sibling'], + units: [ + { worktreeId: 'parent', worktreeIds: ['parent', 'child'] }, + { worktreeId: 'sibling', worktreeIds: ['sibling'] } + ] + } + ], + rects + }) + ).toEqual({ ...childSession, rects }) + }) }) function makeContainer(elements: readonly ReturnType[]): HTMLElement { diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.ts b/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.ts index 4ccbf340549..27e2a3d7d2d 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.ts @@ -180,8 +180,13 @@ export function refreshWorktreeSidebarDragSession(args: { return null } + const sourceGroupIds = new Set(sourceGroup.worktreeIds) const sourceUnitIds = new Set(sourceUnitGroup.worktreeIds) - if (args.session.reorderUnitDraggedIds.some((worktreeId) => !sourceUnitIds.has(worktreeId))) { + if ( + args.session.reorderUnitDraggedIds.some( + (worktreeId) => !sourceUnitIds.has(worktreeId) && !sourceGroupIds.has(worktreeId) + ) + ) { return null } diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.test.ts b/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.test.ts index 69f4c693de0..9fb1e630276 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.test.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.test.ts @@ -60,6 +60,58 @@ describe('computeWorktreeSidebarDropPreview', () => { }) expect(Array.from(preview?.previewOffsetsByWorktreeId ?? [])).toEqual([['sibling', -288]]) }) + + it('uses one card-height placeholder for multi-select reorder previews', () => { + const preview = computeWorktreeSidebarDropPreview({ + pointerY: 280, + containerTop: 0, + scrollTop: 0, + rects: [ + { worktreeId: 'a', groupIndex: 0, top: 0, bottom: 50 }, + { worktreeId: 'b', groupIndex: 1, top: 56, bottom: 106 }, + { worktreeId: 'c', groupIndex: 2, top: 112, bottom: 162 }, + { worktreeId: 'd', groupIndex: 3, top: 168, bottom: 218 }, + { worktreeId: 'e', groupIndex: 4, top: 224, bottom: 274 } + ], + groupIds: ['a', 'b', 'c', 'd', 'e'], + draggedIds: ['b', 'c', 'd'], + draggingWorktreeId: 'b' + }) + + expect(preview).toMatchObject({ + dropIndex: 5, + dropIndicatorY: 277 + }) + expect(Array.from(preview?.previewOffsetsByWorktreeId ?? [])).toEqual([ + ['c', -56], + ['d', -56], + ['e', -56] + ]) + }) + + it('uses the grabbed selected card as the multi-select preview placeholder', () => { + const preview = computeWorktreeSidebarDropPreview({ + pointerY: 280, + containerTop: 0, + scrollTop: 0, + rects: [ + { worktreeId: 'a', groupIndex: 0, top: 0, bottom: 50 }, + { worktreeId: 'b', groupIndex: 1, top: 56, bottom: 106 }, + { worktreeId: 'c', groupIndex: 2, top: 112, bottom: 162 }, + { worktreeId: 'd', groupIndex: 3, top: 168, bottom: 218 }, + { worktreeId: 'e', groupIndex: 4, top: 224, bottom: 274 } + ], + groupIds: ['a', 'b', 'c', 'd', 'e'], + draggedIds: ['b', 'c', 'd'], + draggingWorktreeId: 'd' + }) + + expect(preview).toMatchObject({ + dropIndex: 5, + dropIndicatorY: 277 + }) + expect(Array.from(preview?.previewOffsetsByWorktreeId ?? [])).toEqual([['e', -56]]) + }) }) describe('resolveWorktreeSidebarStatusDropCommitTarget', () => { diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.ts b/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.ts index b150188bdb9..d3702968e59 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.ts @@ -99,6 +99,7 @@ export function computeWorktreeSidebarDropPreview(args: { rects: readonly WorktreeSidebarDragRect[] groupIds: readonly string[] draggedIds: readonly string[] + draggingWorktreeId?: string | null }): WorktreeSidebarDropPreview | null { const rects = getWorktreeSidebarDragUnitRects({ rects: args.rects, @@ -139,6 +140,7 @@ export function computeWorktreeSidebarDropPreview(args: { const previewOffsetsByWorktreeId = buildWorktreeDragPreviewOffsets({ groupIds: args.groupIds, draggedIds: args.draggedIds, + draggingWorktreeId: args.draggingWorktreeId, dropIndex, rects }) diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-header-drop-preview.ts b/src/renderer/src/components/sidebar/worktree-sidebar-header-drop-preview.ts new file mode 100644 index 00000000000..386a3e317a5 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-sidebar-header-drop-preview.ts @@ -0,0 +1,79 @@ +import { getWorktreeSidebarBoundaryDrop } from './worktree-sidebar-drag-autoscroll' + +export type WorktreeSidebarHeaderDragRect = { + headerIndex: number + top: number + bottom: number + sectionBottom?: number +} + +export type WorktreeSidebarHeaderDropPreview = { + dropIndex: number + dropIndicatorY: number +} + +const INDICATOR_GAP_PX = 4 + +export function computeWorktreeSidebarHeaderDropPreview< + TRect extends WorktreeSidebarHeaderDragRect +>(args: { + pointerY: number + containerTop: number + scrollTop: number + rects: readonly TRect[] + headerCount: number + getId: (rect: TRect) => string +}): WorktreeSidebarHeaderDropPreview | null { + if (args.rects.length === 0 || args.headerCount === 0) { + return null + } + + const localY = args.pointerY - args.containerTop + args.scrollTop + const first = args.rects[0]! + const last = args.rects.at(-1)! + const lastBoundaryBottom = Math.max(last.bottom, last.sectionBottom ?? last.bottom) + const boundaryDrop = getWorktreeSidebarBoundaryDrop({ + localY, + firstRect: { + worktreeId: args.getId(first), + groupIndex: first.headerIndex, + top: first.top, + bottom: first.bottom + }, + lastRect: { + worktreeId: args.getId(last), + groupIndex: last.headerIndex, + top: last.top, + bottom: lastBoundaryBottom + }, + sourceGroupSize: args.headerCount + }) + if (boundaryDrop.kind === 'outside') { + return null + } + if (boundaryDrop.kind === 'drop') { + return { + dropIndex: boundaryDrop.dropIndex, + dropIndicatorY: Math.max(args.scrollTop, boundaryDrop.indicatorY) + } + } + + const hoveredRect = args.rects.find((rect) => localY >= rect.top && localY <= rect.bottom) + if (!hoveredRect) { + return null + } + + const mid = (hoveredRect.top + hoveredRect.bottom) / 2 + const dropIndex = localY < mid ? hoveredRect.headerIndex : hoveredRect.headerIndex + 1 + const nextRect = + localY < mid ? hoveredRect : args.rects.find((rect) => rect.headerIndex >= dropIndex) + const indicatorY = nextRect + ? Math.max(0, nextRect.top - INDICATOR_GAP_PX) + : Math.max(hoveredRect.bottom, hoveredRect.sectionBottom ?? hoveredRect.bottom) + + INDICATOR_GAP_PX + + return { + dropIndex, + dropIndicatorY: Math.max(args.scrollTop, indicatorY) + } +} diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.test.ts b/src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.test.ts new file mode 100644 index 00000000000..1da451ff024 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.test.ts @@ -0,0 +1,43 @@ +// @vitest-environment happy-dom + +import { describe, expect, it } from 'vitest' +import { isSidebarPointerDragBlocked } from './worktree-sidebar-pointer-drag-dom' + +describe('worktree sidebar pointer drag DOM guards', () => { + it('allows plain row targets to start pointer drags', () => { + const row = document.createElement('div') + const card = document.createElement('div') + row.appendChild(card) + + expect(isSidebarPointerDragBlocked(card, row)).toBe(false) + }) + + it('blocks portaled hover card targets outside the row', () => { + const row = document.createElement('div') + const hoverCardContent = document.createElement('div') + document.body.append(row, hoverCardContent) + + expect(isSidebarPointerDragBlocked(hoverCardContent, row)).toBe(true) + + hoverCardContent.remove() + row.remove() + }) + + it('blocks interactive targets inside the row', () => { + const row = document.createElement('div') + const button = document.createElement('button') + row.appendChild(button) + + expect(isSidebarPointerDragBlocked(button, row)).toBe(true) + }) + + it('blocks icon targets inside interactive row controls', () => { + const row = document.createElement('div') + const button = document.createElement('button') + const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg') + button.appendChild(icon) + row.appendChild(button) + + expect(isSidebarPointerDragBlocked(icon, row)).toBe(true) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.ts b/src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.ts index 740a32abf92..15d102ac3b5 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.ts @@ -17,7 +17,15 @@ const INTERACTIVE_DRAG_BLOCKER_SELECTOR = [ ].join(',') export function isSidebarPointerDragBlocked(target: EventTarget | null, row: HTMLElement): boolean { - if (!(target instanceof HTMLElement)) { + if (!(target instanceof Node)) { + return false + } + // Why: Radix hover cards portal outside the row, but React still bubbles their + // pointer events through row handlers; text selection there must not drag rows. + if (!row.contains(target)) { + return true + } + if (!(target instanceof Element)) { return false } const blocker = target.closest(INTERACTIVE_DRAG_BLOCKER_SELECTOR) diff --git a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.rows.test.tsx b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.rows.test.tsx new file mode 100644 index 00000000000..57f77d1e472 --- /dev/null +++ b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.rows.test.tsx @@ -0,0 +1,134 @@ +// @vitest-environment happy-dom + +import React, { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ORPHAN_WORKTREE_ID } from '../../../../shared/constants' +import type { UnifiedSessionRow, UnifiedWorktreeRow } from './resource-usage-merge-types' + +vi.mock('@/store', () => { + const storeState = {} + const useAppStore = Object.assign( + (selector: (state: typeof storeState) => unknown) => selector(storeState), + { getState: () => storeState } + ) + return { useAppStore } +}) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children} +})) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, values?: Record) => + values + ? Object.entries(values).reduce( + (text, [token, value]) => text.replace(`{{${token}}}`, value), + fallback + ) + : fallback +})) + +vi.mock('sonner', () => ({ + toast: { error: vi.fn(), success: vi.fn() } +})) + +import { WorktreeRow } from './ResourceUsageStatusSegment' + +function makeSession(overrides: Partial): UnifiedSessionRow { + return { + sessionId: 'sess-1', + paneKey: null, + pid: 100, + label: 'zsh', + bound: true, + tabId: 'tab-1', + cpu: 1, + memory: 100, + hasLocalSamples: true, + ...overrides + } +} + +function makeWorktree(overrides: Partial): UnifiedWorktreeRow { + return { + worktreeId: 'wt-1', + worktreeName: 'feature-branch', + repoId: 'repo-1', + repoName: 'repo', + cpu: 1, + memory: 100, + history: [], + hasLocalSamples: true, + isRemote: false, + sessions: [], + ...overrides + } +} + +describe('resource manager row presentation', () => { + let container: HTMLDivElement + let root: Root + + afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + }) + + function renderWorktreeRow(worktree: UnifiedWorktreeRow): void { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => { + root.render( + {}} + onNavigate={() => {}} + onDelete={() => {}} + onKillSession={() => {}} + navigateToTab={() => {}} + /> + ) + }) + } + + it('keeps the remote chip and kill affordances on SSH-backed rows', () => { + renderWorktreeRow( + makeWorktree({ + isRemote: true, + cpu: null, + memory: null, + sessions: [ + makeSession({ sessionId: 'ssh-a', bound: true }), + makeSession({ sessionId: 'ssh-b', bound: false, tabId: null, cpu: null, memory: null }) + ] + }) + ) + + expect(container.textContent).toContain('· remote') + const killButtons = container.querySelectorAll('button[aria-label^="Kill session"]') + expect(killButtons).toHaveLength(2) + expect(container.querySelector('button[aria-label="Kill session ssh-a"]')).not.toBeNull() + expect(container.querySelector('button[aria-label="Kill session ssh-b"]')).not.toBeNull() + }) + + it('keeps kill affordances on the orphan bucket rows', () => { + renderWorktreeRow( + makeWorktree({ + worktreeId: ORPHAN_WORKTREE_ID, + worktreeName: 'Orphaned terminals', + sessions: [makeSession({ sessionId: 'orphan-a', bound: false, tabId: null })] + }) + ) + + expect(container.querySelector('button[aria-label="Kill session orphan-a"]')).not.toBeNull() + }) +}) diff --git a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.session-polling.test.ts b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.session-polling.test.ts new file mode 100644 index 00000000000..e29871f0a17 --- /dev/null +++ b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.session-polling.test.ts @@ -0,0 +1,23 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +const SOURCE_PATH = resolve(__dirname, 'ResourceUsageStatusSegment.tsx') + +describe('ResourceUsageStatusSegment session polling', () => { + it('does not poll global terminal sessions while the popover is closed', () => { + const source = readFileSync(SOURCE_PATH, 'utf8') + + expect(source).not.toContain('installWindowVisibilityInterval') + expect(source).not.toContain('SESSIONS_POLL_MS') + expect(source.match(/window\.api\.pty\.listSessions\(\)/g) ?? []).toHaveLength(1) + + const openEffectIndex = source.indexOf('if (!open)') + const refreshIndex = source.indexOf('void refreshSessions()', openEffectIndex) + + // Why: pty.listSessions() is a global daemon inventory and can pause input + // with large preserved-session sets. Keep it on explicit Resource Manager use. + expect(openEffectIndex).toBeGreaterThanOrEqual(0) + expect(refreshIndex).toBeGreaterThan(openEffectIndex) + }) +}) diff --git a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx index 67095fee7c8..fffd83215ba 100644 --- a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx @@ -31,13 +31,13 @@ import { cn } from '@/lib/utils' import { useMountedRef } from '@/hooks/useMountedRef' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane' -import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval' import { useAppStore } from '../../store' import { useWorktreeMap } from '../../store/selectors' import { runWorktreeDelete } from '../sidebar/delete-worktree-flow' import { useDaemonActions, DaemonActionDialog } from '../shared/useDaemonActions' import type { AppMemory, UsageValues, Worktree } from '../../../../shared/types' import { ORPHAN_WORKTREE_ID } from '../../../../shared/constants' +import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host' import { isFolderRepo } from '../../../../shared/repo-kind' import { isWorkspaceOldForCleanup } from '../../../../shared/workspace-cleanup' import { mergeSnapshotAndSessions, UNATTRIBUTED_REPO_ID } from './mergeSnapshotAndSessions' @@ -76,7 +76,6 @@ import { import { translate } from '@/i18n/i18n' const POLL_MS = 2_000 -const SESSIONS_POLL_MS = 10_000 type SortOption = 'memory' | 'cpu' | 'name' @@ -354,7 +353,9 @@ function sortProjectGroups(groups: UnifiedProjectGroup[], sort: SortOption): Uni // ─── Session row ──────────────────────────────────────────────────── -function SessionRow({ +// Exported (with WorktreeRow) for row-level regression tests pinning the kill +// affordance and remote-chip presentation for SSH/orphan rows. +export function SessionRow({ session, worktreeId, onNavigate, @@ -435,7 +436,7 @@ function SessionRow({ // ─── Worktree row ─────────────────────────────────────────────────── -function WorktreeRow({ +export function WorktreeRow({ worktree, storeRecord, activeWorktreeId, @@ -747,10 +748,6 @@ export function ResourceUsageStatusSegment({ const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) const workspaceSpaceScannedAt = useAppStore((s) => s.workspaceSpaceAnalysis?.scannedAt ?? null) const workspaceSpaceScanning = useAppStore((s) => s.workspaceSpaceScanning) - const activeRuntimeEnvironmentId = useAppStore( - (s) => s.settings?.activeRuntimeEnvironmentId ?? null - ) - const runtimeEnvironmentActive = Boolean(activeRuntimeEnvironmentId?.trim()) const [open, setOpen] = useState(false) const [sortOption, setSortOption] = useState('memory') @@ -772,19 +769,12 @@ export function ResourceUsageStatusSegment({ // merged tree needs them only while open, so closed status-bar badges should // not subscribe to those high-churn maps. const runtimePaneTitlesByTabId = useAppStore((s) => - getResourceUsageRuntimePaneTitlesByTabId(s, open, runtimeEnvironmentActive) + getResourceUsageRuntimePaneTitlesByTabId(s, open) ) - const repos = useAppStore((s) => getResourceUsageRepos(s, open, runtimeEnvironmentActive)) - const allWorktrees = useAppStore((s) => - getResourceUsageAllWorktrees(s, open, runtimeEnvironmentActive) - ) - const tabsByWorktree = useAppStore((s) => - getResourceUsageTabsByWorktree(s, open, runtimeEnvironmentActive) - ) - // Why: this segment only understands the local Electron PTY/resource daemon. - // While a runtime server is active, hiding local samples avoids showing or - // killing sessions from the wrong machine. - const resourceSnapshot = runtimeEnvironmentActive ? null : snapshot + const repos = useAppStore((s) => getResourceUsageRepos(s, open)) + const allWorktrees = useAppStore((s) => getResourceUsageAllWorktrees(s, open)) + const tabsByWorktree = useAppStore((s) => getResourceUsageTabsByWorktree(s, open)) + const resourceSnapshot = snapshot // Why: ptyIdsByTabId intentionally tracks mounted/live panes only. Resource // Manager also reads restored wake hints, but only for classification. const resourceSessionBindings = useMemo( @@ -824,13 +814,6 @@ export function ResourceUsageStatusSegment({ ) const refreshSessions = useCallback(async () => { - if (runtimeEnvironmentActive) { - if (mountedRef.current) { - setSessions([]) - setSessionsError(false) - } - return - } try { const result = await window.api.pty.listSessions() if (!mountedRef.current) { @@ -843,7 +826,7 @@ export function ResourceUsageStatusSegment({ setSessionsError(true) } } - }, [mountedRef, runtimeEnvironmentActive]) + }, [mountedRef]) const daemonActions = useDaemonActions({ onRestartSettled: () => { @@ -860,7 +843,6 @@ export function ResourceUsageStatusSegment({ // closes this popover; the status-bar trigger becomes the handoff point. const nextSpaceScanSnapshot = resolveResourceUsageSpaceScanReady({ snapshot: spaceScanSnapshot, - runtimeEnvironmentActive, open, activeView, scannedAt: workspaceSpaceScannedAt, @@ -877,41 +859,26 @@ export function ResourceUsageStatusSegment({ } const spaceScanReady = nextSpaceScanSnapshot.ready - // Poll memory + sessions when popover is open. Sessions also poll in the - // background at a slower rate so the badge count stays reasonably fresh - // without keeping the Memory IPC hot. + // Poll memory when popover is open. Sessions are refreshed on open and after + // session actions; a closed status-bar badge must not globally inventory + // daemon PTYs because large preserved-session sets make that visible while + // typing. useEffect(() => { - if (!open || runtimeEnvironmentActive) { + if (!open) { return } void fetchSnapshot() void refreshSessions() - // Why: sessions already have an always-on poll in the effect below; only - // the memory snapshot is gated on the popover being open. Stacking a - // second sessions interval here doubled IPC traffic while the popover - // was open. + // Why: only the memory snapshot keeps an interval while the popover is + // open. Session inventory is explicit-on-open/action because it can be + // expensive with many daemon-preserved terminals. const memTimer = window.setInterval(() => { void fetchSnapshot() }, POLL_MS) return () => { window.clearInterval(memTimer) } - }, [open, runtimeEnvironmentActive, fetchSnapshot, refreshSessions]) - - useEffect(() => { - if (runtimeEnvironmentActive) { - setSessions([]) - setSessionsError(false) - return - } - // Why: the closed-popover badge is informational. Polling daemon sessions - // while the whole window is hidden keeps IPC and daemon list calls hot for - // no visible UI; visibility refreshes catch the badge up immediately. - return installWindowVisibilityInterval({ - run: () => void refreshSessions(), - intervalMs: SESSIONS_POLL_MS - }) - }, [runtimeEnvironmentActive, refreshSessions]) + }, [open, fetchSnapshot, refreshSessions]) const repoDisplayNameById = useMemo(() => { const map = new Map() @@ -937,6 +904,17 @@ export function ResourceUsageStatusSegment({ return map }, [repos]) + // Why: runtime-hosted repos never have local daemon samples or killable + // local sessions; this map drives their per-row exclusion in the merge. + const repoRuntimeScopedById = useMemo(() => { + const map = new Map() + for (const repo of repos) { + const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) + map.set(repo.id, parsed?.kind === 'runtime') + } + return map + }, [repos]) + const repoById = useMemo(() => new Map(repos.map((repo) => [repo.id, repo])), [repos]) const oldWorkspaceCount = useMemo(() => { @@ -961,7 +939,7 @@ export function ResourceUsageStatusSegment({ // feel laggy because the segment is always mounted in the status bar. const unifiedRepos = useMemo( () => - open && !runtimeEnvironmentActive + open ? mergeSnapshotAndSessions(resourceSnapshot, sessions, { tabsByWorktree, ptyIdsByTabId, @@ -969,12 +947,12 @@ export function ResourceUsageStatusSegment({ runtimePaneTitlesByTabId, workspaceSessionReady, repoDisplayNameById, - repoConnectionIdById + repoConnectionIdById, + repoRuntimeScopedById }) : [], [ open, - runtimeEnvironmentActive, resourceSnapshot, sessions, tabsByWorktree, @@ -983,18 +961,27 @@ export function ResourceUsageStatusSegment({ runtimePaneTitlesByTabId, workspaceSessionReady, repoDisplayNameById, - repoConnectionIdById + repoConnectionIdById, + repoRuntimeScopedById ] ) - // Why: orphanCount drives the trigger badge (always visible in the status - // bar, popover open or not) so it must compute outside the open-gate. + // Why: orphan detection needs daemon inventory. Keep it open-only so the + // closed badge never reintroduces a background global session scan. const orphanCount = useMemo(() => { - if (!workspaceSessionReady || runtimeEnvironmentActive) { + if (!open || !workspaceSessionReady) { return 0 } return countUnboundDaemonSessions(sessions, resourceSessionBindings) - }, [sessions, resourceSessionBindings, workspaceSessionReady, runtimeEnvironmentActive]) + }, [open, sessions, resourceSessionBindings, workspaceSessionReady]) + + const closedSessionCount = useMemo(() => { + if (!workspaceSessionReady) { + return 0 + } + return buildResourceSessionBindingIndex(resourceSessionBindings).boundPtyIds.size + }, [resourceSessionBindings, workspaceSessionReady]) + const triggerSessionCount = open ? sessions.length : closedSessionCount const { totalMemory, totalCpu, hostShare, memBadgeLabel } = useMemo(() => { const memory = resourceSnapshot?.totalMemory ?? 0 @@ -1009,30 +996,22 @@ export function ResourceUsageStatusSegment({ }, [resourceSnapshot]) // Why: memorySnapshotError is null both for "last fetch succeeded" and - // "never fetched". When the segment is mounted but the popover hasn't - // been opened, fetchMemorySnapshot has never run, so a sessions IPC - // failure on the always-on poll would otherwise be silent. Treat the - // absence of any snapshot plus a sessions error as unreachable too. - const daemonUnreachable = - !runtimeEnvironmentActive && - sessionsError && - (memorySnapshotError !== null || snapshot === null) + // "never fetched". If session refresh fails before a memory snapshot exists, + // treat that as daemon-unreachable too. + const daemonUnreachable = sessionsError && (memorySnapshotError !== null || snapshot === null) // Why: a partial failure where the sessions IPC fails but the snapshot // IPC still works was silently invisible after the merge — the old // SessionsTabPanel surfaced it as "Terminal sessions unavailable". Show // a slim inline notice so the user understands why the session list is // empty/stale even though the resource numbers look fine. - const sessionsOnlyError = - !runtimeEnvironmentActive && sessionsError && memorySnapshotError === null + const sessionsOnlyError = sessionsError && memorySnapshotError === null const resourceManagerTooltipLines = getResourceManagerTooltipLines({ memoryLabel: memBadgeLabel, - sessionCount: sessions.length, - runtimeEnvironmentActive, + sessionCount: triggerSessionCount, spaceScanReady }) const resourceManagerAriaLabel = getResourceManagerAriaLabel({ - sessionCount: sessions.length, - runtimeEnvironmentActive, + sessionCount: triggerSessionCount, spaceScanReady }) @@ -1089,12 +1068,9 @@ export function ResourceUsageStatusSegment({ }, []) const handleOpenWorkspaceCleanup = useCallback((): void => { - if (runtimeEnvironmentActive) { - return - } setOpen(false) queueMicrotask(() => openModal('workspace-cleanup')) - }, [openModal, runtimeEnvironmentActive]) + }, [openModal]) const handleKillSession = useCallback( (session: UnifiedSessionRow): void => { @@ -1132,8 +1108,8 @@ export function ResourceUsageStatusSegment({ if (orphans.length === 0) { return } - // Why: optimistic removal so the rows disappear immediately rather than - // lingering up to SESSIONS_POLL_MS while the daemon-side list reconciles. + // Why: optimistic removal so rows disappear immediately instead of waiting + // for the next explicit daemon-side list refresh. const orphanIds = new Set(orphans.map((s) => s.id)) setSessions((prev) => prev.filter((s) => !orphanIds.has(s.id))) await Promise.allSettled(orphans.map((s) => window.api.pty.kill(s.id))) @@ -1148,7 +1124,7 @@ export function ResourceUsageStatusSegment({ setKilling(true) // Why: optimistic removal — the kill X was on the row that's about to be // unmounted, so updating local state immediately avoids a flash where the - // dialog closes but the killed row stays for up to 10s. + // dialog closes but the killed row waits for the next list refresh. setSessions((prev) => prev.filter((s) => s.id !== target.sessionId)) try { await window.api.pty.kill(target.sessionId) @@ -1205,7 +1181,7 @@ export function ResourceUsageStatusSegment({ : resourceManagerAriaLabel } > - {spaceScanReady && !runtimeEnvironmentActive ? ( + {spaceScanReady ? ( - {sessions.length} + {triggerSessionCount} {orphanCount > 0 && ( ({orphanCount}) )} )} - {iconOnly && sessions.length > 0 && ( + {iconOnly && triggerSessionCount > 0 && ( - {sessions.length} + {triggerSessionCount} )} {daemonUnreachable && ( @@ -1276,15 +1252,10 @@ export function ResourceUsageStatusSegment({
- {runtimeEnvironmentActive - ? translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.6a822b06a7', - 'Resource Manager' - ) - : translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.6d9793d4bc', - 'Resource Manager - Terminals' - )} + {translate( + 'auto.components.status.bar.ResourceUsageStatusSegment.6d9793d4bc', + 'Resource Manager - Terminals' + )}
@@ -1294,7 +1265,7 @@ export function ResourceUsageStatusSegment({ - {runtimeEnvironmentActive - ? translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.14ff448686', - 'Unavailable for runtime servers' - ) - : translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.c9382662bb', - 'Restart daemon' - )} + {translate( + 'auto.components.status.bar.ResourceUsageStatusSegment.c9382662bb', + 'Restart daemon' + )} @@ -1321,7 +1287,7 @@ export function ResourceUsageStatusSegment({ - {runtimeEnvironmentActive - ? translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.14ff448686', - 'Unavailable for runtime servers' - ) - : translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.bd19fd7a59', - 'Kill all sessions' - )} + {translate( + 'auto.components.status.bar.ResourceUsageStatusSegment.bd19fd7a59', + 'Kill all sessions' + )}
@@ -1578,66 +1539,55 @@ export function ResourceUsageStatusSegment({ {!resourceSnapshot && !daemonUnreachable && (
- {runtimeEnvironmentActive - ? translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.56b6888304', - 'Local resource usage hidden for runtime servers.' - ) - : translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.888dad8c55', - 'Loading…' - )} + {translate( + 'auto.components.status.bar.ResourceUsageStatusSegment.888dad8c55', + 'Loading…' + )}
)}
- {!runtimeEnvironmentActive || orphanCount > 0 ? ( -
- {!runtimeEnvironmentActive ? ( - + {orphanCount > 0 ? ( + - ) : null} - {orphanCount > 0 ? ( - - ) : null} -
- ) : null} + + ) : null} +
- {!runtimeEnvironmentActive ? ( - - ) : null} + {/* Why: Radix Dialog must not be a descendant of PopoverContent — when the popover unmounts (e.g. clicking outside, focus moving to the @@ -1718,7 +1668,7 @@ export function ResourceUsageStatusSegment({ - {!runtimeEnvironmentActive && } + ) } diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index 4b15511ec7a..6dd7c102d18 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -53,7 +53,7 @@ import { formatResetCreditExpiry, getProviderUsageStatusLabel } from './tooltip' -import { ClaudeIcon, GeminiIcon, OpenAIIcon, OpenCodeGoIcon } from './icons' +import { ClaudeIcon, GeminiIcon, MiniMaxIcon, OpenAIIcon, OpenCodeGoIcon } from './icons' import { AgentIcon } from '@/lib/agent-catalog' import { formatWindowLabel } from '@/lib/window-label-formatter' import { markLiveCodexSessionsForRestart } from '@/lib/codex-session-restart' @@ -901,59 +901,69 @@ function MiniBar({ leftPct }: { leftPct: number }): React.JSX.Element { // Inline usage bars (compact bars for inactive accounts in the switcher) // --------------------------------------------------------------------------- -function InlineUsageBars({ +export function InlineUsageBars({ limits, isFetching }: { limits: ProviderRateLimits isFetching: boolean }): React.JSX.Element { - const sessionLeft = limits.session - ? Math.max(0, Math.round(100 - limits.session.usedPercent)) - : null - const weeklyLeft = limits.weekly ? Math.max(0, Math.round(100 - limits.weekly.usedPercent)) : null + const usageWindows = [ + limits.session + ? { + key: 'session', + left: Math.max(0, Math.round(100 - limits.session.usedPercent)), + label: translate('auto.components.status.bar.StatusBar.d79c3362c4', '% 5h') + } + : null, + limits.weekly + ? { + key: 'weekly', + left: Math.max(0, Math.round(100 - limits.weekly.usedPercent)), + label: translate('auto.components.status.bar.StatusBar.5c938d39ac', '% wk') + } + : null, + limits.fableWeekly + ? { + key: 'fableWeekly', + left: Math.max(0, Math.round(100 - limits.fableWeekly.usedPercent)), + label: translate('auto.components.status.bar.StatusBar.54e8d6bb2d', '% Fable') + } + : null + ].filter((window): window is { key: string; left: number; label: string } => window !== null) return ( -
- {sessionLeft !== null && ( -
-
+
+ {usageWindows.map((window) => ( +
+
- - {sessionLeft} - {translate('auto.components.status.bar.StatusBar.d79c3362c4', '% 5h')} + + {window.left} + {window.label}
- )} - {weeklyLeft !== null && ( -
-
-
-
- - {weeklyLeft} - {translate('auto.components.status.bar.StatusBar.5c938d39ac', '% wk')} - -
- )} - {limits.status === 'error' && !limits.session && !limits.weekly && ( + ))} + {usageWindows.length === 0 && limits.status === 'error' ? ( {translate('auto.components.status.bar.StatusBar.f19a63e7cd', 'Sign in to see usage')} - )} + ) : null}
) } function isUnavailableInactiveUsage(limits: ProviderRateLimits | null | undefined): boolean { - return limits?.status === 'error' && !limits.session && !limits.weekly + return limits?.status === 'error' && !limits.session && !limits.weekly && !limits.fableWeekly } function InlineUsageSignInAction({ @@ -1053,7 +1063,7 @@ function ProviderSegment({ } // Fetching with no prior data - if (p.status === 'fetching' && !p.session && !p.weekly) { + if (p.status === 'fetching' && !p.session && !p.weekly && !p.fableWeekly) { return ( @@ -1072,7 +1082,7 @@ function ProviderSegment({ } // Error with no data - if (p.status === 'error' && !p.session && !p.weekly) { + if (p.status === 'error' && !p.session && !p.weekly && !p.fableWeekly) { return ( @@ -1109,15 +1119,40 @@ function ProviderSegment({ ) } + const visibleWindows = [ + p.session + ? { + key: 'session', + window: p.session, + label: formatWindowLabel(p.session.windowMinutes) + } + : null, + p.weekly + ? { + key: 'weekly', + window: p.weekly, + label: formatWindowLabel(p.weekly.windowMinutes) + } + : null, + p.fableWeekly + ? { + key: 'fableWeekly', + window: p.fableWeekly, + label: translate('auto.components.status.bar.StatusBar.a79c64f87e', 'Fable') + } + : null + ].filter((w): w is { key: string; window: RateLimitWindow; label: string } => w !== null) + return ( {p.session && !compact && } - {p.session && ( - - )} - {p.session && p.weekly && ·} - {p.weekly && } + {visibleWindows.map((window, index) => ( + + {index > 0 && ·} + + + ))} {isStale && } ) @@ -1643,7 +1678,7 @@ export function ProviderDetailsMenu({ {iconOnly ? ( {provider.provider === 'claude' @@ -1654,7 +1689,9 @@ export function ProviderDetailsMenu({ ? 'O' : provider.provider === 'kimi' ? 'K' - : 'X'} + : provider.provider === 'minimax' + ? 'M' + : 'X'} ) : ( @@ -1796,17 +1833,21 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele return null } - const { claude, codex, gemini, opencodeGo, kimi } = rateLimits + const { claude, codex, gemini, opencodeGo, kimi, minimax } = rateLimits // Why: a provider earns a bar from either a usable live snapshot or durable // setup in Settings. The durable path keeps account switchers visible while // usage snapshots hydrate, fail, or temporarily report unavailable. // Detection-gating (see status-bar-agent-gating) additionally hides per-CLI // bars when the agent isn't installed on PATH. - const visibleClaude = getVisibleUsageProvider('claude', claude, settings) - const visibleCodex = getVisibleUsageProvider('codex', codex, settings) - const visibleGemini = getVisibleUsageProvider('gemini', gemini, settings) - const visibleKimi = getVisibleUsageProvider('kimi', kimi, settings) + // Why: thread the cookie durability flag from RateLimitState so the + // MiniMax bar stays visible after a reload and between snapshot refreshes. + const usageSettings = { ...settings, minimaxCookieConfigured: rateLimits.minimaxCookieConfigured } + const visibleClaude = getVisibleUsageProvider('claude', claude, usageSettings) + const visibleCodex = getVisibleUsageProvider('codex', codex, usageSettings) + const visibleGemini = getVisibleUsageProvider('gemini', gemini, usageSettings) + const visibleKimi = getVisibleUsageProvider('kimi', kimi, usageSettings) + const visibleMiniMax = getVisibleUsageProvider('minimax', minimax, usageSettings) const showClaude = visibleClaude !== null && statusBarItems.includes('claude') && @@ -1823,9 +1864,12 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele visibleKimi !== null && statusBarItems.includes('kimi') && isStatusBarItemAvailable('kimi', detectedAgentIds) + // Why: MiniMax is a cookie-auth provider, not a CLI on PATH, so detection-gating + // doesn't apply (same rationale as OpenCode Go below). + const showMiniMax = visibleMiniMax !== null && statusBarItems.includes('minimax') // Why: OpenCode Go is a web/cookie-auth provider, not a CLI on PATH, so // detection-gating doesn't apply. - const visibleOpencodeGo = getVisibleUsageProvider('opencode-go', opencodeGo, settings) + const visibleOpencodeGo = getVisibleUsageProvider('opencode-go', opencodeGo, usageSettings) const showOpencodeGo = visibleOpencodeGo !== null && statusBarItems.includes('opencode-go') const showSsh = statusBarItems.includes('ssh') const showResourceUsage = statusBarItems.includes('resource-usage') @@ -1833,12 +1877,21 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele const showFloatingTerminalToggle = floatingTerminalEnabled && floatingTerminalTriggerLocation === 'status-bar' const anyVisible = - showClaude || showCodex || showGemini || showOpencodeGo || showKimi || showResourceUsage + showClaude || + showCodex || + showGemini || + showOpencodeGo || + showKimi || + showMiniMax || + showResourceUsage // Why: a brand-new user with no provider configured would otherwise see an // empty left side of the status bar and wonder what's missing. Settings are // included because managed accounts are durable even when live usage // snapshots are still hydrating or unavailable after an update. - const isEmptyUsageState = isUsageEmptyState({ claude, codex, gemini, opencodeGo, kimi }, settings) + const isEmptyUsageState = isUsageEmptyState( + { claude, codex, gemini, opencodeGo, kimi, minimax }, + usageSettings + ) // Why: the teaching CTA is a one-time nudge — once the user hides it, keep it // hidden even after providers are disconnected again. const showEmptyUsageCta = isEmptyUsageState && !usageEmptyStateDismissed @@ -1847,7 +1900,8 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele codex?.status === 'fetching' || gemini?.status === 'fetching' || opencodeGo?.status === 'fetching' || - kimi?.status === 'fetching' + kimi?.status === 'fetching' || + minimax?.status === 'fetching' const compact = containerWidth < 900 const iconOnly = containerWidth < 500 @@ -1922,6 +1976,17 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele )} /> )} + {showMiniMax && ( + + )} )} {anyVisible && !isEmptyUsageState && ( @@ -2052,6 +2117,16 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele {translate('auto.components.status.bar.StatusBar.5e59007df4', 'Kimi Usage')} )} + { + recordFeatureInteraction('usage-tracking') + toggleStatusBarItem('minimax') + }} + > + + {translate('auto.components.status.bar.StatusBar.3bbf140864', 'MiniMax Usage')} + { diff --git a/src/renderer/src/components/status-bar/icons.test.tsx b/src/renderer/src/components/status-bar/icons.test.tsx new file mode 100644 index 00000000000..d46d9a65cce --- /dev/null +++ b/src/renderer/src/components/status-bar/icons.test.tsx @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { renderToStaticMarkup } from 'react-dom/server' +import { MiniMaxIcon } from './icons' + +describe('MiniMaxIcon', () => { + it('renders the official MiniMax mark as an image', () => { + const markup = renderToStaticMarkup() + expect(markup.startsWith(' { + const markup = renderToStaticMarkup() + expect(markup).toContain('width="20"') + expect(markup).toContain('height="20"') + }) + + it('does not render the legacy "M" placeholder text', () => { + const markup = renderToStaticMarkup() + expect(markup).not.toContain('>M<') + }) +}) diff --git a/src/renderer/src/components/status-bar/icons.tsx b/src/renderer/src/components/status-bar/icons.tsx index e6fb38bccca..480297b6412 100644 --- a/src/renderer/src/components/status-bar/icons.tsx +++ b/src/renderer/src/components/status-bar/icons.tsx @@ -1,4 +1,5 @@ import React from 'react' +import minimaxIconUrl from '../../../../../resources/minimax-icon.svg?url' export function OpenAIIcon({ size = 14 }: { size?: number }): React.JSX.Element { return ( @@ -15,6 +16,24 @@ export function OpenAIIcon({ size = 14 }: { size?: number }): React.JSX.Element ) } +export function MiniMaxIcon({ size = 14 }: { size?: number }): React.JSX.Element { + // Why: ship the official MiniMax wordmark mark so the icon is recognizable + // in both light and dark themes. The SVG carries its own pink→orange gradient + // that reads on muted backgrounds, so we keep the asset monochrome-agnostic + // and let CSS theme tokens drive the surrounding chrome instead. + return ( + + ) +} + // Why: each instance needs unique filter/mask IDs — reusing the same ID across // multiple SVGs on the same page causes the browser to resolve to the first one, // breaking all subsequent instances. diff --git a/src/renderer/src/components/status-bar/inline-usage-bars.test.tsx b/src/renderer/src/components/status-bar/inline-usage-bars.test.tsx new file mode 100644 index 00000000000..0160978beb7 --- /dev/null +++ b/src/renderer/src/components/status-bar/inline-usage-bars.test.tsx @@ -0,0 +1,53 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import type { ProviderRateLimits } from '../../../../shared/rate-limit-types' + +vi.mock('@/i18n/i18n', () => ({ + i18n: { language: 'en' }, + translate: (_key: string, fallback: string) => fallback +})) + +vi.mock('@/lib/agent-catalog', () => ({ + AgentIcon: () => null +})) + +function claudeLimits(): ProviderRateLimits { + return { + provider: 'claude', + session: { + usedPercent: 32, + windowMinutes: 300, + resetsAt: null, + resetDescription: null + }, + weekly: { + usedPercent: 16, + windowMinutes: 10080, + resetsAt: null, + resetDescription: null + }, + fableWeekly: { + usedPercent: 42, + windowMinutes: 10080, + resetsAt: null, + resetDescription: null + }, + updatedAt: Date.now(), + error: null, + status: 'ok' + } +} + +describe('InlineUsageBars', () => { + it('renders Claude Fable usage in inactive account preview rows', async () => { + const { InlineUsageBars } = await import('./StatusBar') + + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('68% 5h') + expect(markup).toContain('84% wk') + expect(markup).toContain('58% Fable') + }) +}) diff --git a/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.test.ts b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.test.ts index ad0d13dd0da..e1b96f4b9c8 100644 --- a/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.test.ts +++ b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.test.ts @@ -53,6 +53,7 @@ const baseCtx = (overrides: Partial = {}): MergeContext => ({ workspaceSessionReady: true, repoDisplayNameById: new Map(), repoConnectionIdById: new Map(), + repoRuntimeScopedById: new Map(), ...overrides }) @@ -251,6 +252,58 @@ describe('mergeSnapshotAndSessions', () => { expect(remote.worktrees[0].isRemote).toBe(true) }) + it('excludes runtime-scoped rows while preserving SSH and unattributed sessions', () => { + const runtimeWt: WorktreeMemory = { + worktreeId: 'runtime-repo::/runtime/Wt', + worktreeName: 'Wt', + repoId: 'runtime-repo', + repoName: 'RUNTIME', + cpu: 5, + memory: 500_000_000, + history: [], + sessions: [{ sessionId: 'runtime-pty', paneKey: null, pid: 2, cpu: 5, memory: 500_000_000 }] + } + const sessions: DaemonSession[] = [ + { id: 'runtime-repo::/runtime/Wt@@future-runtime', cwd: '', title: 'runtime/Wt' }, + { id: 'ssh-repo::/remote/Wt@@ssh-session', cwd: '', title: 'ssh/Wt' }, + { id: 'opaque-local-orphan', cwd: '', title: 'orphan shell' } + ] + const ctx = baseCtx({ + repoConnectionIdById: new Map([ + ['runtime-repo', null], + ['ssh-repo', 'ssh-target-1'] + ]), + repoRuntimeScopedById: new Map([ + ['runtime-repo', true], + ['ssh-repo', false] + ]) + }) + + const out = mergeSnapshotAndSessions(makeSnapshot([runtimeWt]), sessions, ctx) + + expect(out.map((repo) => repo.repoId)).toEqual(['ssh-repo', UNATTRIBUTED_REPO_ID]) + expect(out.find((repo) => repo.repoId === 'runtime-repo')).toBeUndefined() + + const ssh = out.find((repo) => repo.repoId === 'ssh-repo')! + expect(ssh.hasRemoteChildren).toBe(true) + expect(ssh.worktrees[0]).toMatchObject({ + isRemote: true, + cpu: null, + memory: null + }) + expect(ssh.worktrees[0].sessions[0]).toMatchObject({ + sessionId: 'ssh-repo::/remote/Wt@@ssh-session', + bound: false + }) + + const orphan = out.find((repo) => repo.repoId === UNATTRIBUTED_REPO_ID)! + expect(orphan.hasRemoteChildren).toBe(false) + expect(orphan.worktrees[0].sessions[0]).toMatchObject({ + sessionId: 'opaque-local-orphan', + bound: false + }) + }) + it('unresolvable session falls into unattributed bucket without flagging remote', () => { // Why: under the connectionId predicate, an unresolved session is // not evidence of remoteness — we just don't know what it belongs diff --git a/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts index 6fcfa3b2acf..e4d12744f90 100644 --- a/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts +++ b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts @@ -152,6 +152,10 @@ export function mergeSnapshotAndSessions( return ctx.repoConnectionIdById.get(repoId) != null } + function isRuntimeScopedRepo(repoId: string): boolean { + return ctx.repoRuntimeScopedById.get(repoId) === true + } + function ensureRepo( repoId: string, repoName: string, @@ -183,6 +187,11 @@ export function mergeSnapshotAndSessions( // ── Step 1: ingest snapshot worktrees as the local-truth foundation. if (snapshot) { for (const wt of snapshot.worktrees as readonly WorktreeMemory[]) { + // Why: local snapshot data must never render under a runtime-hosted repo + // row; belt-and-braces with the matching session-ingest guard below. + if (isRuntimeScopedRepo(wt.repoId)) { + continue + } const repo = ensureRepo(wt.repoId, wt.repoName) const sessions: UnifiedSessionRow[] = wt.sessions.map((s) => { seenSessionIds.add(s.sessionId) @@ -243,6 +252,12 @@ export function mergeSnapshotAndSessions( ? session.title || session.id.slice(0, 12) : deriveWorktreeNameFromWorktreeId(finalWorktreeId) + // Why: the current daemon inputs are local/SSH only; this guard prevents a + // future local daemon row accidentally exposing kill actions for runtime PTYs. + if (isRuntimeScopedRepo(finalRepoId)) { + continue + } + const repoIsRemote = isRepoRemote(finalRepoId) const repo = ensureRepo(finalRepoId, finalRepoName, repoIsRemote) if (repoIsRemote) { diff --git a/src/renderer/src/components/status-bar/resource-manager-terminal-copy.test.ts b/src/renderer/src/components/status-bar/resource-manager-terminal-copy.test.ts index a99aff11fa1..2a34e5e3dff 100644 --- a/src/renderer/src/components/status-bar/resource-manager-terminal-copy.test.ts +++ b/src/renderer/src/components/status-bar/resource-manager-terminal-copy.test.ts @@ -16,7 +16,6 @@ describe('resource manager terminal copy', () => { getResourceManagerTooltipLines({ memoryLabel: '512 MB', sessionCount: 2, - runtimeEnvironmentActive: false, spaceScanReady: false }) ).toEqual([ @@ -25,17 +24,17 @@ describe('resource manager terminal copy', () => { ]) }) - it('does not advertise local session navigation for runtime servers', () => { + it('keeps local session copy active under runtime focus', () => { expect( getResourceManagerTooltipLines({ memoryLabel: '-', sessionCount: 0, - runtimeEnvironmentActive: true, spaceScanReady: true }) ).toEqual([ 'Resource Manager - memory unavailable - 0 terminal sessions', - 'Local terminal sessions are hidden for runtime servers.' + 'Space scan ready', + 'No terminal sessions yet.' ]) }) @@ -43,7 +42,6 @@ describe('resource manager terminal copy', () => { expect( getResourceManagerAriaLabel({ sessionCount: 1, - runtimeEnvironmentActive: false, spaceScanReady: true }) ).toBe('Resource Manager, 1 terminal session, Space scan ready') diff --git a/src/renderer/src/components/status-bar/resource-manager-terminal-copy.ts b/src/renderer/src/components/status-bar/resource-manager-terminal-copy.ts index 4a14bbb54fb..69b5b7b6193 100644 --- a/src/renderer/src/components/status-bar/resource-manager-terminal-copy.ts +++ b/src/renderer/src/components/status-bar/resource-manager-terminal-copy.ts @@ -5,7 +5,6 @@ export function formatTerminalSessionCount(count: number): string { export function getResourceManagerTooltipLines(args: { memoryLabel: string sessionCount: number - runtimeEnvironmentActive: boolean spaceScanReady: boolean }): string[] { const rawMemoryLabel = args.memoryLabel.trim() @@ -17,13 +16,11 @@ export function getResourceManagerTooltipLines(args: { `Resource Manager - ${memoryLabel} - ${formatTerminalSessionCount(args.sessionCount)}` ] - if (args.spaceScanReady && !args.runtimeEnvironmentActive) { + if (args.spaceScanReady) { lines.push('Space scan ready') } - if (args.runtimeEnvironmentActive) { - lines.push('Local terminal sessions are hidden for runtime servers.') - } else if (args.sessionCount > 0) { + if (args.sessionCount > 0) { lines.push('Terminal sessions are grouped by workspace.') } else { lines.push('No terminal sessions yet.') @@ -34,18 +31,13 @@ export function getResourceManagerTooltipLines(args: { export function getResourceManagerAriaLabel(args: { sessionCount: number - runtimeEnvironmentActive: boolean spaceScanReady: boolean }): string { const parts = ['Resource Manager', formatTerminalSessionCount(args.sessionCount)] - if (args.spaceScanReady && !args.runtimeEnvironmentActive) { + if (args.spaceScanReady) { parts.push('Space scan ready') } - if (args.runtimeEnvironmentActive) { - parts.push('local sessions hidden for runtime server') - } - return parts.join(', ') } diff --git a/src/renderer/src/components/status-bar/resource-usage-merge-types.ts b/src/renderer/src/components/status-bar/resource-usage-merge-types.ts index 88791b5d36e..7e208d51817 100644 --- a/src/renderer/src/components/status-bar/resource-usage-merge-types.ts +++ b/src/renderer/src/components/status-bar/resource-usage-merge-types.ts @@ -60,4 +60,6 @@ export type MergeContext = { repoDisplayNameById: Map /** Repo connectionId by repo id (null/missing == local). */ repoConnectionIdById: Map + /** Repo runtime-host scope by repo id (missing == keep row). */ + repoRuntimeScopedById: Map } diff --git a/src/renderer/src/components/status-bar/resource-usage-open-slices.test.ts b/src/renderer/src/components/status-bar/resource-usage-open-slices.test.ts index 76374b1cfd1..8fd0fef6d7d 100644 --- a/src/renderer/src/components/status-bar/resource-usage-open-slices.test.ts +++ b/src/renderer/src/components/status-bar/resource-usage-open-slices.test.ts @@ -71,22 +71,20 @@ describe('resource usage open slices', () => { ) }) - it('gates repo and worktree slices while closed or runtime-backed', () => { + it('gates repo and worktree slices only while closed', () => { const repos = [{ id: 'repo-1', path: '/repo', kind: 'git' }] as AppState['repos'] const row = worktree() const worktreesByRepo = { 'repo-1': [row] } - expect(getResourceUsageRepos({ repos }, false, false)).toBe( - getResourceUsageRepos({ repos: [] }, false, false) + expect(getResourceUsageRepos({ repos }, false)).toBe( + getResourceUsageRepos({ repos: [] }, false) ) - expect(getResourceUsageAllWorktrees({ worktreesByRepo }, false, false)).toBe( - getResourceUsageAllWorktrees({ worktreesByRepo: {} }, false, false) + expect(getResourceUsageAllWorktrees({ worktreesByRepo }, false)).toBe( + getResourceUsageAllWorktrees({ worktreesByRepo: {} }, false) ) - expect(getResourceUsageRepos({ repos }, true, true)).toEqual([]) - expect(getResourceUsageAllWorktrees({ worktreesByRepo }, true, true)).toEqual([]) - expect(getResourceUsageRepos({ repos }, true, false)).toBe(repos) - expect(getResourceUsageAllWorktrees({ worktreesByRepo }, true, false)).toEqual([row]) + expect(getResourceUsageRepos({ repos }, true)).toBe(repos) + expect(getResourceUsageAllWorktrees({ worktreesByRepo }, true)).toEqual([row]) }) }) diff --git a/src/renderer/src/components/status-bar/resource-usage-open-slices.ts b/src/renderer/src/components/status-bar/resource-usage-open-slices.ts index 1a2b8cefcf5..e74a79e2e10 100644 --- a/src/renderer/src/components/status-bar/resource-usage-open-slices.ts +++ b/src/renderer/src/components/status-bar/resource-usage-open-slices.ts @@ -6,44 +6,30 @@ const EMPTY_RUNTIME_PANE_TITLES_BY_TAB_ID: AppState['runtimePaneTitlesByTabId'] const EMPTY_REPOS: AppState['repos'] = [] const EMPTY_WORKTREES: ReturnType = [] -function shouldReadPopoverSlices(open: boolean, runtimeEnvironmentActive: boolean): boolean { - return open && !runtimeEnvironmentActive -} - export function getResourceUsageTabsByWorktree( state: Pick, - open: boolean, - runtimeEnvironmentActive = false + open: boolean ): AppState['tabsByWorktree'] { - return shouldReadPopoverSlices(open, runtimeEnvironmentActive) - ? state.tabsByWorktree - : EMPTY_TABS_BY_WORKTREE + return open ? state.tabsByWorktree : EMPTY_TABS_BY_WORKTREE } export function getResourceUsageRuntimePaneTitlesByTabId( state: Pick, - open: boolean, - runtimeEnvironmentActive = false + open: boolean ): AppState['runtimePaneTitlesByTabId'] { - return shouldReadPopoverSlices(open, runtimeEnvironmentActive) - ? state.runtimePaneTitlesByTabId - : EMPTY_RUNTIME_PANE_TITLES_BY_TAB_ID + return open ? state.runtimePaneTitlesByTabId : EMPTY_RUNTIME_PANE_TITLES_BY_TAB_ID } export function getResourceUsageRepos( state: Pick, - open: boolean, - runtimeEnvironmentActive: boolean + open: boolean ): AppState['repos'] { - return shouldReadPopoverSlices(open, runtimeEnvironmentActive) ? state.repos : EMPTY_REPOS + return open ? state.repos : EMPTY_REPOS } export function getResourceUsageAllWorktrees( state: Pick, - open: boolean, - runtimeEnvironmentActive: boolean + open: boolean ): ReturnType { - return shouldReadPopoverSlices(open, runtimeEnvironmentActive) - ? getAllWorktreesFromState(state) - : EMPTY_WORKTREES + return open ? getAllWorktreesFromState(state) : EMPTY_WORKTREES } diff --git a/src/renderer/src/components/status-bar/resource-usage-space-scan-ready.test.ts b/src/renderer/src/components/status-bar/resource-usage-space-scan-ready.test.ts index 447985dcc05..c7e00d9b4f2 100644 --- a/src/renderer/src/components/status-bar/resource-usage-space-scan-ready.test.ts +++ b/src/renderer/src/components/status-bar/resource-usage-space-scan-ready.test.ts @@ -15,7 +15,6 @@ describe('resolveResourceUsageSpaceScanReady', () => { expect( resolveResourceUsageSpaceScanReady({ snapshot: { ...baseSnapshot, previousScanning: true }, - runtimeEnvironmentActive: false, open: false, activeView: 'terminal', scannedAt: 100, @@ -32,7 +31,6 @@ describe('resolveResourceUsageSpaceScanReady', () => { expect( resolveResourceUsageSpaceScanReady({ snapshot: { ...baseSnapshot, previousScanning: true }, - runtimeEnvironmentActive: false, open: true, activeView: 'terminal', scannedAt: 100, @@ -47,7 +45,6 @@ describe('resolveResourceUsageSpaceScanReady', () => { expect( resolveResourceUsageSpaceScanReady({ snapshot: { ...baseSnapshot, previousScanning: true }, - runtimeEnvironmentActive: false, open: false, activeView: 'space', scannedAt: 100, @@ -66,7 +63,6 @@ describe('resolveResourceUsageSpaceScanReady', () => { expect( resolveResourceUsageSpaceScanReady({ snapshot: readySnapshot, - runtimeEnvironmentActive: false, open: true, activeView: 'terminal', scannedAt: 100, @@ -81,7 +77,6 @@ describe('resolveResourceUsageSpaceScanReady', () => { expect( resolveResourceUsageSpaceScanReady({ snapshot: readySnapshot, - runtimeEnvironmentActive: false, open: false, activeView: 'space', scannedAt: 100, @@ -97,7 +92,6 @@ describe('resolveResourceUsageSpaceScanReady', () => { previousScanning: true, lastSeenScannedAt: 100 }, - runtimeEnvironmentActive: false, open: false, activeView: 'terminal', scannedAt: 100, @@ -108,7 +102,7 @@ describe('resolveResourceUsageSpaceScanReady', () => { expect(result.lastSeenScannedAt).toBe(100) }) - it('hides local Space scan handoffs while a remote runtime is active', () => { + it('keeps local Space scan handoffs independent of remote runtime focus', () => { expect( resolveResourceUsageSpaceScanReady({ snapshot: { @@ -116,15 +110,14 @@ describe('resolveResourceUsageSpaceScanReady', () => { previousScanning: true, lastSeenScannedAt: 100 }, - runtimeEnvironmentActive: true, open: false, activeView: 'terminal', scannedAt: 200, scanning: true }) ).toEqual({ - ready: false, - previousScanning: false, + ready: true, + previousScanning: true, lastSeenScannedAt: 100 }) }) @@ -133,7 +126,6 @@ describe('resolveResourceUsageSpaceScanReady', () => { expect( resolveResourceUsageSpaceScanReady({ snapshot: baseSnapshot, - runtimeEnvironmentActive: false, open: false, activeView: 'terminal', scannedAt: null, diff --git a/src/renderer/src/components/status-bar/resource-usage-space-scan-ready.ts b/src/renderer/src/components/status-bar/resource-usage-space-scan-ready.ts index dd8a6aaedad..6b0be7beb55 100644 --- a/src/renderer/src/components/status-bar/resource-usage-space-scan-ready.ts +++ b/src/renderer/src/components/status-bar/resource-usage-space-scan-ready.ts @@ -6,27 +6,17 @@ export type ResourceUsageSpaceScanSnapshot = { export function resolveResourceUsageSpaceScanReady({ snapshot, - runtimeEnvironmentActive, open, activeView, scannedAt, scanning }: { snapshot: ResourceUsageSpaceScanSnapshot - runtimeEnvironmentActive: boolean open: boolean activeView: string scannedAt: number | null scanning: boolean }): ResourceUsageSpaceScanSnapshot { - if (runtimeEnvironmentActive) { - return { - ready: false, - previousScanning: false, - lastSeenScannedAt: snapshot.lastSeenScannedAt - } - } - const scanCompleted = snapshot.previousScanning && !scanning && diff --git a/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts b/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts index 790a4dbfa82..23de9434c37 100644 --- a/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts +++ b/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts @@ -70,6 +70,7 @@ function usageSettings(overrides: Partial = {}): UsagePro claudeManagedAccounts: [], opencodeSessionCookie: '', geminiCliOAuthEnabled: false, + minimaxCookieConfigured: false, ...overrides } } @@ -117,6 +118,7 @@ describe('hasUsageProviderSettings', () => { expect( hasUsageProviderSettings(usageSettings({ opencodeSessionCookie: ' session=abc ' })) ).toBe(true) + expect(hasUsageProviderSettings(usageSettings({ minimaxCookieConfigured: true }))).toBe(true) }) it('does not treat empty or unloaded settings as configured', () => { @@ -147,6 +149,17 @@ describe('hasUsageProviderSettingsForProvider', () => { expect(hasUsageProviderSettingsForProvider('claude', usageSettings())).toBe(false) expect(hasUsageProviderSettingsForProvider('kimi', usageSettings())).toBe(false) }) + + it('treats minimaxCookieConfigured as the durable signal for MiniMax', () => { + expect( + hasUsageProviderSettingsForProvider( + 'minimax', + usageSettings({ minimaxCookieConfigured: true }) + ) + ).toBe(true) + expect(hasUsageProviderSettingsForProvider('minimax', usageSettings())).toBe(false) + expect(hasUsageProviderSettingsForProvider('minimax', null)).toBe(false) + }) }) describe('getVisibleUsageProvider', () => { @@ -207,6 +220,45 @@ describe('getVisibleUsageProvider', () => { expect(getVisibleUsageProvider('codex', null, usageSettings())).toBe(null) expect(getVisibleUsageProvider('gemini', provider('fetching'), usageSettings())).toBe(null) }) + + it('keeps MiniMax visible while the snapshot is pending when a cookie is configured', () => { + const visible = getVisibleUsageProvider( + 'minimax', + null, + usageSettings({ minimaxCookieConfigured: true }) + ) + expect(visible).toMatchObject({ + provider: 'minimax', + status: 'fetching', + session: null, + weekly: null + }) + }) + + it('keeps MiniMax visible when the fetch returns unavailable for a configured cookie', () => { + const unavailable = provider('unavailable', { + provider: 'minimax', + error: 'MiniMax session expired. Replace the MiniMax cookie in Settings.' + }) + expect( + getVisibleUsageProvider( + 'minimax', + unavailable, + usageSettings({ minimaxCookieConfigured: true }) + ) + ).toBe(unavailable) + }) + + it('hides MiniMax when no cookie is configured and the snapshot is empty', () => { + expect(getVisibleUsageProvider('minimax', null, usageSettings())).toBe(null) + expect( + getVisibleUsageProvider( + 'minimax', + provider('unavailable', { provider: 'minimax' }), + usageSettings() + ) + ).toBe(null) + }) }) describe('isUsageEmptyState', () => { @@ -218,7 +270,8 @@ describe('isUsageEmptyState', () => { codex: null, gemini: null, opencodeGo: null, - kimi: null + kimi: null, + minimax: null }, usageSettings() ) @@ -233,7 +286,8 @@ describe('isUsageEmptyState', () => { codex: provider('fetching', { provider: 'codex' }), gemini: provider('unavailable'), opencodeGo: provider('unavailable', { provider: 'opencode-go' }), - kimi: provider('unavailable', { provider: 'kimi' }) + kimi: provider('unavailable', { provider: 'kimi' }), + minimax: provider('unavailable', { provider: 'minimax' }) }, usageSettings() ) @@ -248,7 +302,8 @@ describe('isUsageEmptyState', () => { codex: provider('unavailable', { provider: 'codex' }), gemini: provider('unavailable'), opencodeGo: provider('unavailable', { provider: 'opencode-go' }), - kimi: provider('unavailable', { provider: 'kimi' }) + kimi: provider('unavailable', { provider: 'kimi' }), + minimax: provider('unavailable', { provider: 'minimax' }) }, usageSettings({ codexManagedAccounts: [ @@ -274,7 +329,8 @@ describe('isUsageEmptyState', () => { codex: null, gemini: null, opencodeGo: null, - kimi: null + kimi: null, + minimax: null }, null ) @@ -289,7 +345,8 @@ describe('isUsageEmptyState', () => { codex: provider('unavailable', { provider: 'codex' }), gemini: provider('unavailable'), opencodeGo: provider('unavailable', { provider: 'opencode-go' }), - kimi: provider('unavailable', { provider: 'kimi' }) + kimi: provider('unavailable', { provider: 'kimi' }), + minimax: provider('unavailable', { provider: 'minimax' }) }, usageSettings() ) diff --git a/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts b/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts index bd2c5c1dcaa..bbac8ba8a38 100644 --- a/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts +++ b/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts @@ -7,7 +7,13 @@ export type UsageProviderSettings = Pick< | 'claudeManagedAccounts' | 'opencodeSessionCookie' | 'geminiCliOAuthEnabled' -> +> & { + // Why: the MiniMax cookie lives in the file system, not GlobalSettings, so + // we can't derive durability from settings alone. The renderer threads the + // flag from RateLimitState (pushed by the main process) so the bar stays + // visible across reloads and between snapshot refreshes. + minimaxCookieConfigured: boolean +} type UsageProviderSnapshots = { claude: ProviderRateLimits | null @@ -15,6 +21,7 @@ type UsageProviderSnapshots = { gemini: ProviderRateLimits | null opencodeGo: ProviderRateLimits | null kimi: ProviderRateLimits | null + minimax: ProviderRateLimits | null } type UsageProviderId = ProviderRateLimits['provider'] @@ -23,6 +30,7 @@ function hasUsageData(provider: ProviderRateLimits): boolean { return Boolean( provider.session || provider.weekly || + provider.fableWeekly || provider.monthly || (provider.buckets && provider.buckets.length > 0) ) @@ -57,7 +65,8 @@ export function hasUsageProviderSettings( (settings?.codexManagedAccounts?.length ?? 0) > 0 || (settings?.claudeManagedAccounts?.length ?? 0) > 0 || settings?.geminiCliOAuthEnabled === true || - Boolean(settings?.opencodeSessionCookie?.trim()) + Boolean(settings?.opencodeSessionCookie?.trim()) || + settings?.minimaxCookieConfigured === true ) } @@ -80,6 +89,9 @@ export function hasUsageProviderSettingsForProvider( if (providerId === 'opencode-go') { return Boolean(settings.opencodeSessionCookie?.trim()) } + if (providerId === 'minimax') { + return settings.minimaxCookieConfigured === true + } return false } @@ -127,7 +139,8 @@ export function isUsageEmptyState( isProviderSnapshotPending(providers.codex) || isProviderSnapshotPending(providers.gemini) || isProviderSnapshotPending(providers.opencodeGo) || - isProviderSnapshotPending(providers.kimi) + isProviderSnapshotPending(providers.kimi) || + isProviderSnapshotPending(providers.minimax) ) { return false } @@ -137,6 +150,7 @@ export function isUsageEmptyState( !isProviderConfigured(providers.codex) && !isProviderConfigured(providers.gemini) && !isProviderConfigured(providers.opencodeGo) && - !isProviderConfigured(providers.kimi) + !isProviderConfigured(providers.kimi) && + !isProviderConfigured(providers.minimax) ) } diff --git a/src/renderer/src/components/status-bar/tooltip.test.ts b/src/renderer/src/components/status-bar/tooltip.test.ts index 356ecd7bd49..510e3eea66f 100644 --- a/src/renderer/src/components/status-bar/tooltip.test.ts +++ b/src/renderer/src/components/status-bar/tooltip.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import { renderToStaticMarkup } from 'react-dom/server' import type { ProviderRateLimits } from '../../../../shared/rate-limit-types' vi.mock('@/lib/agent-catalog', () => ({ @@ -20,7 +21,9 @@ import { formatResetCountdown, getProviderUsageErrorMessage, getProviderUsageStatusLabel, - getWindowSections + getWindowSections, + ProviderIcon, + ProviderPanel } from './tooltip' function provider(overrides: Partial = {}): ProviderRateLimits { @@ -274,6 +277,29 @@ describe('getWindowSections', () => { ]) }) + it('returns a separate Fable section when Claude reports Fable weekly usage', () => { + const p: ProviderRateLimits = { + provider: 'claude', + session: { usedPercent: 40, windowMinutes: 300, resetsAt: null, resetDescription: null }, + weekly: { usedPercent: 20, windowMinutes: 10080, resetsAt: null, resetDescription: null }, + fableWeekly: { + usedPercent: 42, + windowMinutes: 10080, + resetsAt: null, + resetDescription: null + }, + updatedAt: Date.now(), + error: null, + status: 'ok' + } + const sections = getWindowSections(p) + expect(sections).toEqual([ + { label: 'Session', window: p.session }, + { label: 'Weekly', window: p.weekly }, + { label: 'Fable', window: p.fableWeekly } + ]) + }) + it('returns session and weekly for empty buckets array', () => { const p: ProviderRateLimits = { provider: 'gemini', @@ -355,3 +381,80 @@ describe('getWindowSections', () => { expect(sections[0].window!.resetDescription).toBe('5:00 PM') }) }) + +describe('ProviderPanel reset rendering', () => { + it('renders the Fable reset countdown when Claude reports a reset timestamp', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date(2026, 6, 3, 20, 0)) + const p = provider({ + status: 'ok', + session: null, + weekly: null, + fableWeekly: { + usedPercent: 62, + windowMinutes: 10080, + resetsAt: Date.now() + (6 * 24 + 17) * 60 * 60_000, + resetDescription: 'Jul 10 at 1:00 PM' + } + }) + + const markup = renderToStaticMarkup(ProviderPanel({ p })) + + expect(markup).toContain('Fable') + expect(markup).toContain('Resets in 6d 17h') + }) + + it('renders MiniMax session as `100 - usedPercent` left so the value matches the bar', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date(2026, 6, 4, 15, 0)) + const p = provider({ + provider: 'minimax', + status: 'ok', + session: { + usedPercent: 35, + windowMinutes: 300, + resetsAt: Date.now() + 2 * 60 * 60_000, + resetDescription: null + } + }) + + const markup = renderToStaticMarkup(ProviderPanel({ p })) + + // Why: the bar reads "65% 5h"; the tooltip must read "65% left" from the + // same source field so the two views stay consistent. + expect(markup).toContain('65%') + expect(markup).toContain('% left') + expect(markup).not.toContain('100% left') + }) + + it('clamps MiniMax session to 0% left when usedPercent reports 100', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date(2026, 6, 4, 15, 0)) + const p = provider({ + provider: 'minimax', + status: 'ok', + session: { + usedPercent: 100, + windowMinutes: 300, + resetsAt: Date.now() + 2 * 60 * 60_000, + resetDescription: null + } + }) + + const markup = renderToStaticMarkup(ProviderPanel({ p })) + + expect(markup).toContain('0% left') + }) +}) + +describe('ProviderIcon', () => { + it('renders the official MiniMax icon asset for the minimax provider', () => { + // Why: the icon must travel to the status bar / tooltip unchanged so the + // user recognises the brand. We pin it to an with a non-empty + // resource URL and aria-hidden so the icon stays purely decorative. + const markup = renderToStaticMarkup(ProviderIcon({ provider: 'minimax' })) + expect(markup.startsWith(' } + if (provider === 'minimax') { + return + } return } @@ -158,6 +161,12 @@ export function getWindowSections( window: p.weekly } ] + if (p.fableWeekly !== undefined && p.fableWeekly !== null) { + sections.push({ + label: translate('auto.components.status.bar.tooltip.a79c64f87e', 'Fable'), + window: p.fableWeekly + }) + } if (p.monthly !== undefined && p.monthly !== null) { sections.push({ label: translate('auto.components.status.bar.tooltip.7f7f208060', 'Monthly'), @@ -229,7 +238,7 @@ export function ProviderPanel({ ) } - if (p.status === 'error' && !p.session && !p.weekly && !p.monthly) { + if (p.status === 'error' && !p.session && !p.weekly && !p.fableWeekly && !p.monthly) { return (
@@ -324,7 +333,7 @@ export function ProviderPanel({ {p.error ? ( ) : null} diff --git a/src/renderer/src/components/status-bar/usage-error-copy.test.ts b/src/renderer/src/components/status-bar/usage-error-copy.test.ts new file mode 100644 index 00000000000..3fc29780adc --- /dev/null +++ b/src/renderer/src/components/status-bar/usage-error-copy.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +import { getProviderDisplayName } from './usage-error-copy' + +describe('getProviderDisplayName', () => { + it('returns the MiniMax brand name', () => { + expect(getProviderDisplayName('minimax')).toBe('MiniMax') + }) + + it('returns the existing provider brand names', () => { + expect(getProviderDisplayName('claude')).toBe('Claude') + expect(getProviderDisplayName('codex')).toBe('Codex') + expect(getProviderDisplayName('gemini')).toBe('Gemini') + expect(getProviderDisplayName('opencode-go')).toBe('OpenCode Go') + expect(getProviderDisplayName('kimi')).toBe('Kimi') + }) + + it('falls back to the raw provider id when no mapping exists', () => { + // Why: provider id is a closed union, but TypeScript may not enforce + // exhaustiveness on dynamic callers. Fallback keeps logging safe. + expect(getProviderDisplayName('unknown-provider' as never)).toBe('unknown-provider') + }) +}) 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 60c0b99d452..3d33fe763f6 100644 --- a/src/renderer/src/components/status-bar/usage-error-copy.ts +++ b/src/renderer/src/components/status-bar/usage-error-copy.ts @@ -17,6 +17,9 @@ export function getProviderDisplayName(provider: ProviderRateLimits['provider']) if (provider === 'kimi') { return 'Kimi' } + if (provider === 'minimax') { + return 'MiniMax' + } return provider } diff --git a/src/renderer/src/components/tab-bar/BrowserTab.test.tsx b/src/renderer/src/components/tab-bar/BrowserTab.test.tsx index a53e80e0117..f4dc863f8e8 100644 --- a/src/renderer/src/components/tab-bar/BrowserTab.test.tsx +++ b/src/renderer/src/components/tab-bar/BrowserTab.test.tsx @@ -11,6 +11,11 @@ vi.mock('react', async () => { return { ...actual, useEffect: () => {}, + // Why: this shallow harness calls the component as a plain function (no React + // render), so ref/callback hooks must be stubbed like useState/useEffect. The + // favicon tests never fire pointer events, so non-persistent refs are fine. + useRef: (initial: T) => ({ current: initial }), + useCallback: (fn: T) => fn, useState(initial: T | (() => T)) { const stateIndex = reactHookRuntime.index++ if (!(stateIndex in reactHookRuntime.states)) { diff --git a/src/renderer/src/components/tab-bar/BrowserTab.tsx b/src/renderer/src/components/tab-bar/BrowserTab.tsx index 83ad2c5f45f..0dfb97a9af6 100644 --- a/src/renderer/src/components/tab-bar/BrowserTab.tsx +++ b/src/renderer/src/components/tab-bar/BrowserTab.tsx @@ -26,6 +26,7 @@ import { preventMiddleButtonDefault } from './middle-button-default-guard' import { translate } from '@/i18n/i18n' import { TAB_CONTAINER_WIDTH_CLASSES, TAB_LABEL_WIDTH_CLASSES } from './tab-width-rules' import { TabWorkspaceLayoutMenuSection } from './TabWorkspaceLayoutMenuSection' +import { useTabStripPointerActivation } from './tab-strip-pointer-activation' function formatBrowserTabUrlLabel(url: string): string { if (url === ORCA_BROWSER_BLANK_URL || url === 'about:blank') { @@ -169,6 +170,10 @@ export default function BrowserTab({ return () => window.removeEventListener('blur', dismiss) }, [menuOpen]) + // Why: defer activation to pointer-up so dragging the tab (reorder / move into + // another pane / split) does not switch the active tab mid-gesture. + const { onPointerDown: onTabPointerDown } = useTabStripPointerActivation({ onActivate }) + const tabRoot = (
{ - if (e.button !== 0) { - return - } - onActivate() - listeners?.onPointerDown?.(e) + onTabPointerDown( + e, + listeners?.onPointerDown as ((event: React.PointerEvent) => void) | undefined + ) }} onMouseDown={(e) => { if (e.button === 1) { diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx index 0d48f3dfe41..59a1c18bcee 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx @@ -19,6 +19,8 @@ const appStoreMocks = vi.hoisted(() => ({ })) })) +const renameFileOnDiskMock = vi.hoisted(() => vi.fn()) + vi.mock('react', async () => { const actual = await vi.importActual('react') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() return { @@ -147,7 +149,7 @@ vi.mock('@/components/editor/editor-labels', () => ({ })) vi.mock('@/lib/rename-file', () => ({ - renameFileOnDisk: vi.fn() + renameFileOnDisk: renameFileOnDiskMock })) vi.mock('@/lib/file-type-icons', () => ({ @@ -326,6 +328,27 @@ function findSpanByText(node: unknown, label: string): ReactElementLike { return span } +function pressInputKey( + input: ReactElementLike, + key: string, + options?: { isComposing?: boolean; keyCode?: number } +): { + preventDefault: ReturnType + stopPropagation: ReturnType +} { + const event = { + key, + nativeEvent: { + isComposing: options?.isComposing ?? false, + keyCode: options?.keyCode ?? 13 + }, + preventDefault: vi.fn(), + stopPropagation: vi.fn() + } + ;(input.props.onKeyDown as (nextEvent: typeof event) => void)(event) + return event +} + describe('EditorFileTab rename menu', () => { beforeEach(() => { reactHookRuntime.states = [] @@ -371,6 +394,38 @@ describe('EditorFileTab rename menu', () => { expect(select).not.toHaveBeenCalled() }) + it('ignores IME composition Enter before renaming the editor file tab', async () => { + const file = baseFile() + const firstRender = expandNode((await renderEditorFileTab(file)).element) + const renameItem = findMenuItemByText(firstRender, 'Rename') + + ;(renameItem.props.onSelect as () => void)() + + const secondRender = expandNode((await renderEditorFileTab(file)).element) + const input = findElementsByType(secondRender, 'input')[0] + const setInputRef = input.props.ref as (input: HTMLInputElement | null) => void + setInputRef({ + focus: vi.fn(), + select: vi.fn(), + setSelectionRange: vi.fn(), + value: '日本語.md' + } as unknown as HTMLInputElement) + + const composingEvent = pressInputKey(input, 'Enter', { isComposing: true }) + + expect(composingEvent.preventDefault).not.toHaveBeenCalled() + expect(renameFileOnDiskMock).not.toHaveBeenCalled() + + pressInputKey(input, 'Enter') + + expect(renameFileOnDiskMock).toHaveBeenCalledWith({ + oldPath: '/repo/untitled-5.md', + newName: '日本語.md', + worktreeId: 'wt-1', + worktreePath: '/repo' + }) + }) + it('disables Rename for diff tabs that do not map to one writable file', async () => { const file = baseFile({ mode: 'diff', diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.tsx index 694d03b0b59..9de55f9c95e 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.tsx @@ -6,6 +6,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { basename, normalizeRelativePath } from '@/lib/path' import { getEditorDisplayLabel } from '@/components/editor/editor-labels' import { renameFileOnDisk } from '@/lib/rename-file' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { detectLanguage } from '@/lib/language-detect' import { getFileTypeIcon } from '@/lib/file-type-icons' import { useRepoById, useWorktreeById } from '@/store/selectors' @@ -29,6 +30,7 @@ import { EditorFileTabContextMenu } from './EditorFileTabContextMenu' import { translate } from '@/i18n/i18n' import { TAB_CONTAINER_WIDTH_CLASSES, TAB_LABEL_WIDTH_CLASSES } from './tab-width-rules' import { EditorFileTabCloseButton } from './EditorFileTabCloseButton' +import { useTabStripPointerActivation } from './tab-strip-pointer-activation' export default function EditorFileTab({ file, @@ -200,6 +202,12 @@ export default function EditorFileTab({ }, [menuOpen]) const dragListeners = isRenaming ? undefined : listeners + // Why: defer activation to pointer-up so dragging the tab (reorder / move into + // another pane / split) does not switch the active tab mid-gesture. + const { onPointerDown: onTabPointerDown } = useTabStripPointerActivation({ + onActivate, + disabled: isRenaming + }) const tabRoot = (
{ - if (isRenaming || e.button !== 0) { - return - } - onActivate() - dragListeners?.onPointerDown?.(e) + onTabPointerDown( + e, + dragListeners?.onPointerDown as ((event: React.PointerEvent) => void) | undefined + ) }} onDoubleClick={() => { if (file.isPreview && onMakePermanent) { @@ -281,6 +288,11 @@ export default function EditorFileTab({ onClick={(e) => e.stopPropagation()} onDoubleClick={(e) => e.stopPropagation()} onKeyDown={(e) => { + // Why: an Enter that only confirms a CJK IME candidate must not + // commit the rename; wait for a non-composition Enter. + if (isImeCompositionKeyDown(e)) { + return + } if (e.key === 'Enter') { e.preventDefault() e.stopPropagation() diff --git a/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx b/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx index 10a81ab059c..bb4e757d1c8 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx @@ -208,7 +208,11 @@ function makeTerminalTab() { } } -async function renderSortableTab(): Promise { +async function renderSortableTab({ + onSetCustomTitle = vi.fn() +}: { + onSetCustomTitle?: (tabId: string, title: string | null) => void +} = {}): Promise { reactHookRuntime.index = 0 const module = await import('./SortableTab') return module.default({ @@ -224,7 +228,7 @@ async function renderSortableTab(): Promise { onClose: vi.fn(), onCloseOthers: vi.fn(), onCloseToRight: vi.fn(), - onSetCustomTitle: vi.fn(), + onSetCustomTitle, onSetTabColor: vi.fn(), onTogglePin: vi.fn(), onToggleExpand: vi.fn(), @@ -282,6 +286,25 @@ function findElementsByType(node: unknown, typeName: string): ReactElementLike[] return results } +function pressInputKey( + input: ReactElementLike, + key: string, + options?: { isComposing?: boolean; keyCode?: number } +): { + preventDefault: ReturnType +} { + const event = { + key, + nativeEvent: { + isComposing: options?.isComposing ?? false, + keyCode: options?.keyCode ?? 13 + }, + preventDefault: vi.fn() + } + ;(input.props.onKeyDown as (nextEvent: typeof event) => void)(event) + return event +} + describe('SortableTab rename shortcut signal', () => { beforeEach(() => { reactHookRuntime.states = [] @@ -312,4 +335,26 @@ describe('SortableTab rename shortcut signal', () => { expect(inputs[0].props.value).toBe('Runtime terminal title') expect(inputs[0].props['data-tab-rename-input']).toBe('true') }) + + it('ignores IME composition Enter before committing the custom tab title', async () => { + const onSetCustomTitle = vi.fn() + + await renderSortableTab({ onSetCustomTitle }) + let rerender = expandNode(await renderSortableTab({ onSetCustomTitle })) + let input = findElementsByType(rerender, 'input')[0] + ;(input.props.onChange as (event: { target: { value: string } }) => void)({ + target: { value: '日本語 terminal' } + }) + rerender = expandNode(await renderSortableTab({ onSetCustomTitle })) + input = findElementsByType(rerender, 'input')[0] + + const composingEvent = pressInputKey(input, 'Enter', { isComposing: true }) + + expect(composingEvent.preventDefault).not.toHaveBeenCalled() + expect(onSetCustomTitle).not.toHaveBeenCalled() + + pressInputKey(input, 'Enter') + + expect(onSetCustomTitle).toHaveBeenCalledWith('terminal-tab-1', '日本語 terminal') + }) }) diff --git a/src/renderer/src/components/tab-bar/SortableTab.tsx b/src/renderer/src/components/tab-bar/SortableTab.tsx index e19f1258059..00beaf35b69 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.tsx @@ -5,6 +5,7 @@ import { ShellIcon } from './shell-icons' import { AgentIcon } from '@/lib/agent-catalog' import { stripLeadingAgentTitleDecoration } from '../../../../shared/agent-title-decoration' import { useTabAgent } from '@/lib/use-tab-agent' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { Input } from '@/components/ui/input' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' @@ -24,6 +25,7 @@ import { SortableTabContextMenu } from './SortableTabContextMenu' import { translate } from '@/i18n/i18n' import { TAB_CONTAINER_WIDTH_CLASSES, TAB_LABEL_WIDTH_CLASSES } from './tab-width-rules' import { useShortcutKeyDetails } from '@/hooks/useShortcutLabel' +import { useTabStripPointerActivation } from './tab-strip-pointer-activation' type SortableTabProps = { tab: TerminalTab @@ -93,7 +95,7 @@ export default function SortableTab({ // Older persisted tabs without this field fall back to the generic icon. const shellForIcon = tab.shellOverride - // Why: foreground process and hook status make the tab icon reflect the + // Why: hook status and title evidence make the tab icon reflect the // coding harness currently running in the pane, not just the launch command. const tabAgent = useTabAgent(tab) @@ -209,6 +211,16 @@ export default function SortableTab({ // so dnd-kit's a11y attributes (aria-roledescription, etc.) remain on the element — only // the pointer listeners are gated so a drag can't start while typing. const dragListeners = isEditing ? undefined : listeners + const handleActivate = useCallback(() => { + onActivate(tab.id) + }, [onActivate, tab.id]) + // Why: defer activation to pointer-up so pressing a tab to drag it (reorder / + // move into another pane / split) does not switch the active tab or steal + // terminal focus mid-gesture. See tab-strip-pointer-activation. + const { onPointerDown: onTabPointerDown } = useTabStripPointerActivation({ + onActivate: handleActivate, + disabled: isEditing + }) const closeShortcut = useShortcutKeyDetails('tab.close') const tabTitle = tab.customTitle ?? tab.title const tabRoot = ( @@ -242,11 +254,10 @@ export default function SortableTab({ handleRenameOpen() }} onPointerDown={(e) => { - if (isEditing || e.button !== 0) { - return - } - onActivate(tab.id) - dragListeners?.onPointerDown?.(e) + onTabPointerDown( + e, + dragListeners?.onPointerDown as ((event: React.PointerEvent) => void) | undefined + ) }} onMouseDown={(e) => { // Why: prevent default browser middle-click behavior (auto-scroll) @@ -333,6 +344,11 @@ export default function SortableTab({ onChange={(event) => setRenameValue(event.target.value)} onBlur={commitRename} onKeyDown={(event) => { + // Why: an Enter that only confirms a CJK IME candidate must not + // commit the rename; wait for a non-composition Enter. + if (isImeCompositionKeyDown(event)) { + return + } if (event.key === 'Enter') { event.preventDefault() commitRename() diff --git a/src/renderer/src/components/tab-bar/TabBarQuickCommandsButton.tsx b/src/renderer/src/components/tab-bar/TabBarQuickCommandsButton.tsx index b7c921962a7..0810ea9c2b2 100644 --- a/src/renderer/src/components/tab-bar/TabBarQuickCommandsButton.tsx +++ b/src/renderer/src/components/tab-bar/TabBarQuickCommandsButton.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from 'react' -import { Plus } from 'lucide-react' +import { Play } from 'lucide-react' import { useAppStore } from '@/store' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { @@ -136,7 +136,7 @@ export function TabBarQuickCommandsButton({ return null } - // Empty state: single add button that opens the dialog directly. + // Empty state: single button that opens the dialog directly. if (!hasAnyCommands) { return ( <> @@ -151,11 +151,11 @@ export function TabBarQuickCommandsButton({ 'Add quick command' )} > - + {translate( 'auto.components.tab.bar.TabBarQuickCommandsButton.a2c7a33831', - 'Add Quick Command…' + 'Command' )} diff --git a/src/renderer/src/components/tab-bar/TabBarQuickCommandsMenu.tsx b/src/renderer/src/components/tab-bar/TabBarQuickCommandsMenu.tsx index 1d1b616c67d..84ae849fd1b 100644 --- a/src/renderer/src/components/tab-bar/TabBarQuickCommandsMenu.tsx +++ b/src/renderer/src/components/tab-bar/TabBarQuickCommandsMenu.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { ChevronDown, Play, Plus } from 'lucide-react' +import { ChevronDown, Play } from 'lucide-react' import { Command, CommandEmpty, @@ -390,10 +390,10 @@ export function TabBarQuickCommandsMenu({ }} className="flex w-full items-center gap-2 rounded-[5px] px-2 py-1.5 text-[12px] text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" > - + {translate( 'auto.components.tab.bar.TabBarQuickCommandsButton.a2c7a33831', - 'Add Quick Command…' + 'Command' )}
diff --git a/src/renderer/src/components/tab-bar/tab-strip-overflow-navigation.ts b/src/renderer/src/components/tab-bar/tab-strip-overflow-navigation.ts index 72d66783239..77af550b748 100644 --- a/src/renderer/src/components/tab-bar/tab-strip-overflow-navigation.ts +++ b/src/renderer/src/components/tab-bar/tab-strip-overflow-navigation.ts @@ -5,6 +5,7 @@ import { sameTabStripScrollMetrics, type TabStripScrollMetrics } from './tab-strip-scroll-metrics' +import { isTabStripPointerGestureActive } from './tab-strip-pointer-gesture' const TAB_STRIP_SCROLL_FRACTION = 0.75 const TAB_STRIP_MIN_SCROLL_STEP_PX = 120 @@ -114,6 +115,9 @@ export function useTabStripOverflowNavigation({ if (!stickToEndRef.current) { return } + if (isTabStripPointerGestureActive()) { + return + } el.scrollLeft = Math.max(0, el.scrollWidth - el.clientWidth) } @@ -137,7 +141,8 @@ export function useTabStripOverflowNavigation({ updateTabStripOverflowState() return } - if (stickToEndRef.current) { + const pointerGestureActive = isTabStripPointerGestureActive() + if (stickToEndRef.current && !pointerGestureActive) { const scrollToEnd = (): void => { const el = tabStripRef.current if (!el) { @@ -149,7 +154,7 @@ export function useTabStripOverflowNavigation({ scrollToEnd() requestAnimationFrame(scrollToEnd) } - if (tabCount > prev.len) { + if (tabCount > prev.len && !pointerGestureActive) { const scrollToEnd = (): void => { const el = tabStripRef.current if (!el) { @@ -178,6 +183,12 @@ export function useTabStripOverflowNavigation({ if (!activeTab) { return } + if (isTabStripPointerGestureActive()) { + // Why: active-tab preview changes during a tab press must not move the + // strip under a stationary pointer before the release decides click/drag. + requestAnimationFrame(updateTabStripOverflowState) + return + } activeTab.scrollIntoView({ block: 'nearest', inline: 'nearest' }) requestAnimationFrame(updateTabStripOverflowState) }, [activeVisibleTabId, updateTabStripOverflowState]) diff --git a/src/renderer/src/components/tab-bar/tab-strip-pointer-activation.test.tsx b/src/renderer/src/components/tab-bar/tab-strip-pointer-activation.test.tsx new file mode 100644 index 00000000000..d1d644f7660 --- /dev/null +++ b/src/renderer/src/components/tab-bar/tab-strip-pointer-activation.test.tsx @@ -0,0 +1,122 @@ +// @vitest-environment happy-dom + +import { act, renderHook } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { TAB_DRAG_ACTIVATION_DISTANCE_PX } from '../tab-group/useTabDragSplit' +import { useTabStripPointerActivation } from './tab-strip-pointer-activation' + +function pointerDownEvent(clientX: number, clientY: number, button = 0): React.PointerEvent { + return { button, clientX, clientY } as unknown as React.PointerEvent +} + +function firePointer(type: string, clientX: number, clientY: number): void { + act(() => { + window.dispatchEvent(new PointerEvent(type, { clientX, clientY, bubbles: true })) + }) +} + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('useTabStripPointerActivation', () => { + it('activates on a release that never crossed the drag threshold (a click)', () => { + const onActivate = vi.fn() + const dragListener = vi.fn() + const { result } = renderHook(() => useTabStripPointerActivation({ onActivate })) + + act(() => result.current.onPointerDown(pointerDownEvent(10, 10), dragListener)) + // Why: the dnd-kit gesture must start on pointerdown even though activation + // is deferred. + expect(dragListener).toHaveBeenCalledTimes(1) + expect(onActivate).not.toHaveBeenCalled() + + // Release within the threshold -> click -> activate. + firePointer('pointerup', 12, 11) + expect(onActivate).toHaveBeenCalledTimes(1) + }) + + it('suppresses activation when the pointer travels past the drag threshold', () => { + const onActivate = vi.fn() + const { result } = renderHook(() => useTabStripPointerActivation({ onActivate })) + + act(() => result.current.onPointerDown(pointerDownEvent(10, 10))) + firePointer('pointerup', 10 + TAB_DRAG_ACTIVATION_DISTANCE_PX + 5, 10) + + expect(onActivate).not.toHaveBeenCalled() + }) + + it('activates a stationary click after a single stale over-threshold move', () => { + const onActivate = vi.fn() + const { result } = renderHook(() => useTabStripPointerActivation({ onActivate })) + + act(() => result.current.onPointerDown(pointerDownEvent(10, 10))) + // Why: packaged Chromium can deliver one stale/coalesced move immediately + // after pointerdown; the release position is the click/drag authority. + firePointer('pointermove', 200, 200) + firePointer('pointerup', 11, 11) + + expect(onActivate).toHaveBeenCalledTimes(1) + }) + + it('does not activate when the press is cancelled', () => { + const onActivate = vi.fn() + const { result } = renderHook(() => useTabStripPointerActivation({ onActivate })) + + act(() => result.current.onPointerDown(pointerDownEvent(10, 10))) + firePointer('pointercancel', 10, 10) + firePointer('pointerup', 10, 10) + + expect(onActivate).not.toHaveBeenCalled() + }) + + it('ignores non-left buttons and disabled presses', () => { + const onActivate = vi.fn() + const dragListener = vi.fn() + const { result, rerender } = renderHook( + ({ disabled }: { disabled: boolean }) => + useTabStripPointerActivation({ onActivate, disabled }), + { initialProps: { disabled: false } } + ) + + // Right-click: ignored. + act(() => result.current.onPointerDown(pointerDownEvent(10, 10, 2), dragListener)) + firePointer('pointerup', 10, 10) + expect(onActivate).not.toHaveBeenCalled() + expect(dragListener).not.toHaveBeenCalled() + + // Disabled: ignored. + rerender({ disabled: true }) + act(() => result.current.onPointerDown(pointerDownEvent(10, 10), dragListener)) + firePointer('pointerup', 10, 10) + expect(onActivate).not.toHaveBeenCalled() + expect(dragListener).not.toHaveBeenCalled() + }) + + it('activates a click that lands after a prior drag gesture (regression #6395)', () => { + const onActivate = vi.fn() + const { result } = renderHook(() => useTabStripPointerActivation({ onActivate })) + + // First gesture: a drag (reorder). No activation. + act(() => result.current.onPointerDown(pointerDownEvent(10, 10))) + firePointer('pointermove', 300, 10) + firePointer('pointerup', 300, 10) + expect(onActivate).not.toHaveBeenCalled() + + // Second gesture: a plain click. Must activate. + act(() => result.current.onPointerDown(pointerDownEvent(400, 10))) + firePointer('pointerup', 400, 10) + expect(onActivate).toHaveBeenCalledTimes(1) + }) + + it('flushes a pending press on window focus', () => { + const onActivate = vi.fn() + const { result } = renderHook(() => useTabStripPointerActivation({ onActivate })) + + act(() => result.current.onPointerDown(pointerDownEvent(10, 10))) + act(() => window.dispatchEvent(new Event('focus'))) + firePointer('pointerup', 10, 10) + + expect(onActivate).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/tab-bar/tab-strip-pointer-activation.ts b/src/renderer/src/components/tab-bar/tab-strip-pointer-activation.ts new file mode 100644 index 00000000000..e581e73ba9c --- /dev/null +++ b/src/renderer/src/components/tab-bar/tab-strip-pointer-activation.ts @@ -0,0 +1,88 @@ +import { useCallback, useEffect, useRef } from 'react' +import { TAB_DRAG_ACTIVATION_DISTANCE_PX } from '../tab-group/useTabDragSplit' +import { beginTabStripPointerGesture } from './tab-strip-pointer-gesture' + +/** + * Defer tab activation to pointer-up and suppress it when the press turns into a + * drag. PR #5927 shipped this so dragging a tab (to reorder, move into another + * pane, or split) never switched the active tab or stole terminal focus + * mid-gesture; #6395 removed it (activating eagerly on pointerdown) to fix + * click-to-switch-after-reorder, which regressed the drag feature. + * + * We gate on measured pointer DISPLACEMENT, not the drag-active context ref the + * old hook used — that ref clears asynchronously relative to the drop's + * pointerup, which is what made #6395's click-after-reorder misfire. Displacement + * mirrors dnd-kit's own activation threshold, but the authority is the release + * position. A release within it is a click (activate); a release outside it is a + * drag (activation suppressed). Because each press measures its own gesture, a + * click after a reorder always activates. + */ +export function useTabStripPointerActivation({ + onActivate, + disabled = false +}: { + onActivate: () => void + disabled?: boolean +}): { + onPointerDown: ( + event: React.PointerEvent, + dragListener?: (event: React.PointerEvent) => void + ) => void +} { + const onActivateRef = useRef(onActivate) + onActivateRef.current = onActivate + const cleanupRef = useRef<(() => void) | null>(null) + + // Why: a press still holding when the tab unmounts (tab closed mid-drag, group + // collapse) would otherwise leak its window listeners and later fire activation + // on a dead closure. + useEffect(() => () => cleanupRef.current?.(), []) + + const onPointerDown = useCallback( + (event: React.PointerEvent, dragListener?: (event: React.PointerEvent) => void) => { + if (disabled || event.button !== 0) { + return + } + // Why: start the dnd-kit gesture immediately on pointerdown; only the + // activation decision is deferred to release. + dragListener?.(event) + + cleanupRef.current?.() + const startX = event.clientX + const startY = event.clientY + const releaseTabStripPointerGesture = beginTabStripPointerGesture() + + const cleanup = (): void => { + window.removeEventListener('pointerup', onPointerUp) + window.removeEventListener('pointercancel', onPointerCancel) + window.removeEventListener('blur', onPointerCancel) + window.removeEventListener('focus', onPointerCancel) + releaseTabStripPointerGesture() + cleanupRef.current = null + } + const onPointerUp = (upEvent: PointerEvent): void => { + const wasDrag = + Math.hypot(upEvent.clientX - startX, upEvent.clientY - startY) >= + TAB_DRAG_ACTIVATION_DISTANCE_PX + cleanup() + // Why: packaged Chromium can deliver a stale first pointermove after + // focus; the final release position is the click/drag authority. + if (!wasDrag) { + onActivateRef.current() + } + } + const onPointerCancel = (): void => { + cleanup() + } + + window.addEventListener('pointerup', onPointerUp) + window.addEventListener('pointercancel', onPointerCancel) + window.addEventListener('blur', onPointerCancel) + window.addEventListener('focus', onPointerCancel) + cleanupRef.current = cleanup + }, + [disabled] + ) + + return { onPointerDown } +} diff --git a/src/renderer/src/components/tab-bar/tab-strip-pointer-gesture.ts b/src/renderer/src/components/tab-bar/tab-strip-pointer-gesture.ts new file mode 100644 index 00000000000..d00475d71c8 --- /dev/null +++ b/src/renderer/src/components/tab-bar/tab-strip-pointer-gesture.ts @@ -0,0 +1,18 @@ +let activeTabStripPointerGestureCount = 0 + +export function beginTabStripPointerGesture(): () => void { + activeTabStripPointerGestureCount += 1 + let released = false + + return () => { + if (released) { + return + } + released = true + activeTabStripPointerGestureCount = Math.max(0, activeTabStripPointerGestureCount - 1) + } +} + +export function isTabStripPointerGestureActive(): boolean { + return activeTabStripPointerGestureCount > 0 +} diff --git a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx index c0362c59c68..af09c852cab 100644 --- a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx +++ b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx @@ -1,6 +1,10 @@ import { useCallback, useEffect, useRef, useState, type CSSProperties } from 'react' import { toast } from 'sonner' -import { getAiVaultResumeWorkspaceTargetStatus } from '@/lib/ai-vault-resume-target' +import { + canResumeAiVaultSessionOnTarget, + getAiVaultResumeWorkspaceExecutionHostId, + getAiVaultResumeWorkspaceTargetStatus +} from '@/lib/ai-vault-resume-target' import { AI_VAULT_SESSION_DRAG_END_EVENT, AI_VAULT_SESSION_DRAG_START_EVENT, @@ -166,7 +170,9 @@ export default function AiVaultSessionDropLayer({ return true } - const targetStatus = getAiVaultResumeWorkspaceTargetStatus(useAppStore.getState(), worktreeId) + const state = useAppStore.getState() + const targetStatus = getAiVaultResumeWorkspaceTargetStatus(state, worktreeId) + const targetExecutionHostId = getAiVaultResumeWorkspaceExecutionHostId(state, worktreeId) if (targetStatus === 'runtime') { toast.error( translate( @@ -185,6 +191,22 @@ export default function AiVaultSessionDropLayer({ ) return true } + if ( + !canResumeAiVaultSessionOnTarget({ + sessionFilePath: payload.sessionFilePath ?? null, + sessionExecutionHostId: payload.sessionExecutionHostId ?? null, + targetStatus, + targetExecutionHostId + }) + ) { + toast.error( + translate( + 'auto.components.tab.group.AiVaultSessionDropLayer.sessionHostMismatchUnsupported', + 'This session belongs to a different host. Drop it onto a workspace on the same host.' + ) + ) + return true + } launchAiVaultSessionInNewTab({ agent: payload.agent, diff --git a/src/renderer/src/components/tab-group/TabGroupPanel.tsx b/src/renderer/src/components/tab-group/TabGroupPanel.tsx index 9f85c325a08..57dbf5946fc 100644 --- a/src/renderer/src/components/tab-group/TabGroupPanel.tsx +++ b/src/renderer/src/components/tab-group/TabGroupPanel.tsx @@ -225,7 +225,9 @@ export default function TabGroupPanel({ user can only drag from the tiny left-sidebar header strip. */}
{/* Why: Electron's native drag hit-test only respects no-drag on DOM diff --git a/src/renderer/src/components/tab-group/tab-drag-pointer-sensor.ts b/src/renderer/src/components/tab-group/tab-drag-pointer-sensor.ts new file mode 100644 index 00000000000..ab96dc5e394 --- /dev/null +++ b/src/renderer/src/components/tab-group/tab-drag-pointer-sensor.ts @@ -0,0 +1,312 @@ +import type { PointerEvent as ReactPointerEvent } from 'react' +import type { + Activators, + DistanceMeasurement, + PointerActivationConstraint, + PointerSensorOptions, + SensorInstance, + SensorProps +} from '@dnd-kit/core' + +type PointerCoordinates = { x: number; y: number } + +const DEFAULT_COORDINATES: PointerCoordinates = { x: 0, y: 0 } +const TAB_DRAG_EARLY_MOVE_CONFIRMATION_MS = 50 +const TAB_DRAG_CONFIRMED_DISTANCE_SAMPLE_COUNT = 2 + +type ListenerEntry = { + eventName: string + handler: EventListener + options?: AddEventListenerOptions | boolean + target: EventTarget +} + +class ListenerBag { + private readonly listeners: ListenerEntry[] = [] + + add( + target: EventTarget | null, + eventName: string, + handler: (event: T) => void, + options?: AddEventListenerOptions | boolean + ): void { + if (!target) { + return + } + const listener = handler as EventListener + target.addEventListener(eventName, listener, options) + this.listeners.push({ eventName, handler: listener, options, target }) + } + + removeAll = (): void => { + for (const { eventName, handler, options, target } of this.listeners) { + target.removeEventListener(eventName, handler, options) + } + this.listeners.length = 0 + } +} + +function isDistanceConstraint( + constraint: PointerActivationConstraint +): constraint is Extract { + return 'distance' in constraint +} + +function isDelayConstraint( + constraint: PointerActivationConstraint +): constraint is Extract { + return 'delay' in constraint +} + +function getOwnerDocument(target: EventTarget | null): Document { + if (target instanceof Document) { + return target + } + if (target instanceof Node) { + return target.ownerDocument ?? document + } + return document +} + +function getPointerCoordinates(event: Event): PointerCoordinates | null { + if ('clientX' in event && 'clientY' in event) { + const pointerEvent = event as PointerEvent + return { x: pointerEvent.clientX, y: pointerEvent.clientY } + } + return null +} + +function subtractCoordinates( + start: PointerCoordinates, + current: PointerCoordinates +): PointerCoordinates { + return { + x: start.x - current.x, + y: start.y - current.y + } +} + +function hasExceededDistance(delta: PointerCoordinates, measurement: DistanceMeasurement): boolean { + const dx = Math.abs(delta.x) + const dy = Math.abs(delta.y) + + if (typeof measurement === 'number') { + return Math.hypot(dx, dy) > measurement + } + if ('x' in measurement && 'y' in measurement) { + return dx > measurement.x && dy > measurement.y + } + if ('x' in measurement) { + return dx > measurement.x + } + if ('y' in measurement) { + return dy > measurement.y + } + return false +} + +export function shouldActivateTabDragFromDistanceSample({ + elapsedMs, + overThresholdSampleCount +}: { + elapsedMs: number + overThresholdSampleCount: number +}): boolean { + // Why: one immediate over-threshold sample can be stale/coalesced after + // window focus; a second sample or a short grace period confirms real motion. + return ( + elapsedMs >= TAB_DRAG_EARLY_MOVE_CONFIRMATION_MS || + overThresholdSampleCount >= TAB_DRAG_CONFIRMED_DISTANCE_SAMPLE_COUNT + ) +} + +export class TabDragPointerSensor implements SensorInstance { + static activators: Activators = [ + { + eventName: 'onPointerDown', + handler: ( + { nativeEvent: event }: ReactPointerEvent, + { onActivation }: PointerSensorOptions + ): boolean => { + if (!event.isPrimary || event.button !== 0) { + return false + } + onActivation?.({ event }) + return true + } + } + ] + + autoScrollEnabled = true + + private activated = false + private readonly document: Document + private readonly initialCoordinates: PointerCoordinates + private readonly pointerDownTime = performance.now() + private readonly props: SensorProps + private readonly documentListeners = new ListenerBag() + private readonly pointerListeners = new ListenerBag() + private readonly windowListeners = new ListenerBag() + private overThresholdSampleCount = 0 + private timeoutId: number | null = null + + constructor(props: SensorProps) { + this.props = props + this.document = getOwnerDocument(props.event.target) + this.initialCoordinates = getPointerCoordinates(props.event) ?? DEFAULT_COORDINATES + this.handleStart = this.handleStart.bind(this) + this.handleMove = this.handleMove.bind(this) + this.handleEnd = this.handleEnd.bind(this) + this.handleCancel = this.handleCancel.bind(this) + this.handleKeydown = this.handleKeydown.bind(this) + this.removeTextSelection = this.removeTextSelection.bind(this) + this.attach() + } + + private attach(): void { + const win = this.document.defaultView + const { activationConstraint, bypassActivationConstraint } = this.props.options + + this.pointerListeners.add(this.document, 'pointermove', this.handleMove, { passive: false }) + this.pointerListeners.add(this.document, 'pointerup', this.handleEnd) + this.pointerListeners.add(this.document, 'pointercancel', this.handleCancel) + this.windowListeners.add(win, 'resize', this.handleCancel) + this.windowListeners.add(win, 'dragstart', preventDefault) + this.windowListeners.add(win, 'visibilitychange', this.handleCancel) + this.windowListeners.add(win, 'contextmenu', preventDefault) + this.windowListeners.add(win, 'focus', this.handleCancel) + this.documentListeners.add(this.document, 'keydown', this.handleKeydown) + + if (!activationConstraint) { + this.handleStart() + return + } + if ( + bypassActivationConstraint?.({ + activeNode: this.props.activeNode, + event: this.props.event, + options: this.props.options + }) + ) { + this.handleStart() + return + } + if (isDelayConstraint(activationConstraint)) { + this.timeoutId = window.setTimeout(this.handleStart, activationConstraint.delay) + this.handlePending(activationConstraint) + return + } + this.handlePending(activationConstraint) + } + + private detach(): void { + this.pointerListeners.removeAll() + this.windowListeners.removeAll() + window.setTimeout(this.documentListeners.removeAll, 50) + if (this.timeoutId !== null) { + window.clearTimeout(this.timeoutId) + this.timeoutId = null + } + } + + private handlePending( + constraint: PointerActivationConstraint, + offset?: PointerCoordinates | undefined + ): void { + this.props.onPending(this.props.active, constraint, this.initialCoordinates, offset) + } + + private handleStart(): void { + if (this.activated) { + return + } + this.activated = true + this.documentListeners.add(this.document, 'click', stopPropagation, { capture: true }) + this.removeTextSelection() + this.documentListeners.add(this.document, 'selectionchange', this.removeTextSelection) + this.props.onStart(this.initialCoordinates) + } + + private handleMove(event: PointerEvent): void { + const coordinates = getPointerCoordinates(event) + const { activationConstraint } = this.props.options + if (!coordinates) { + return + } + const delta = subtractCoordinates(this.initialCoordinates, coordinates) + + if (!this.activated && activationConstraint) { + if (isDistanceConstraint(activationConstraint)) { + if ( + activationConstraint.tolerance != null && + hasExceededDistance(delta, activationConstraint.tolerance) + ) { + this.handleCancel() + return + } + if (hasExceededDistance(delta, activationConstraint.distance)) { + this.overThresholdSampleCount += 1 + if ( + shouldActivateTabDragFromDistanceSample({ + elapsedMs: performance.now() - this.pointerDownTime, + overThresholdSampleCount: this.overThresholdSampleCount + }) + ) { + this.handleStart() + return + } + } else { + this.overThresholdSampleCount = 0 + } + } + if ( + isDelayConstraint(activationConstraint) && + hasExceededDistance(delta, activationConstraint.tolerance) + ) { + this.handleCancel() + return + } + this.handlePending(activationConstraint, delta) + return + } + + if (event.cancelable) { + event.preventDefault() + } + this.props.onMove(coordinates) + } + + private handleEnd(): void { + this.detach() + if (!this.activated) { + this.props.onAbort(this.props.active) + } + this.props.onEnd() + } + + private handleCancel(): void { + this.detach() + if (!this.activated) { + this.props.onAbort(this.props.active) + } + this.props.onCancel() + } + + private handleKeydown(event: KeyboardEvent): void { + if (event.code === 'Escape') { + this.handleCancel() + } + } + + private removeTextSelection(): void { + this.document.getSelection()?.removeAllRanges() + } +} + +function preventDefault(event: Event): void { + event.preventDefault() +} + +function stopPropagation(event: Event): void { + event.stopPropagation() +} diff --git a/src/renderer/src/components/tab-group/useTabDragSplit.test.ts b/src/renderer/src/components/tab-group/useTabDragSplit.test.ts index 9c526ce25e3..fa825031762 100644 --- a/src/renderer/src/components/tab-group/useTabDragSplit.test.ts +++ b/src/renderer/src/components/tab-group/useTabDragSplit.test.ts @@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Tab, TabGroup, TabGroupLayoutNode } from '../../../../shared/types' import { useAppStore } from '../../store' import type { TabDragItemData } from './useTabDragSplit' +import { shouldActivateTabDragFromDistanceSample } from './tab-drag-pointer-sensor' import { canDropTabForPaneColumnSplit, canDropTabIntoPaneBody, @@ -192,6 +193,27 @@ describe('tab drag activation distance', () => { it('uses an impossible activation distance when tab dragging is disabled', () => { expect(getTabDragActivationDistance(false)).toBe(Number.MAX_SAFE_INTEGER) }) + + it('requires confirmation for an immediate over-threshold distance sample', () => { + expect( + shouldActivateTabDragFromDistanceSample({ + elapsedMs: 10, + overThresholdSampleCount: 1 + }) + ).toBe(false) + expect( + shouldActivateTabDragFromDistanceSample({ + elapsedMs: 10, + overThresholdSampleCount: 2 + }) + ).toBe(true) + expect( + shouldActivateTabDragFromDistanceSample({ + elapsedMs: 60, + overThresholdSampleCount: 1 + }) + ).toBe(true) + }) }) describe('canDropTabIntoPaneBody', () => { @@ -245,7 +267,7 @@ describe('canDropTabIntoPaneBody', () => { }) describe('useTabDragSplit', () => { - it.each(['pointerup', 'pointercancel', 'blur'])( + it.each(['pointerup', 'pointercancel', 'blur', 'focus'])( 'clears a stuck active drag when %s arrives without a dnd end event', async (eventName) => { const activeData = makeDragData('group-1') diff --git a/src/renderer/src/components/tab-group/useTabDragSplit.ts b/src/renderer/src/components/tab-group/useTabDragSplit.ts index 6d945d07145..090e41b047f 100644 --- a/src/renderer/src/components/tab-group/useTabDragSplit.ts +++ b/src/renderer/src/components/tab-group/useTabDragSplit.ts @@ -5,7 +5,6 @@ import { useCallback, useRef, useState, type RefObject } from 'react' import { closestCenter, pointerWithin, - PointerSensor, type CollisionDetection, type DragEndEvent, type DragMoveEvent, @@ -34,6 +33,7 @@ import { } from './tab-drag-preview-activation' import { resolveDragPreviewTabId, resolveSourceGroupRestoreOnDrop } from './tab-drag-preview-target' import { getDragPointer } from './tab-drag-pointer' +import { TabDragPointerSensor } from './tab-drag-pointer-sensor' import { captureTabGroupPanelGeometrySnapshot, resolveActivePaneColumnSplitTarget, @@ -195,7 +195,7 @@ export function useTabDragSplit({ // useSensors(ptr) / useSensors(), because dnd-kit internally spreads // the sensors array into a useEffect dependency list — changing its // length between renders violates React's rules of hooks. - const pointerSensor = useSensor(PointerSensor, { + const pointerSensor = useSensor(TabDragPointerSensor, { activationConstraint: { distance: getTabDragActivationDistance(enabled) } }) const sensors = useSensors(pointerSensor) @@ -233,6 +233,7 @@ export function useTabDragSplit({ window.addEventListener('pointerup', clearIfDndMissedEnd) window.addEventListener('pointercancel', clearIfDndMissedEnd) window.addEventListener('blur', clearIfDndMissedEnd) + window.addEventListener('focus', clearIfDndMissedEnd) releaseMissedEndFallbackRef.current = () => { if (cleanupTimer !== null) { window.clearTimeout(cleanupTimer) @@ -240,6 +241,7 @@ export function useTabDragSplit({ window.removeEventListener('pointerup', clearIfDndMissedEnd) window.removeEventListener('pointercancel', clearIfDndMissedEnd) window.removeEventListener('blur', clearIfDndMissedEnd) + window.removeEventListener('focus', clearIfDndMissedEnd) } }, [releaseMissedEndFallback]) diff --git a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.focus.test.ts b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.focus.test.ts index 01e772f4c05..a8d3fd0a571 100644 --- a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.focus.test.ts +++ b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.focus.test.ts @@ -372,6 +372,43 @@ describe('useTabGroupWorkspaceModel terminal activation focus', () => { expect(mocks.closeBrowserTab).toHaveBeenCalledWith('browser-workspace-1') }) + it('preserves the stored session partition when duplicating a local browser tab', async () => { + storeBox.state = { + ...storeBox.state, + browserTabsByWorktree: { + 'wt-1': [ + { + id: 'browser-workspace-1', + worktreeId: 'wt-1', + sessionProfileId: 'profile-1', + sessionPartition: 'persist:orca-browser-session-profile-1', + activePageId: 'browser-page-1', + pageIds: ['browser-page-1'], + url: 'https://example.com', + title: 'Example', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 1 + } + ] + } + } + const { useTabGroupWorkspaceModel } = await import('./useTabGroupWorkspaceModel') + const model = useTabGroupWorkspaceModel({ groupId: 'group-1', worktreeId: 'wt-1' }) + + model.commands.duplicateBrowserTab('browser-workspace-1') + + expect(mocks.createBrowserTab).toHaveBeenCalledWith('wt-1', 'https://example.com', { + title: 'Example', + sessionProfileId: 'profile-1', + sessionPartition: 'persist:orca-browser-session-profile-1', + targetGroupId: 'group-1' + }) + }) + it('closes a host-mirrored browser with an empty page list via the host (no dead-end)', async () => { // Regression: a host-owned browser whose local page list was momentarily // empty had no remote-owned PAGES, so the close skipped the host RPC and the diff --git a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts index fe50561d270..87cbc63b0a0 100644 --- a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts +++ b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts @@ -27,6 +27,7 @@ import { closeTerminalTab } from '../terminal/terminal-tab-actions' import { openTabBarEntry, type TabCreateEntryArgs } from '../tab-bar/tab-create-entry-action' import { openMobileEmulatorTab } from '@/lib/open-mobile-emulator-tab' import { ensureSimulatorTab, getSimulatorTabForWorktree } from '@/lib/ensure-simulator-tab' +import { buildDuplicatedBrowserTabOptions } from '@/lib/duplicate-browser-tab-options' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { browserWorkspaceHasRemoteOwner } from '@/runtime/remote-browser-tab-ownership' @@ -151,6 +152,7 @@ export function useTabGroupWorkspaceModel({ createdAt: item.createdAt, generation: terminalTab?.generation, shellOverride: terminalTab?.shellOverride, + startupCwd: terminalTab?.startupCwd, // Why: carry the launched agent through the rebuilt tab so the tab // bar can show the provider icon before the agent's first hook — // this object is reconstructed from the unified-tab model, so any @@ -639,8 +641,7 @@ export function useTabGroupWorkspaceModel({ return } createBrowserTab(worktreeId, source.url, { - title: source.title, - sessionProfileId: source.sessionProfileId, + ...buildDuplicatedBrowserTabOptions(source), targetGroupId: groupId }) })() diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index b1f90ac2f60..7ed49ac0da0 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -15,7 +15,11 @@ import { resolveOpaqueTerminalBackground, resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme' -import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager' +import type { + ManagedPane, + PaneExternalDropTarget, + PaneManager +} from '@/lib/pane-manager/pane-manager' import TerminalSearch from '@/components/TerminalSearch' import type { PtyTransport } from './pty-transport' import { fitPanes, isWindowsUserAgent } from './pane-helpers' @@ -62,6 +66,11 @@ import { useSystemPrefersDark } from './use-system-prefers-dark' import { useTerminalPaneGlobalEffects } from './use-terminal-pane-global-effects' import { useTerminalPaneLifecycle } from './use-terminal-pane-lifecycle' import { useTerminalPaneContextMenu } from './use-terminal-pane-context-menu' +import { + detachTerminalPaneToTab, + isTerminalTabStripDropTarget, + resolveTerminalTabStripDropTarget +} from './terminal-pane-tab-detach' import type { PreparedAgentSessionFork } from './terminal-agent-session-fork' import { useNotificationDispatch } from './use-notification-dispatch' import { connectPanePty } from './pty-connection' @@ -84,6 +93,7 @@ import type { AgentType } from '../../../../shared/agent-status-types' import { resolvePaneKeyForManager } from '@/lib/pane-manager/pane-key-resolution' import { safeFit } from '@/lib/pane-manager/pane-tree-ops' import { captureTerminalShutdownLayout } from './terminal-shutdown-layout-capture' +import { getOverrideAffectedPanes, getPanesNeedingOverrideFit } from './override-affected-panes' import { inspectRuntimeTerminalProcess, isRemoteRuntimePtyId @@ -159,7 +169,7 @@ import { } from './terminal-pane-attention-subscriptions' import { getCachedTerminalTabForWorktree } from './terminal-tab-lookup' import { getCachedTerminalGroupIdForWorktree } from './terminal-unified-tab-lookup' -import { resolveTabAgentFromTitle } from '@/lib/use-tab-agent' +import { resolveNativeChatLeafTitleAgent } from './native-chat-leaf-title-agent' import { useRepoById } from '@/store/selectors' import { isXtermHelperTextarea, @@ -341,8 +351,9 @@ export default function TerminalPane({ const daemonActions = useDaemonActions() // Why: override state lives in a plain Map for perf (safeFit reads it on // every resize). This counter forces a re-render when overrides change so - // the mobile-fit banner appears/disappears. When an override is cleared - // (desktop-fit), we also trigger safeFit on affected panes so the terminal + // the mobile-fit banner appears/disappears. On both transitions we also + // trigger safeFit on affected panes: mobile-fit shrinks the watcher's xterm + // to phone dims (matching the live phone-wrapped stream), and desktop-fit // resizes back to desktop dimensions. const [, setOverrideTick] = useState(0) useEffect(() => { @@ -367,17 +378,49 @@ export default function TerminalPane({ const unsubscribe = onOverrideChange((event) => { setOverrideTick((n) => n + 1) - if (event.mode === 'desktop-fit') { - const manager = managerRef.current - if (!manager) { + const manager = managerRef.current + if (!manager) { + return + } + // Why: pane IDs are per-tab, so resolve the affected PTY through this + // tab's live transport bindings instead of global numeric pane IDs. + const getAffectedPanes = (): ReturnType => + getOverrideAffectedPanes( + manager.getPanes(), + (paneId) => paneTransportsRef.current.get(paneId)?.getPtyId(), + event.ptyId + ) + if (event.mode === 'mobile-fit') { + // Why: when mobile starts driving, the agent re-renders its output at + // phone width and that phone-wrapped byte stream flows live into this + // passive watcher's xterm. xterm must shrink to the phone dims now or + // the wide desktop grid renders the narrow stream as overlapping, + // garbled lines. safeFit honors the active override and parks xterm at + // override.cols/rows, matching the incoming stream. rAF lets the DOM + // settle before the resize; no loud fallback is needed because the + // override branch of safeFit is itself the authoritative resize. + // Why: override events fan out to every terminal tab; skip the rAF + // unless this tab has a pane still parked at the wrong grid. + const panesNeedingFit = getPanesNeedingOverrideFit( + getAffectedPanes(), + event.cols, + event.rows + ) + if (panesNeedingFit.length === 0) { return } - // Why: pane IDs are per-tab, so resolve the affected PTY through this - // tab's live transport bindings instead of global numeric pane IDs. - const getAffectedPanes = (): ReturnType => - manager - .getPanes() - .filter((pane) => paneTransportsRef.current.get(pane.id)?.getPtyId() === event.ptyId) + scheduleFitFrame(() => { + for (const pane of getPanesNeedingOverrideFit( + getAffectedPanes(), + event.cols, + event.rows + )) { + safeFit(pane) + } + }) + return + } + if (event.mode === 'desktop-fit') { // Why: fitAddon.fit() measures DOM dimensions, so it must run after // the browser has settled layout. Running synchronously inside the // IPC callback can produce stale measurements. rAF ensures the DOM @@ -582,6 +625,9 @@ export default function TerminalPane({ (t) => t.contentType === 'terminal' && t.entityId === tabId )?.label ) + const runtimePaneTitlesByPaneId = useAppStore( + useShallow((store) => store.runtimePaneTitlesByTabId[tabId] ?? {}) + ) // The native-chat toggle joins the pane header's split/close cluster. Eligible // when Orca launched a *supported* agent here or one was detected live for the // leaf, keyed `${tabId}:${leafId}`. Carry the agent identity, not just "an @@ -605,11 +651,17 @@ export default function TerminalPane({ const terminalTab = useAppStore((store) => getCachedTerminalTabForWorktree(store.tabsByWorktree, worktreeId, tabId) ) - // Why: manually-started/resumed TUIs can be recognized by the unified tab - // label before the backing terminal title or hook state catches up. - const titleResolvedAgent = - resolveTabAgentFromTitle(unifiedTabLabel ?? '') ?? - (terminalTab ? resolveTabAgentFromTitle(terminalTab.title) : null) + const resolveTitleAgentForLeaf = useCallback( + (leafId: string | null) => + resolveNativeChatLeafTitleAgent({ + leafId, + panes: managerRef.current?.getPanes() ?? [], + runtimePaneTitlesByPaneId, + tabLabel: unifiedTabLabel, + terminalTitle: terminalTab?.title + }), + [runtimePaneTitlesByPaneId, terminalTab?.title, unifiedTabLabel] + ) // Per-leaf eligibility: a split can mix a supported agent in one leaf with an // unsupported one in another, so the toggle is gated by the specific leaf. // A leaf's own live agent is authoritative; the tab-wide launch/title hints @@ -627,7 +679,7 @@ export default function TerminalPane({ contentType: 'terminal', launchAgent: detectedAgent ? null : terminalTab?.launchAgent, detectedAgent, - resolvedAgent: detectedAgent ? null : titleResolvedAgent, + resolvedAgent: detectedAgent ? null : resolveTitleAgentForLeaf(leafId), isChatViewMode: isChatViewForLeaf }) }, @@ -637,7 +689,7 @@ export default function TerminalPane({ chatLeafId, nativeChatEnabled, terminalTab?.launchAgent, - titleResolvedAgent + resolveTitleAgentForLeaf ] ) const toggleNativeChatForLeaf = useCallback( @@ -996,7 +1048,13 @@ export default function TerminalPane({ // 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 - clearWebRuntimeTerminalBuffer(ptyId) + const clearedRemoteHostBuffer = clearWebRuntimeTerminalBuffer(ptyId) + if (!clearedRemoteHostBuffer && ptyId) { + // Why: local/daemon/SSH PTYs keep their own screen state (ConPTY on + // Windows), and a stale host cursor row makes the next prompt repaint + // land below a blank gap after a frontend-only clear. + window.api.pty.clearBuffer(ptyId) + } persistLayoutSnapshot() }, [paneTransportsRef, persistLayoutSnapshot] @@ -1298,6 +1356,54 @@ export default function TerminalPane({ setPendingCloseConfirmation(null) }, []) + const resolveExternalPaneDropTarget = useCallback( + ({ + sourcePaneId, + clientX, + clientY + }: { + sourcePaneId: number + clientX: number + clientY: number + }): PaneExternalDropTarget | null => { + const manager = managerRef.current + const panes = manager?.getPanes() ?? [] + if (panes.length <= 1 || !panes.some((pane) => pane.id === sourcePaneId)) { + return null + } + return resolveTerminalTabStripDropTarget({ + clientX, + clientY, + groupsByWorktree: useAppStore.getState().groupsByWorktree, + worktreeId + }) + }, + [worktreeId] + ) + + const handleExternalPaneDrop = useCallback( + (sourcePaneId: number, target: PaneExternalDropTarget): boolean => { + if (!isTerminalTabStripDropTarget(target)) { + return false + } + const fallbackPtyId = paneTransportsRef.current.get(sourcePaneId)?.getPtyId() ?? null + return ( + detachTerminalPaneToTab({ + fallbackPtyId, + getStore: useAppStore.getState, + manager: managerRef.current, + persistLayoutSnapshot, + sourcePaneId, + sourceTabId: tabId, + targetGroupId: target.groupId, + targetIndex: target.insertionIndex, + worktreeId + }) !== null + ) + }, + [persistLayoutSnapshot, tabId, worktreeId] + ) + useTerminalPaneLifecycle({ tabId, worktreeId, @@ -1354,7 +1460,9 @@ export default function TerminalPane({ paneTitlesRef, setRenamingPaneId, setPaneCount, - setPaneLayoutRevision + setPaneLayoutRevision, + resolveExternalPaneDropTarget, + onExternalPaneDrop: handleExternalPaneDrop }) useEffect(() => { @@ -1620,6 +1728,7 @@ export default function TerminalPane({ useTerminalKeyboardShortcuts({ tabId, + worktreeId, isActive, keyboardScopeRef: containerRef, managerRef, @@ -2776,6 +2885,7 @@ export default function TerminalPane({ const chatPanePtyId = chatPane ? (paneTransportsRef.current.get(chatPane.id)?.getPtyId() ?? null) : null + const chatPaneResolvedAgent = chatPane ? resolveTitleAgentForLeaf(chatPane.leafId) : null const activePaneIsChatLeaf = Boolean( isChatViewMode && activePane?.leafId && activePane.leafId === chatLeafId ) @@ -2874,6 +2984,7 @@ export default function TerminalPane({ paneKey={makePaneKey(tabId, chatPane.leafId)} targetPtyId={chatPanePtyId} launchAgent={terminalTab?.launchAgent} + resolvedAgent={chatPaneResolvedAgent} onSwitchToTerminal={() => toggleNativeChatForLeaf(chatPane.leafId)} contextMenuActions={{ onSplitRight: () => contextMenu.runForPane(chatPane.id, contextMenu.onSplitRight), diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx index 78e80b3231e..80a386b3c8d 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx @@ -45,17 +45,24 @@ function renderOverlay({ paneCount = 2, showAlwaysOnHeaders = true, onClosePane = vi.fn(), - onRemoveTitle = vi.fn() + onRemoveTitle = vi.fn(), + onRenameSubmit = vi.fn(), + renameValue = '', + renamingPaneId = null }: { paneTitles: Record paneCount?: number showAlwaysOnHeaders?: boolean onClosePane?: ReturnType onRemoveTitle?: ReturnType + onRenameSubmit?: ReturnType + renameValue?: string + renamingPaneId?: number | null }): { container: HTMLDivElement onClosePane: ReturnType onRemoveTitle: ReturnType + onRenameSubmit: ReturnType } { const panes = [makePane(1), makePane(2)] const container = document.createElement('div') @@ -76,8 +83,8 @@ function renderOverlay({ 1: { left: 0, top: 0, width: 200 }, 2: { left: 220, top: 0, width: 200 } }} - renamingPaneId={null} - renameValue="" + renamingPaneId={renamingPaneId} + renameValue={renameValue} renameInputRef={createRef()} titleUsesLightSurface={false} paneTitleBackground="transparent" @@ -93,14 +100,31 @@ function renderOverlay({ onRemoveTitle={onRemoveTitle as (paneId: number) => void} onClosePane={onClosePane as (paneId: number) => void} onRenameValueChange={vi.fn()} - onRenameSubmit={vi.fn()} + onRenameSubmit={onRenameSubmit as () => void} onRenameCancel={vi.fn()} onRenameBlur={vi.fn()} /> ) }) mounted.push({ container, root }) - return { container, onClosePane, onRemoveTitle } + return { container, onClosePane, onRemoveTitle, onRenameSubmit } +} + +function pressInputKey( + input: HTMLInputElement, + key: string, + options?: { isComposing?: boolean; keyCode?: number } +): void { + act(() => { + const event = new KeyboardEvent('keydown', { key, bubbles: true }) + if (options?.isComposing !== undefined) { + Object.defineProperty(event, 'isComposing', { value: options.isComposing }) + } + if (options?.keyCode !== undefined) { + Object.defineProperty(event, 'keyCode', { value: options.keyCode }) + } + input.dispatchEvent(event) + }) } afterEach(() => { @@ -142,4 +166,23 @@ describe('TerminalPaneHeaderOverlay', () => { expect(onClosePane).toHaveBeenCalledWith(1) expect(onRemoveTitle).not.toHaveBeenCalled() }) + + it('ignores IME composition Enter before submitting a pane title rename', () => { + const { container, onRenameSubmit } = renderOverlay({ + paneTitles: { 1: 'server', 2: '' }, + renamingPaneId: 1, + renameValue: '日本語 pane' + }) + const input = container.querySelector('.pane-title-input') + + expect(input).not.toBeNull() + + pressInputKey(input as HTMLInputElement, 'Enter', { isComposing: true }) + + expect(onRenameSubmit).not.toHaveBeenCalled() + + pressInputKey(input as HTMLInputElement, 'Enter') + + expect(onRenameSubmit).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx index 3ad25d41bdd..fc1602f866e 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx @@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { translate } from '@/i18n/i18n' import { WORKSPACE_FILE_PATH_MIME, WORKSPACE_FILE_PATHS_MIME } from '@/lib/workspace-file-drag' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import type { PtyTransport } from './pty-transport' import { handleInternalTerminalFileDrop } from './terminal-drop-handler' @@ -183,6 +184,11 @@ export default function TerminalPaneHeaderOverlay({ value={renameValue} onChange={(event) => onRenameValueChange(event.target.value)} onKeyDown={(event) => { + // Why: an Enter that only confirms a CJK IME candidate must + // not commit the rename; wait for a non-composition Enter. + if (isImeCompositionKeyDown(event)) { + return + } if (event.key === 'Enter') { onRenameSubmit() } else if (event.key === 'Escape') { diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx index dd71eba0cf7..0e68af9126f 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx @@ -50,6 +50,7 @@ type TerminalOverlaySlotProps = { terminalGeneration: number | undefined worktreeId: string worktreePath: string + startupCwd: string | undefined groupId: string | undefined isWorktreeActive: boolean isVisible: boolean @@ -66,6 +67,7 @@ const TerminalOverlaySlot = memo(function TerminalOverlaySlot({ terminalGeneration, worktreeId, worktreePath, + startupCwd, groupId, isWorktreeActive, isVisible, @@ -223,7 +225,7 @@ const TerminalOverlaySlot = memo(function TerminalOverlaySlot({ key={`${terminalTabId}-${terminalGeneration ?? 0}`} tabId={terminalTabId} worktreeId={worktreeId} - cwd={worktreePath} + cwd={startupCwd ?? worktreePath} isActive={isActive || activityTerminalPortal?.active === true} // Why: split-group changes reparent TabGroupPanel subtrees. Keeping the // TerminalPane mounted here preserves alt-screen TUI state while this @@ -365,6 +367,7 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({ terminalGeneration={terminalTab.generation} worktreeId={worktreeId} worktreePath={worktreePath} + startupCwd={terminalTab.startupCwd} groupId={assignment?.groupId} isWorktreeActive={isWorktreeActive} isVisible={isVisible} diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts index ed370b84864..d69d1858c1f 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts @@ -1510,6 +1510,43 @@ describe('agent completion coordinator', () => { expect(dispatchAttention).not.toHaveBeenCalled() }) + it('still dispatches completion on done after an intervening waiting state in the same turn', () => { + const dispatchCompletion = vi.fn() + const dispatchAttention = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(), + dispatchCompletion, + dispatchAttention, + isLive: () => true + }) + + const turn = { + prompt: 'fix the bug', + agentType: 'cursor' as const + } + + // Realistic flow: the agent pauses for a permission prompt mid-turn, resumes, + // then genuinely finishes. The intervening attention state must surface as + // attention only and must not suppress the final completion. This fails if + // 'waiting' is treated as a completion state (issue #5698). + coordinator.observeHookStatus({ state: 'working', ...turn }) + coordinator.observeHookStatus({ + state: 'waiting', + ...turn, + toolName: 'Shell', + toolInput: 'pnpm test' + }) + coordinator.observeHookStatus({ state: 'working', ...turn }) + coordinator.observeHookStatus({ state: 'done', ...turn, lastAssistantMessage: 'Done.' }) + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) + + expect(dispatchAttention).toHaveBeenCalledTimes(1) + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + }) + it('keeps a generic title completion pending long enough for the first remote inspection', async () => { const inspection = createDeferred() const dispatchCompletion = vi.fn() diff --git a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts index 35cd9f82fcd..ed78a5d6500 100644 --- a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts +++ b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts @@ -22,6 +22,7 @@ import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion import { splitTerminalPaneWithInheritedCwd } from './terminal-pane-split-with-inherited-cwd' import { useAppStore } from '@/store' import { recordTerminalUserInputForLeaf } from './terminal-input-activity' +import { isLocalWindowsConptyPaneForCtrlArrow } from './terminal-ctrl-arrow-conpty' import { markTerminalFollowOutput, markTerminalPinnedViewport, @@ -137,6 +138,7 @@ export function matchFileSearchShortcut( type KeyboardHandlersDeps = { tabId: string + worktreeId: string isActive: boolean keyboardScopeRef: React.RefObject managerRef: React.RefObject @@ -170,6 +172,7 @@ type KeyboardHandlersDeps = { */ export function useTerminalKeyboardShortcuts({ tabId, + worktreeId, isActive, keyboardScopeRef, managerRef, @@ -268,13 +271,36 @@ export function useTerminalKeyboardShortcuts({ return } + // Why: the active pane's live PTY session decides whether Ctrl+Arrow should + // pass through as native \e[1;5C/\e[1;5D or be translated to \eb/\ef. + // Resolved lazily so session/runtime lookups stay off other keystrokes. + const isLocalWindowsConptyPane = (): boolean => { + const activePane = manager.getActivePane() ?? manager.getPanes()[0] + if (!activePane) { + return false + } + const storeState = useAppStore.getState() + return isLocalWindowsConptyPaneForCtrlArrow({ + isWindows, + userAgent: navigator.userAgent, + state: storeState, + worktreeId, + tabId, + paneId: activePane.id, + paneCwd: paneCwdRef.current, + fallbackCwd, + transport: paneTransportsRef.current.get(activePane.id) ?? null + }) + } + const action = resolveTerminalShortcutAction( e, isMac, macOptionAsAltRef.current, optionKeyLocation, isWindows, - keybindings + keybindings, + isLocalWindowsConptyPane ) if (!action) { return @@ -513,7 +539,8 @@ export function useTerminalKeyboardShortcuts({ macOptionAsAltRef, keybindings, terminalShortcutPolicy, - tabId + tabId, + worktreeId ]) } diff --git a/src/renderer/src/components/terminal-pane/layout-serialization.test.ts b/src/renderer/src/components/terminal-pane/layout-serialization.test.ts index 06c85c002c5..b72cc28a82a 100644 --- a/src/renderer/src/components/terminal-pane/layout-serialization.test.ts +++ b/src/renderer/src/components/terminal-pane/layout-serialization.test.ts @@ -36,6 +36,8 @@ beforeAll(() => { import { buildFontFamily, + POST_REPLAY_MODE_RESET, + restoreScrollbackBuffers, serializePaneTree, serializeTerminalLayout, replayTerminalLayout, @@ -422,6 +424,38 @@ describe('replayTerminalLayout', () => { }) }) +describe('restoreScrollbackBuffers', () => { + it('marks panes with restored scrollback for fresh-shell viewport blanking', () => { + const writes: string[] = [] + const pane = { + id: 1, + terminal: { + write: vi.fn((data: string, callback?: () => void) => { + writes.push(data) + callback?.() + }) + } + } + const manager = { + getPanes: vi.fn(() => [pane]) + } + const replayingPanesRef = { current: new Map() } + const restoredViewportBlankingPanesRef = { current: new Set() } + + restoreScrollbackBuffers( + manager as unknown as Parameters[0], + { [LEAF_1]: 'restored output' }, + new Map([[LEAF_1, 1]]), + replayingPanesRef, + restoredViewportBlankingPanesRef + ) + + expect(writes).toEqual(['restored output', '\r\n', POST_REPLAY_MODE_RESET]) + expect(restoredViewportBlankingPanesRef.current.has(1)).toBe(true) + expect(replayingPanesRef.current.size).toBe(0) + }) +}) + // --------------------------------------------------------------------------- // collectLeafIdsInReplayCreationOrder // --------------------------------------------------------------------------- diff --git a/src/renderer/src/components/terminal-pane/layout-serialization.ts b/src/renderer/src/components/terminal-pane/layout-serialization.ts index 423a08b8697..78f774ca439 100644 --- a/src/renderer/src/components/terminal-pane/layout-serialization.ts +++ b/src/renderer/src/components/terminal-pane/layout-serialization.ts @@ -6,6 +6,7 @@ import type { import { isTerminalLeafId } from '../../../../shared/stable-pane-id' import type { PaneManager } from '@/lib/pane-manager/pane-manager' import { replayIntoTerminal, type ReplayingPanesRef } from './replay-guard' +import type { RestoredViewportBlankingPanesRef } from './terminal-restored-viewport' import { getLeftmostLeafId, normalizeTerminalLayoutSnapshot, @@ -58,27 +59,32 @@ export const POST_REPLAY_MODE_RESET = `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KIT export const POST_REPLAY_LIVE_SNAPSHOT_RESET = `${RESET_TERMINAL_CURSOR_STYLE}\x1b[?25h\x1b[?1004l` // Why: daemon snapshot restore reattaches to a live session, so we avoid the -// full POST_REPLAY_MODE_RESET bundle there — a still-running TUI may still -// rely on mouse or bracketed-paste modes. Four exceptions are safe to reset: +// full POST_REPLAY_MODE_RESET bundle there. The default still clears the two +// stale mode bits most harmful to a plain shell after a TUI exits badly: // // 0 q — DECSCUSR cursor style/blink reset: raw replay can contain a stale // steady cursor override, while SerializeAddon does not preserve an // authoritative current cursor style. Reset to the user's configured // xterm cursor; the post-reattach SIGWINCH lets live TUIs repaint if // they need a different cursor. -// 25 — DECTCEM cursor visibility: SerializeAddon bakes `?25l` into the -// snapshot when the cursor was hidden at capture time. Without `?25h` -// here the cursor stays invisible after reattach. If a TUI is still -// running and wants the cursor hidden, the SIGWINCH sent immediately -// after restore triggers a repaint that re-hides it — a brief flash -// that is far less harmful than a permanently invisible cursor. -// 1004 — focus event reporting: preserving `?1004h` makes restored shells -// ring BEL on pane focus/blur (shells like zsh treat `\e[I`/`\e[O` -// as unbound key input). +// 25 — DECTCEM cursor visibility: snapshots can preserve hidden cursor +// state from a no-longer-running TUI; reset by default so shells do +// not inherit a permanently invisible cursor. +// 1004 — focus event reporting: snapshots can preserve focus reporting from +// a no-longer-running TUI; reset by default so shells do not receive +// stray focus-in/focus-out bytes. // <99u/=0u — Kitty keyboard mode is renderer-side xterm state; stale copies // can make the next Ctrl+C encode as CSI-u after reattach. export const POST_REPLAY_REATTACH_RESET = `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}\x1b[?25h\x1b[?1004l` +// Why: a live agent TUI legitimately owns focus reporting; resetting `?1004h` +// would suppress the post-reattach focus-in the agent needs to move its real +// cursor back to the input caret (the IME anchor). Cursor visibility is still +// reset: agent detection can false-positive on a dead TUI's leftovers, and a +// permanently invisible shell cursor is far worse than the brief flash a live +// agent re-hides on its post-reattach SIGWINCH repaint. +export const POST_REPLAY_LIVE_AGENT_REATTACH_RESET = `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}\x1b[?25h` + // Cross-platform monospace fallback chain ensures the terminal always has a // usable font regardless of OS. macOS-only fonts like SF Mono and Menlo are // harmless on other platforms (the browser skips them), while Cascadia Mono / @@ -206,7 +212,8 @@ export function restoreScrollbackBuffers( manager: PaneManager, savedBuffers: Record | undefined, restoredPaneByLeafId: Map, - replayingPanesRef: ReplayingPanesRef + replayingPanesRef: ReplayingPanesRef, + restoredViewportBlankingPanesRef?: RestoredViewportBlankingPanesRef ): void { if (!savedBuffers) { return @@ -244,6 +251,9 @@ export function restoreScrollbackBuffers( // The shell underneath is fresh and has no TUI consuming these modes. // See POST_REPLAY_MODE_RESET comment. replayIntoTerminal(pane, replayingPanesRef, POST_REPLAY_MODE_RESET) + // Why: connection resolution happens after layout replay; only the + // fresh-shell paths should move these visible rows into scrollback. + restoredViewportBlankingPanesRef?.current.add(pane.id) } } catch { // If restore fails, continue with blank terminal. diff --git a/src/renderer/src/components/terminal-pane/native-chat-leaf-title-agent.test.ts b/src/renderer/src/components/terminal-pane/native-chat-leaf-title-agent.test.ts new file mode 100644 index 00000000000..accbbaa1c80 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/native-chat-leaf-title-agent.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { resolveNativeChatLeafTitleAgent } from './native-chat-leaf-title-agent' + +const panes = [ + { id: 1, leafId: 'leaf-1' }, + { id: 2, leafId: 'leaf-2' } +] + +describe('resolveNativeChatLeafTitleAgent', () => { + it('uses the target split leaf runtime title', () => { + expect( + resolveNativeChatLeafTitleAgent({ + leafId: 'leaf-2', + panes, + runtimePaneTitlesByPaneId: { 1: 'PowerShell', 2: 'Codex - working' }, + tabLabel: 'PowerShell' + }) + ).toBe('codex') + }) + + it('does not reuse the active leaf tab label for an inactive split leaf', () => { + expect( + resolveNativeChatLeafTitleAgent({ + leafId: 'leaf-2', + panes, + runtimePaneTitlesByPaneId: { 1: 'Codex - working', 2: 'PowerShell' }, + tabLabel: 'Codex - working' + }) + ).toBeNull() + }) + + it('does not reuse a stale tab label for the active split leaf without a runtime title', () => { + expect( + resolveNativeChatLeafTitleAgent({ + leafId: 'leaf-1', + panes, + runtimePaneTitlesByPaneId: {}, + tabLabel: 'Claude Code' + }) + ).toBeNull() + }) + + it('falls back to the terminal title in a single pane', () => { + expect( + resolveNativeChatLeafTitleAgent({ + leafId: 'leaf-1', + panes: [panes[0]], + runtimePaneTitlesByPaneId: {}, + terminalTitle: 'OpenClaude' + }) + ).toBe('openclaude') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/native-chat-leaf-title-agent.ts b/src/renderer/src/components/terminal-pane/native-chat-leaf-title-agent.ts new file mode 100644 index 00000000000..e3527bf11ac --- /dev/null +++ b/src/renderer/src/components/terminal-pane/native-chat-leaf-title-agent.ts @@ -0,0 +1,40 @@ +import type { TuiAgent } from '../../../../shared/types' +import { resolveTabAgentFromTitle } from '@/lib/use-tab-agent' + +export type NativeChatLeafTitlePane = { + id: number + leafId: string +} + +export type NativeChatLeafTitleAgentInput = { + leafId: string | null + panes: readonly NativeChatLeafTitlePane[] + runtimePaneTitlesByPaneId: Readonly> + tabLabel?: string | null + terminalTitle?: string | null +} + +export function resolveNativeChatLeafTitleAgent({ + leafId, + panes, + runtimePaneTitlesByPaneId, + tabLabel, + terminalTitle +}: NativeChatLeafTitleAgentInput): TuiAgent | null { + if (!leafId) { + return null + } + const targetPane = panes.find((pane) => pane.leafId === leafId) + const paneAgent = targetPane + ? resolveTabAgentFromTitle(runtimePaneTitlesByPaneId[targetPane.id] ?? '') + : null + if (paneAgent) { + return paneAgent + } + // Tab titles can lag pane focus in split layouts, so use them only when there + // is no sibling leaf they could accidentally describe. + if (panes.length > 1) { + return null + } + return resolveTabAgentFromTitle(tabLabel ?? '') ?? resolveTabAgentFromTitle(terminalTitle ?? '') +} diff --git a/src/renderer/src/components/terminal-pane/override-affected-panes.test.ts b/src/renderer/src/components/terminal-pane/override-affected-panes.test.ts new file mode 100644 index 00000000000..ba0ca79d821 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/override-affected-panes.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import type { ManagedPane } from '@/lib/pane-manager/pane-manager-types' +import { getOverrideAffectedPanes, getPanesNeedingOverrideFit } from './override-affected-panes' + +function makePane(id: number, cols = 120, rows = 40): ManagedPane { + return { id, terminal: { cols, rows } } as ManagedPane +} + +describe('getOverrideAffectedPanes', () => { + it('returns only panes bound to the event PTY in this tab', () => { + const panes = [makePane(1), makePane(2), makePane(3)] + const bindings = new Map([ + [1, 'pty-a'], + [2, 'pty-b'], + [3, 'pty-a'] + ]) + + const affected = getOverrideAffectedPanes(panes, (paneId) => bindings.get(paneId), 'pty-a') + + expect(affected.map((pane) => pane.id)).toEqual([1, 3]) + }) + + it('returns nothing for a watcher whose panes are bound to other PTYs', () => { + const panes = [makePane(10), makePane(11)] + const bindings = new Map([ + [10, 'pty-x'], + [11, 'pty-y'] + ]) + + const affected = getOverrideAffectedPanes(panes, (paneId) => bindings.get(paneId), 'pty-z') + + expect(affected).toEqual([]) + }) + + it('ignores unbound panes (resolver returns undefined)', () => { + const panes = [makePane(1), makePane(2)] + const bindings = new Map([[1, 'pty-a']]) + + const affected = getOverrideAffectedPanes(panes, (paneId) => bindings.get(paneId), 'pty-a') + + expect(affected.map((pane) => pane.id)).toEqual([1]) + }) +}) + +describe('getPanesNeedingOverrideFit', () => { + it('returns only affected panes whose grid does not already match the override', () => { + const panes = [makePane(1, 49, 20), makePane(2, 120, 40), makePane(3, 49, 21)] + + const panesNeedingFit = getPanesNeedingOverrideFit(panes, 49, 20) + + expect(panesNeedingFit.map((pane) => pane.id)).toEqual([2, 3]) + }) + + it('returns nothing when every affected pane already matches the override', () => { + const panes = [makePane(1, 49, 20), makePane(2, 49, 20)] + + expect(getPanesNeedingOverrideFit(panes, 49, 20)).toEqual([]) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/override-affected-panes.ts b/src/renderer/src/components/terminal-pane/override-affected-panes.ts new file mode 100644 index 00000000000..98f20bddcab --- /dev/null +++ b/src/renderer/src/components/terminal-pane/override-affected-panes.ts @@ -0,0 +1,36 @@ +// Why: a fit-override event names a PTY, but pane IDs are per-tab and the +// override stream reaches every subscriber (including passive desktop +// watchers). This resolves which of this tab's panes are bound to the +// event's PTY via the tab's live transport bindings, so both the +// mobile-fit and desktop-fit branches of the override listener refit the +// same set of panes without colliding on global numeric pane IDs. + +import type { ManagedPane } from '@/lib/pane-manager/pane-manager-types' + +// Why: transports return string | null and the binding lookup yields +// undefined for unbound panes, so the resolver tolerates both absent cases. +export type PanePtyResolver = (paneId: number) => string | null | undefined + +export function getOverrideAffectedPanes( + panes: readonly ManagedPane[], + resolvePtyId: PanePtyResolver, + ptyId: string +): ManagedPane[] { + return panes.filter((pane) => resolvePtyId(pane.id) === ptyId) +} + +function paneNeedsOverrideFit( + pane: Pick, + cols: number, + rows: number +): boolean { + return pane.terminal.cols !== cols || pane.terminal.rows !== rows +} + +export function getPanesNeedingOverrideFit( + panes: readonly ManagedPane[], + cols: number, + rows: number +): ManagedPane[] { + return panes.filter((pane) => paneNeedsOverrideFit(pane, cols, rows)) +} diff --git a/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts b/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts new file mode 100644 index 00000000000..5a2e8c25840 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts @@ -0,0 +1,190 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createPaneForegroundAgentTracker } from './pane-foreground-agent-tracker' +import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent' + +const COMMAND_SETTLE_MS = 350 +const WRAPPER_RESOLVE_RETRY_MS = 1200 +const SECOND_WRAPPER_RETRY_MS = 3500 + +describe('createPaneForegroundAgentTracker', () => { + const readForegroundProcess = vi.fn<(ptyId: string) => Promise>() + const publish = vi.fn<(entry: PaneForegroundAgentEntry) => void>() + let ptyId: string | null = 'pty-1' + + function makeTracker(): ReturnType { + return createPaneForegroundAgentTracker({ + getPtyId: () => ptyId, + isTrackablePtyId: (id) => !id.startsWith('remote:') && !id.startsWith('ssh:'), + readForegroundProcess, + publish + }) + } + + async function flushSettleRead(ms: number): Promise { + await vi.advanceTimersByTimeAsync(ms) + } + + beforeEach(() => { + vi.useFakeTimers() + readForegroundProcess.mockReset() + publish.mockReset() + ptyId = 'pty-1' + }) + + afterEach(() => { + vi.clearAllTimers() + vi.useRealTimers() + }) + + it('reads the foreground once after a command starts and publishes the recognized agent', async () => { + readForegroundProcess.mockResolvedValue('claude') + const tracker = makeTracker() + + tracker.onCommandStarted() + expect(publish).toHaveBeenCalledExactlyOnceWith({ agent: null, shellForeground: false }) + expect(readForegroundProcess).not.toHaveBeenCalled() + + await flushSettleRead(COMMAND_SETTLE_MS) + + expect(readForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1') + expect(publish).toHaveBeenLastCalledWith({ agent: 'claude', shellForeground: false }) + }) + + it('re-reads on a bounded ladder while the read still sees an interpreter wrapper', async () => { + // Why: daemon shell/helper→agent ancestry resolution has been observed to + // take >1.5s for real node-wrapped CLIs, so the ladder gets two re-reads. + readForegroundProcess + .mockResolvedValueOnce('node') + .mockResolvedValueOnce('node') + .mockResolvedValueOnce('claude') + const tracker = makeTracker() + + tracker.onCommandStarted() + await flushSettleRead(COMMAND_SETTLE_MS) + expect(readForegroundProcess).toHaveBeenCalledTimes(1) + expect(publish).toHaveBeenLastCalledWith({ agent: null, shellForeground: false }) + + await flushSettleRead(WRAPPER_RESOLVE_RETRY_MS) + expect(readForegroundProcess).toHaveBeenCalledTimes(2) + + await flushSettleRead(SECOND_WRAPPER_RETRY_MS) + expect(readForegroundProcess).toHaveBeenCalledTimes(3) + expect(publish).toHaveBeenLastCalledWith({ agent: 'claude', shellForeground: false }) + }) + + it('stops after the ladder and publishes no identity for a persistent unknown process', async () => { + readForegroundProcess.mockResolvedValue('some-unknown-tool') + const tracker = makeTracker() + + tracker.onCommandStarted() + await flushSettleRead( + COMMAND_SETTLE_MS + WRAPPER_RESOLVE_RETRY_MS + SECOND_WRAPPER_RETRY_MS + 10_000 + ) + + expect(readForegroundProcess).toHaveBeenCalledTimes(3) + expect(publish).toHaveBeenLastCalledWith({ agent: null, shellForeground: false }) + }) + + it('does not treat a nested shell seen mid-command as prompt proof', async () => { + // Why: 133;D cancels pending reads, so a read that still runs means the + // command is live — a shell foreground here is a nested sh/bash, and a + // shell-foreground mark would suppress live title identity in the pane. + readForegroundProcess.mockResolvedValue('sh') + const tracker = makeTracker() + + tracker.onCommandStarted() + await flushSettleRead( + COMMAND_SETTLE_MS + WRAPPER_RESOLVE_RETRY_MS + SECOND_WRAPPER_RETRY_MS + 10_000 + ) + + expect(readForegroundProcess).toHaveBeenCalledTimes(3) + expect(publish).not.toHaveBeenCalledWith({ agent: null, shellForeground: true }) + expect(publish).toHaveBeenLastCalledWith({ agent: null, shellForeground: false }) + }) + + it('recognizes an agent started from a nested shell on a ladder re-read', async () => { + readForegroundProcess.mockResolvedValueOnce('bash').mockResolvedValueOnce('gemini') + const tracker = makeTracker() + + tracker.onCommandStarted() + await flushSettleRead(COMMAND_SETTLE_MS) + await flushSettleRead(WRAPPER_RESOLVE_RETRY_MS) + + expect(publish).toHaveBeenLastCalledWith({ agent: 'gemini', shellForeground: false }) + }) + + it('marks shell foreground on command finished without any foreground read', () => { + const tracker = makeTracker() + + tracker.onCommandFinished() + + expect(publish).toHaveBeenCalledExactlyOnceWith({ agent: null, shellForeground: true }) + expect(readForegroundProcess).not.toHaveBeenCalled() + }) + + it('cancels a pending read when the command finishes first', async () => { + readForegroundProcess.mockResolvedValue('claude') + const tracker = makeTracker() + + tracker.onCommandStarted() + tracker.onCommandFinished() + await flushSettleRead(COMMAND_SETTLE_MS + WRAPPER_RESOLVE_RETRY_MS) + + expect(readForegroundProcess).not.toHaveBeenCalled() + expect(publish).toHaveBeenLastCalledWith({ agent: null, shellForeground: true }) + }) + + it('never reads or publishes for remote or ssh panes', async () => { + ptyId = 'remote:web-env-1@@terminal-1' + const tracker = makeTracker() + + tracker.onCommandStarted() + tracker.onCommandFinished() + await flushSettleRead(COMMAND_SETTLE_MS + WRAPPER_RESOLVE_RETRY_MS) + + ptyId = 'ssh:conn@@pty-9' + tracker.onCommandStarted() + tracker.onCommandFinished() + await flushSettleRead(COMMAND_SETTLE_MS + WRAPPER_RESOLVE_RETRY_MS) + + expect(readForegroundProcess).not.toHaveBeenCalled() + expect(publish).not.toHaveBeenCalled() + }) + + it('drops a stale read result when a newer command superseded it', async () => { + let resolveFirstRead: (value: string | null) => void = () => {} + readForegroundProcess + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirstRead = resolve + }) + ) + .mockResolvedValueOnce('codex') + const tracker = makeTracker() + + tracker.onCommandStarted() + await flushSettleRead(COMMAND_SETTLE_MS) + tracker.onCommandStarted() + resolveFirstRead('claude') + await flushSettleRead(COMMAND_SETTLE_MS) + + expect(publish).toHaveBeenLastCalledWith({ agent: 'codex', shellForeground: false }) + expect(publish).not.toHaveBeenCalledWith({ agent: 'claude', shellForeground: false }) + }) + + it('stops publishing after dispose', async () => { + readForegroundProcess.mockResolvedValue('claude') + const tracker = makeTracker() + + tracker.onCommandStarted() + publish.mockReset() + tracker.dispose() + await flushSettleRead(COMMAND_SETTLE_MS + WRAPPER_RESOLVE_RETRY_MS) + + expect(readForegroundProcess).not.toHaveBeenCalled() + expect(publish).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts b/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts new file mode 100644 index 00000000000..1af2f78fdd7 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts @@ -0,0 +1,105 @@ +import { recognizeAgentProcess } from '../../../../shared/agent-process-recognition' +import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent' + +// Why: the read must land after the shell has exec'd the command; and when it +// still sees a node/python wrapper, the daemon resolves that ancestry +// asynchronously — observed to take >1.5s for real node-wrapped CLIs — so +// give its cache two bounded re-reads, not an open-ended retry loop. +const COMMAND_SETTLE_MS = 350 +const WRAPPER_RESOLVE_RETRY_DELAYS_MS = [1200, 3500] as const + +type PaneForegroundAgentTrackerDeps = { + getPtyId: () => string | null + /** Local panes only — remote/SSH foreground reads are expensive RPCs and + * their replayed OSC streams must not produce process evidence. */ + isTrackablePtyId: (ptyId: string) => boolean + readForegroundProcess: (ptyId: string) => Promise + publish: (entry: PaneForegroundAgentEntry) => void +} + +/** + * Publishes process-table identity for a pane at OSC 133 command boundaries: + * one foreground read when a command starts (that is when the foreground + * changes), and a no-RPC shell-foreground mark when it finishes — 133;D is + * the ONLY source of shell-foreground proof; reads never produce it. + */ +export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTrackerDeps): { + onCommandStarted: () => void + onCommandFinished: () => void + dispose: () => void +} { + let disposed = false + let readTimer: number | null = null + let readGeneration = 0 + + const trackablePtyId = (): string | null => { + const ptyId = deps.getPtyId() + return ptyId && deps.isTrackablePtyId(ptyId) ? ptyId : null + } + + const cancelPendingRead = (): void => { + readGeneration += 1 + if (readTimer !== null) { + window.clearTimeout(readTimer) + readTimer = null + } + } + + const scheduleRead = (delayMs: number, retryIndex: number): void => { + const generation = readGeneration + readTimer = window.setTimeout(() => { + readTimer = null + void readForeground(generation, retryIndex) + }, delayMs) + } + + async function readForeground(generation: number, retryIndex: number): Promise { + const ptyId = trackablePtyId() + if (disposed || generation !== readGeneration || !ptyId) { + return + } + const processName = await deps.readForegroundProcess(ptyId).catch(() => null) + if (disposed || generation !== readGeneration) { + return + } + const recognized = recognizeAgentProcess(processName) + if (recognized) { + deps.publish({ agent: recognized.agent, shellForeground: false }) + return + } + // Why: a shell seen here is NOT prompt proof — 133;D cancels pending reads, + // so a still-live generation means the command is running and the shell is + // a nested one (sh/bash without integration); marking shell-foreground + // would suppress live title identity. Only 133;D proves the prompt. + const retryDelay = WRAPPER_RESOLVE_RETRY_DELAYS_MS[retryIndex] + if (retryDelay !== undefined && processName) { + scheduleRead(retryDelay, retryIndex + 1) + return + } + deps.publish({ agent: null, shellForeground: false }) + } + + return { + onCommandStarted() { + cancelPendingRead() + if (!trackablePtyId()) { + return + } + // Why: the foreground left the prompt the moment C fired — stale + // shell-foreground evidence must not clear the command that just started. + deps.publish({ agent: null, shellForeground: false }) + scheduleRead(COMMAND_SETTLE_MS, 0) + }, + onCommandFinished() { + cancelPendingRead() + if (!trackablePtyId()) { + return + } + deps.publish({ agent: null, shellForeground: true }) + }, + dispose() { + disposed = true + cancelPendingRead() + } + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-types.ts b/src/renderer/src/components/terminal-pane/pty-connection-types.ts index 090ef959038..01c98e84557 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-types.ts @@ -1,5 +1,6 @@ import type { PtyTransport } from './pty-transport' import type { ReplayingPanesRef } from './replay-guard' +import type { RestoredViewportBlankingPanesRef } from './terminal-restored-viewport' import type { AgentCompletionStatusSnapshot } from './agent-completion-coordinator-types' import type { EventProps } from '../../../../shared/telemetry-events' import type { TerminalColorSchemeMode } from '../../../../shared/terminal-color-scheme-protocol' @@ -38,6 +39,7 @@ export type PtyConnectionDeps = { paneMode2031Ref: React.RefObject> paneLastThemeModeRef: React.RefObject> replayingPanesRef: ReplayingPanesRef + restoredViewportBlankingPanesRef?: RestoredViewportBlankingPanesRef isActiveRef: React.RefObject isVisibleRef: React.RefObject onPtyExitRef: React.RefObject<(ptyId: string) => void> 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 426cbf15656..c8e90d3d6f2 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -1,12 +1,15 @@ /* oxlint-disable max-lines */ import type * as React from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Terminal } from '@xterm/headless' import { + POST_REPLAY_LIVE_AGENT_REATTACH_RESET, POST_REPLAY_MODE_RESET, POST_REPLAY_REATTACH_RESET, RESET_KITTY_KEYBOARD_PROTOCOL, RESET_TERMINAL_CURSOR_STYLE } from './layout-serialization' +import { buildFreshShellViewportBlankingSequence } from './terminal-restored-viewport' import { TERMINAL_PASTE_DIRECT_MAX_BYTES } from './terminal-paste-coordinator' import type * as UseNotificationDispatchModule from './use-notification-dispatch' import { getEagerPtyBufferHandle } from './pty-dispatcher' @@ -44,6 +47,51 @@ async function drainPendingTimeouts(pendingTimeouts: (() => void)[], limit = 100 } } +function writeHeadlessTerminal(term: Terminal, data: string): Promise { + return new Promise((resolve) => term.write(data, resolve)) +} + +async function renderHeadlessBuffer(writes: string[], cols = 80, rows = 8): Promise { + const term = new Terminal({ cols, rows, allowProposedApi: true }) + try { + for (const write of writes) { + await writeHeadlessTerminal(term, write) + } + const lines: string[] = [] + for (let lineIndex = 0; lineIndex < term.buffer.active.length; lineIndex++) { + lines.push(term.buffer.active.getLine(lineIndex)?.translateToString(true) ?? '') + } + return lines + } finally { + term.dispose() + } +} + +async function renderHeadlessTerminalState( + writes: string[], + cols = 80, + rows = 8 +): Promise<{ allLines: string[]; visibleLines: string[]; baseY: number }> { + const term = new Terminal({ cols, rows, allowProposedApi: true }) + try { + for (const write of writes) { + await writeHeadlessTerminal(term, write) + } + const allLines: string[] = [] + const buffer = term.buffer.active + for (let lineIndex = 0; lineIndex < buffer.length; lineIndex++) { + allLines.push(buffer.getLine(lineIndex)?.translateToString(true) ?? '') + } + const visibleLines: string[] = [] + for (let row = 0; row < term.rows; row++) { + visibleLines.push(buffer.getLine(buffer.viewportY + row)?.translateToString(true) ?? '') + } + return { allLines, visibleLines, baseY: buffer.baseY } + } finally { + term.dispose() + } +} + const toastInfo = vi.fn() const LEAF_1 = '11111111-1111-4111-8111-111111111111' as const const LEAF_2 = '22222222-2222-4222-8222-222222222222' as const @@ -88,7 +136,7 @@ type StoreState = { | { kind: 'windows-host' } | { kind: 'wsl'; distro: string } }[] - sshConnectionStates: Map + sshConnectionStates: Map cacheTimerByKey: Record settings: { theme?: 'system' | 'dark' | 'light' @@ -134,13 +182,15 @@ type StoreState = { setAgentStatus: ReturnType removeAgentStatus: ReturnType dropAgentStatus: ReturnType + setPaneForegroundAgent: ReturnType + clearPaneForegroundAgent: ReturnType markTerminalTabUnread: ReturnType markTerminalPaneUnread: ReturnType markAgentCompletionPaneUnread: ReturnType } type ConnectCallbacks = { - onData?: (data: string, meta?: { seq?: number; rawLength?: number }) => void + onData?: (data: string, meta?: { seq?: number; rawLength?: number; background?: boolean }) => void onReplayData?: (data: string, meta?: { clearBeforeReplay?: boolean }) => void onError?: (msg: string) => void } @@ -208,6 +258,9 @@ vi.mock('@/lib/agent-status', async (importOriginal) => { if (/Codex( working)?/.test(title)) { return /working/.test(title) ? 'working' : 'idle' } + if (/^\s*(?:[\u2800-\u28ff]\s+)?(?:Pi|OMP)(?: ready| idle)?\s*$/i.test(title)) { + return /[\u2800-\u28ff]/u.test(title) ? 'working' : 'idle' + } return null }) } @@ -330,7 +383,8 @@ function createPane(paneId: number) { active: activeBuffer }, modes: { - bracketedPasteMode: false + bracketedPasteMode: false, + sendFocusMode: false }, options: { ignoreBracketedPasteMode: false, @@ -371,6 +425,21 @@ function createPane(paneId: number) { } } +function captureCallbackTerminalWrites(pane: ReturnType): { + writes: string[] + parseCallbacks: (() => void)[] +} { + const writes: string[] = [] + const parseCallbacks: (() => void)[] = [] + pane.terminal.write = function write(data: string, callback?: () => void): void { + writes.push(data) + if (callback) { + parseCallbacks.push(callback) + } + } as typeof pane.terminal.write + return { writes, parseCallbacks } +} + function createManager(paneCount = 1, initialActivePaneId: number | null = null) { let activePaneId = initialActivePaneId const panes = Array.from({ length: paneCount }, (_, index) => ({ @@ -434,6 +503,52 @@ function createDeps(overrides: Record = {}) { } } +function setReattachPaneTitle(title: string): void { + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty', title }] + }, + runtimePaneTitlesByTabId: { + 'tab-1': { 1: title } + } + } as StoreState +} + +async function withMockedDocumentActiveElement( + activeElement: unknown, + run: () => Promise +): Promise { + const originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document') + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: { activeElement } + }) + try { + return await run() + } finally { + if (originalDocument) { + Object.defineProperty(globalThis, 'document', originalDocument) + } else { + Reflect.deleteProperty(globalThis, 'document') + } + } +} + +function configureTerminalFocusMode( + pane: ReturnType, + textarea: HTMLTextAreaElement +): void { + Object.assign(pane.terminal, { textarea }) + Object.assign(pane.terminal.modes, { sendFocusMode: true }) + pane.terminal.write.mockImplementation((_data: string, callback?: () => void) => { + callback?.() + }) +} + +const ANSI_POSITIONED_CURSOR_AGENT_REATTACH_SCREEN = + '\x1b[4;3HCursor Agent\x1b[5;3Hv2026.06.29\x1b[9;3H→ Plan, search, build anything' + // Why: setting activeRuntimeEnvironmentId in mockStoreState exercises the // remote-runtime path where the renderer still owns OSC 9999 status. function enableActiveRuntimeEnvironment(environmentId = 'env-1'): void { @@ -642,17 +757,22 @@ describe('connectPanePty', () => { markWorktreeUnread: vi.fn(), observeTerminalGitHubPullRequestLink: vi.fn(), recordTerminalInput: vi.fn(), - setAgentStatus: vi.fn((paneKey: string, payload: Record) => { - mockStoreState.agentStatusByPaneKey[paneKey] = { - ...payload, - paneKey, - updatedAt: Date.now(), - stateStartedAt: Date.now(), - stateHistory: [] + setAgentStatus: vi.fn( + (paneKey: string, payload: Record, terminalTitle?: string | null) => { + mockStoreState.agentStatusByPaneKey[paneKey] = { + ...payload, + paneKey, + ...(terminalTitle ? { terminalTitle } : {}), + updatedAt: Date.now(), + stateStartedAt: Date.now(), + stateHistory: [] + } } - }), + ), removeAgentStatus: vi.fn(), dropAgentStatus: vi.fn(), + setPaneForegroundAgent: vi.fn(), + clearPaneForegroundAgent: vi.fn(), markTerminalTabUnread: vi.fn(), markTerminalPaneUnread: vi.fn(), markAgentCompletionPaneUnread: vi.fn() @@ -666,6 +786,7 @@ describe('connectPanePty', () => { pty: { signal: vi.fn(), listSessions: vi.fn().mockResolvedValue([]), + hasPty: vi.fn().mockResolvedValue(true), getSize: vi.fn().mockResolvedValue(null), reportGeometry: vi.fn(), getMainBufferSnapshot: vi.fn().mockResolvedValue(null), @@ -693,7 +814,10 @@ describe('connectPanePty', () => { agentStatus: { inferInterrupt: vi.fn().mockResolvedValue(false) } - } + }, + dispatchEvent: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn() } globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { callback(0) @@ -836,6 +960,451 @@ describe('connectPanePty', () => { ) }) + it('normalizes Pi-compatible remote runtime status to OMP after typed omp command', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-omp') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, 'omp\r') + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(transport.sendInput).toHaveBeenCalledWith('omp\r') + expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'OMP ready') + expect(deps.updateTabTitle).toHaveBeenCalledWith('tab-1', 'OMP ready') + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'omp', + terminalTitle: 'OMP ready' + }) + }) + + it('normalizes after shell word deletion edits a typed command to omp', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-omp-edited') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, 'pi \x17omp\r') + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(transport.sendInput).toHaveBeenCalledWith('pi \x17omp\r') + expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'OMP ready') + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'omp', + terminalTitle: 'OMP ready' + }) + }) + + it('keeps Pi-compatible remote runtime status as Pi after typed pi command', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-pi') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, 'pi\r') + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(transport.sendInput).toHaveBeenCalledWith('pi\r') + expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'Pi ready') + expect(deps.updateTabTitle).toHaveBeenCalledWith('tab-1', 'Pi ready') + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'pi', + terminalTitle: 'Pi ready' + }) + }) + + it('does not infer shell ownership from prompts typed inside an existing Pi session', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + const now = Date.now() + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'done', + prompt: '', + agentType: 'pi', + paneKey, + terminalTitle: 'Pi ready', + updatedAt: now, + stateStartedAt: now, + stateHistory: [] + } + mockStoreState.runtimePaneTitlesByTabId = { 'tab-1': { 1: 'Pi ready' } } + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-pi-prompt') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, 'omp\r') + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(transport.sendInput).toHaveBeenCalledWith('omp\r') + expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'Pi ready') + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'pi', + terminalTitle: 'Pi ready' + }) + }) + + it('does not infer shell ownership from prompts typed in a title-only Pi session', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.runtimePaneTitlesByTabId = { 'tab-1': { 1: 'Pi ready' } } + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-pi-title-only') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, 'omp\r') + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(transport.sendInput).toHaveBeenCalledWith('omp\r') + expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'Pi ready') + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'pi', + terminalTitle: 'Pi ready' + }) + }) + + it('lets a new typed omp command override a stale retained done status', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + const now = Date.now() + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'done', + prompt: '', + agentType: 'pi', + paneKey, + terminalTitle: 'Pi ready', + updatedAt: now, + stateStartedAt: now, + stateHistory: [] + } + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-stale-done') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, 'omp\r') + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'OMP ready') + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'omp', + terminalTitle: 'OMP ready' + }) + }) + + it('tracks cursor edits when inferring a typed omp command', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-cursor-edit') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, 'op\x1b[Dm\r') + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(transport.sendInput).toHaveBeenCalledWith('op\x1b[Dm\r') + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'omp', + terminalTitle: 'OMP ready' + }) + }) + + it('tracks delete-key cursor edits when inferring a typed omp command', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-delete-edit') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, 'ommp\x1b[D\x1b[D\x1b[3~\r') + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'omp', + terminalTitle: 'OMP ready' + }) + }) + + it('skips manual agent inference for large paste chunks', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-large-paste') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, `${'x'.repeat(4097)}omp\r`) + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'pi', + terminalTitle: 'Pi ready' + }) + }) + + it('resumes manual agent inference when large paste input is cancelled', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-cancelled-large-paste') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, 'x'.repeat(4097)) + sendTerminalInputThroughPane(pane, '\x03') + sendTerminalInputThroughPane(pane, 'omp\r') + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + if (!onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'omp', + terminalTitle: 'OMP ready' + }) + }) + + it('preserves typed shell ownership through same-chunk command-finished side effects', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } + const pane = createPane(1) + const transport = createMockTransport('remote:web-env-1@@pty-command-finished') + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks?: ConnectCallbacks }) => { + dataCallbackRef.current = callbacks?.onData ?? null + return 'remote:web-env-1@@pty-command-finished' + } + ) + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + sendTerminalInputThroughPane(pane, 'omp\r') + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void) + | undefined + const dataCallback = dataCallbackRef.current + if (!dataCallback || !onTitleChange || !onAgentStatus) { + throw new Error('missing remote PTY callbacks') + } + dataCallback('\x1b]133;D;0\x07') + onTitleChange('Pi ready', 'Pi ready') + onAgentStatus({ + state: 'done', + prompt: '', + agentType: 'pi' + }) + + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + agentType: 'omp', + terminalTitle: 'OMP ready' + }) + }) + it('queues visible bulk output off the synchronous xterm write path', async () => { const { connectPanePty } = await import('./pty-connection') const pane = createPane(1) @@ -974,6 +1543,257 @@ describe('connectPanePty', () => { expect(manager.closePane).not.toHaveBeenCalled() }) + it('disarms input modes and resumes a hibernated agent session on visibility reveal', async () => { + // Regression: agent hibernation suppresses its kill's PTY exit while the + // pane is hidden. Before the wake fix, onExit consumed the suppression and + // permanently latched handledExitPtyId, so revealing the tab left a frozen + // alt-screen frame with mouse-tracking armed and no PTY — a fully inert + // ghost pane (no resume, no input, no selection). + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-pane-2') + transportFactoryQueue.push(transport) + const manager = createManager(1) + // Hibernation only targets hidden background panes, so the exit lands while + // the pane is not visible and the wake must wait for the reveal. + const deps = createDeps({ + consumeSuppressedPtyExit: vi.fn(() => true), + isVisibleRef: { current: false } + }) + const pane = createPane(2) + const paneKey = `tab-1:${leafIdForPane(2)}` + mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] = { + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'claude', + providerSession: { key: 'session_id', id: 'sess-hibernated-1' }, + prompt: 'test prompt', + state: 'done', + capturedAt: 1, + updatedAt: 1, + origin: 'worktree-sleep' + } + + const binding = connectPanePty(pane as never, manager as never, deps as never) as unknown as { + noteVisibilityResume: () => void + dispose: () => void + } + await flushAsyncTicks() + + const onPtyExit = createdTransportOptions[0]?.onPtyExit as ((ptyId: string) => void) | undefined + expect(onPtyExit).toBeTypeOf('function') + // The deferred connect attached this transport to the persisted tab PTY, + // so the hibernation kill's exit must carry that id for the wake guard's + // same-pty check to model reality. + expect((transport.getPtyId as unknown as () => string | null)()).toBe('tab-pty') + const connectCallsBeforeExit = transport.connect.mock.calls.length + onPtyExit?.('tab-pty') + await flushAsyncTicks() + + // The frozen frame's input-eating modes are disarmed at hibernation-exit + // time (mouse tracking / bracketed paste would otherwise swallow clicks). + const writesAfterExit = pane.terminal.write.mock.calls.flat().join('') + expect(writesAfterExit).toContain('\x1b[?1003l') + expect(writesAfterExit).toContain('\x1b[?2004l') + // A hidden pane must not respawn on exit — the wake waits for the reveal. + expect(transport.connect.mock.calls.length).toBe(connectCallsBeforeExit) + + binding.noteVisibilityResume() + await flushAsyncTicks() + + expect(transport.connect.mock.calls.length).toBeGreaterThan(connectCallsBeforeExit) + const resumeConnectOptions = transport.connect.mock.calls.at(-1)?.[0] as + | { command?: string } + | undefined + expect(resumeConnectOptions?.command).toContain('--resume') + expect(resumeConnectOptions?.command).toContain('sess-hibernated-1') + + // The wake is one-shot: a second reveal must not spawn again. + const connectCallsAfterWake = transport.connect.mock.calls.length + binding.noteVisibilityResume() + await flushAsyncTicks() + expect(transport.connect.mock.calls.length).toBe(connectCallsAfterWake) + }) + + it('auto-resumes a hibernated pane when its kill lands after the pane is already revealed', async () => { + // Race: the user reveals the background tab in the window between the + // coordinator confirming the candidate and the kill's exit arriving. The + // reveal's noteVisibilityResume runs before onExit arms the wake, so the + // arm-time foreground check must resume the pane instead of stranding a + // disarmed-but-dead frame until the next hide/reveal. + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-pane-2') + transportFactoryQueue.push(transport) + const manager = createManager(1) + const deps = createDeps({ + consumeSuppressedPtyExit: vi.fn(() => true), + isVisibleRef: { current: true } + }) + const pane = createPane(2) + const paneKey = `tab-1:${leafIdForPane(2)}` + mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] = { + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'claude', + providerSession: { key: 'session_id', id: 'sess-hibernated-2' }, + prompt: 'test prompt', + state: 'done', + capturedAt: 1, + updatedAt: 1, + origin: 'worktree-sleep' + } + + const binding = connectPanePty(pane as never, manager as never, deps as never) as unknown as { + noteVisibilityResume: () => void + dispose: () => void + } + await flushAsyncTicks() + + const onPtyExit = createdTransportOptions[0]?.onPtyExit as ((ptyId: string) => void) | undefined + expect(onPtyExit).toBeTypeOf('function') + // The deferred connect attached this transport to the persisted tab PTY, + // so the hibernation kill's exit must carry that id for the wake guard's + // same-pty check to model reality. + expect((transport.getPtyId as unknown as () => string | null)()).toBe('tab-pty') + const connectCallsBeforeExit = transport.connect.mock.calls.length + onPtyExit?.('tab-pty') + await flushAsyncTicks() + + // No second reveal was needed: the foreground pane resumed its recorded + // session directly from the arm-time wake. + expect(transport.connect.mock.calls.length).toBeGreaterThan(connectCallsBeforeExit) + const resumeConnectOptions = transport.connect.mock.calls.at(-1)?.[0] as + | { command?: string } + | undefined + expect(resumeConnectOptions?.command).toContain('--resume') + expect(resumeConnectOptions?.command).toContain('sess-hibernated-2') + + // Still one-shot: a later reveal must not spawn again. + const connectCallsAfterWake = transport.connect.mock.calls.length + binding.noteVisibilityResume() + await flushAsyncTicks() + expect(transport.connect.mock.calls.length).toBe(connectCallsAfterWake) + }) + + it('invalidates the hibernation wake when another flow rebinds the pane before reveal', async () => { + // Intentional restarts use the same exit suppression as hibernation. When + // one rebinds the pane to a fresh PTY while hidden, its spawn owns the + // pane: the armed wake must be discarded on reveal instead of launching a + // second resume over the restarted session — and discarded means gone, so + // a later death of the rebound PTY cannot revive the stale wake either. + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-pane-2') + transportFactoryQueue.push(transport) + const manager = createManager(1) + const deps = createDeps({ + consumeSuppressedPtyExit: vi.fn(() => true), + isVisibleRef: { current: false } + }) + const pane = createPane(2) + const paneKey = `tab-1:${leafIdForPane(2)}` + mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] = { + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'claude', + providerSession: { key: 'session_id', id: 'sess-hibernated-3' }, + prompt: 'test prompt', + state: 'done', + capturedAt: 1, + updatedAt: 1, + origin: 'worktree-sleep' + } + + const binding = connectPanePty(pane as never, manager as never, deps as never) as unknown as { + noteVisibilityResume: () => void + dispose: () => void + } + await flushAsyncTicks() + + const onPtyExit = createdTransportOptions[0]?.onPtyExit as ((ptyId: string) => void) | undefined + expect(onPtyExit).toBeTypeOf('function') + expect((transport.getPtyId as unknown as () => string | null)()).toBe('tab-pty') + const connectCallsBeforeExit = transport.connect.mock.calls.length + onPtyExit?.('tab-pty') + await flushAsyncTicks() + + // Another flow rebinds the pane to a fresh PTY while it is still hidden. + transport.getPtyId.mockReturnValue('pty-restarted') + binding.noteVisibilityResume() + await flushAsyncTicks() + // The rebound pane keeps its own session: no wake-driven resume spawn. + expect(transport.connect.mock.calls.length).toBe(connectCallsBeforeExit) + + // The rebound PTY later dies without a new sleeping record; the stale wake + // must not fire on the next reveal. + transport.getPtyId.mockReturnValue(null) + binding.noteVisibilityResume() + await flushAsyncTicks() + expect(transport.connect.mock.calls.length).toBe(connectCallsBeforeExit) + }) + + it('records hibernation activity from the core user-input signal, not synthetic onData replies', async () => { + // Regression: xterm auto-replies (focus in/out reports, DA/DSR responses) + // arrive through the same onData stream as typing. Recording them as pane + // input made the hibernation planner treat a pane hidden after its agent + // finished as "input after done" and never hibernate it. + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-pane-2') + transportFactoryQueue.push(transport) + const manager = createManager(1) + const deps = createDeps() + const pane = createPane(2) + let userInputListener: (() => void) | null = null + const userInputDispose = vi.fn() + ;(pane.terminal as unknown as { _core: unknown })._core = { + coreService: { + onUserInput: vi.fn((listener: () => void) => { + userInputListener = listener + return { dispose: userInputDispose } + }) + } + } + + const binding = connectPanePty(pane as never, manager as never, deps as never) as unknown as { + dispose: () => void + } + await flushAsyncTicks() + expect(userInputListener).toBeTypeOf('function') + ;(mockStoreState.recordTerminalInput as ReturnType).mockClear() + + // A focus-out report forwarded to the PTY must not count as activity. + sendTerminalInputThroughPane(pane, '\x1b[O') + expect(mockStoreState.recordTerminalInput).not.toHaveBeenCalled() + // The reply still reaches the shell; only the activity recording is gated. + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[O') + + // Real user input fires the core signal and records activity. + ;(userInputListener as unknown as () => void)() + expect(mockStoreState.recordTerminalInput).toHaveBeenCalledTimes(1) + + binding.dispose() + expect(userInputDispose).toHaveBeenCalled() + }) + + it('falls back to onData hibernation recording when the core user-input signal is unavailable', async () => { + // If an xterm upgrade removes the internal signal, activity recording must + // degrade to the historical onData behavior — never to no tracking at all. + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-pane-2') + transportFactoryQueue.push(transport) + const manager = createManager(1) + const deps = createDeps() + const pane = createPane(2) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks() + ;(mockStoreState.recordTerminalInput as ReturnType).mockClear() + + sendTerminalInputThroughPane(pane, 'x') + expect(mockStoreState.recordTerminalInput).toHaveBeenCalledTimes(1) + }) + it('keeps a fresh split pane mounted when its newborn PTY exits before output or input', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-pane-2') @@ -2300,6 +3120,45 @@ describe('connectPanePty', () => { expect(pane.container.dataset.ptyId).toBeUndefined() }) + it('refits immediately when binding to a PTY with an active mobile-fit override', async () => { + const { setFitOverride } = await import('@/lib/pane-manager/mobile-fit-overrides') + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + const pane = createPane(1) + Object.defineProperty(pane.container, 'getBoundingClientRect', { + configurable: true, + value: () => + ({ + width: 800, + height: 400, + top: 0, + left: 0, + right: 800, + bottom: 400 + }) as DOMRect + }) + ;( + pane.fitAddon as unknown as { proposeDimensions: () => { cols: number; rows: number } } + ).proposeDimensions = vi.fn(() => ({ cols: 120, rows: 40 })) + pane.terminal.resize.mockImplementation((cols: number, rows: number) => { + pane.terminal.cols = cols + pane.terminal.rows = rows + }) + setFitOverride('pty-fit', 'mobile-fit', 49, 20) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as + | ((ptyId: string) => void) + | undefined + expect(onPtySpawn).toBeTypeOf('function') + onPtySpawn?.('pty-fit') + + expect(pane.terminal.resize).toHaveBeenCalledWith(49, 20) + expect(pane.terminal.cols).toBe(49) + expect(pane.terminal.rows).toBe(20) + }) + it('continues post-spawn size reconcile after a transient mobile presence lock', async () => { const frameCallbacks: FrameRequestCallback[] = [] globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { @@ -4007,7 +4866,7 @@ describe('connectPanePty', () => { expect(transport.connect).toHaveBeenCalledTimes(1) }) - it('resets reattach cursor/focus state after daemon snapshot replay without applying the full mode reset', async () => { + 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() transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { @@ -4052,6 +4911,241 @@ describe('connectPanePty', () => { ) }) + it('preserves live modes and injects focus-in after focused agent reattach', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { id: sessionId, snapshot: '\x1b[?1004h\x1b[?25lrestored cursor snapshot' } + } + return null + }) + transportFactoryQueue.push(transport) + setReattachPaneTitle('Cursor Agent') + + const pane = createPane(1) + const textarea = {} as HTMLTextAreaElement + // Why: public xterm modes plus an agent title are the stable signal for a + // live focus-driven TUI; avoid private `_core` field probes. + configureTerminalFocusMode(pane, textarea) + await withMockedDocumentActiveElement(textarea, async () => { + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[I') + expect(pane.terminal.write).toHaveBeenCalledWith( + POST_REPLAY_LIVE_AGENT_REATTACH_RESET, + expect.any(Function) + ) + }) + }) + + 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() + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { id: sessionId, snapshot: '\x1b[?1004h\x1b[?25lrestored cursor snapshot' } + } + return null + }) + transportFactoryQueue.push(transport) + setReattachPaneTitle('Cursor Agent') + + const pane = createPane(1) + const textarea = {} as HTMLTextAreaElement + configureTerminalFocusMode(pane, textarea) + // Why: a different element owns focus, so the reattach must not send a stray + // focus-in to a background pane. + await withMockedDocumentActiveElement({}, async () => { + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + expect(transport.sendInput).not.toHaveBeenCalledWith('\x1b[I') + expect(pane.terminal.write).toHaveBeenCalledWith( + POST_REPLAY_LIVE_AGENT_REATTACH_RESET, + expect.any(Function) + ) + }) + }) + + it('resets stale focus and cursor modes for a focused non-agent shell reattach', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { id: sessionId, snapshot: '\x1b[?1004h\x1b[?25lstale shell snapshot' } + } + return null + }) + transportFactoryQueue.push(transport) + setReattachPaneTitle('zsh') + + const pane = createPane(1) + const textarea = {} as HTMLTextAreaElement + configureTerminalFocusMode(pane, textarea) + await withMockedDocumentActiveElement(textarea, async () => { + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + expect(transport.sendInput).not.toHaveBeenCalledWith('\x1b[I') + expect(pane.terminal.write).toHaveBeenCalledWith( + POST_REPLAY_REATTACH_RESET, + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + POST_REPLAY_LIVE_AGENT_REATTACH_RESET, + expect.any(Function) + ) + }) + }) + + it('does not treat persisted tab launchAgent metadata as a live agent reattach', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { id: sessionId, snapshot: '\x1b[?1004h\x1b[?25lstale shell snapshot' } + } + return null + }) + transportFactoryQueue.push(transport) + // Why: launchAgent is launch ownership metadata that never decays after + // the agent exits; it must not preserve stale modes for the shell left + // behind after an unclean agent death. + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty', title: 'zsh', launchAgent: 'claude' }] + }, + runtimePaneTitlesByTabId: { + 'tab-1': { 1: 'zsh' } + } + } as StoreState + + const pane = createPane(1) + const textarea = {} as HTMLTextAreaElement + configureTerminalFocusMode(pane, textarea) + await withMockedDocumentActiveElement(textarea, async () => { + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + expect(transport.sendInput).not.toHaveBeenCalledWith('\x1b[I') + expect(pane.terminal.write).toHaveBeenCalledWith( + POST_REPLAY_REATTACH_RESET, + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + POST_REPLAY_LIVE_AGENT_REATTACH_RESET, + expect.any(Function) + ) + }) + }) + + 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() + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { id: sessionId, snapshot: '\x1b[?1004h\x1b[?25lstale shell snapshot' } + } + return null + }) + transportFactoryQueue.push(transport) + // Why: broad token matching would classify this ordinary ssh title as an + // agent and preserve stale modes / inject focus-in into a bare shell. + setReattachPaneTitle('ssh devin@host') + + const pane = createPane(1) + const textarea = {} as HTMLTextAreaElement + configureTerminalFocusMode(pane, textarea) + await withMockedDocumentActiveElement(textarea, async () => { + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + expect(transport.sendInput).not.toHaveBeenCalledWith('\x1b[I') + expect(pane.terminal.write).toHaveBeenCalledWith( + POST_REPLAY_REATTACH_RESET, + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + POST_REPLAY_LIVE_AGENT_REATTACH_RESET, + expect.any(Function) + ) + }) + }) + + 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() + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { + id: sessionId, + snapshot: + '\x1b[?1004h\x1b[?25l$ grep -R "Cursor Agent" docs\r\nCursor Agent IME notes\r\n' + } + } + return null + }) + transportFactoryQueue.push(transport) + setReattachPaneTitle('zsh') + + const pane = createPane(1) + const textarea = {} as HTMLTextAreaElement + configureTerminalFocusMode(pane, textarea) + await withMockedDocumentActiveElement(textarea, async () => { + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + expect(transport.sendInput).not.toHaveBeenCalledWith('\x1b[I') + expect(pane.terminal.write).toHaveBeenCalledWith( + POST_REPLAY_REATTACH_RESET, + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + POST_REPLAY_LIVE_AGENT_REATTACH_RESET, + expect.any(Function) + ) + }) + }) + it('resets an already-idle agent cursor again after reattach SIGWINCH repaint', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('tab-pty') @@ -4178,6 +5272,114 @@ describe('connectPanePty', () => { expect(window.api.pty.ackColdRestore).toHaveBeenCalledWith('tab-pty') }) + it('blanks restored scrollback before fresh shell output', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('fresh-pty') + const written: string[] = [] + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + callbacks.onData?.('PS >') + return 'fresh-pty' + }) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, + ptyIdsByTabId: { 'tab-1': [] }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: LEAF_1 }, + activeLeafId: LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: {} + } + } + } as StoreState + + const pane = createPane(1) + pane.terminal.rows = 4 + pane.terminal.cols = 20 + pane.terminal.write = vi.fn((data: string, callback?: () => void) => { + written.push(data) + callback?.() + }) + const manager = createManager(1) + const deps = createDeps({ + restoredViewportBlankingPanesRef: { current: new Set([1]) } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + const blankViewport = buildFreshShellViewportBlankingSequence(4) + expect(written).toContain(blankViewport) + expect(written.indexOf(blankViewport)).toBeLessThan(written.indexOf('PS >')) + + const rendered = await renderHeadlessTerminalState( + ['old TUI row with a long tail\r\nold TUI row two', blankViewport, 'PS >'], + 20, + 4 + ) + expect(rendered.baseY).toBeGreaterThan(0) + expect(rendered.allLines.some((line) => line.includes('old TUI row'))).toBe(true) + expect(rendered.visibleLines).toEqual(['PS >', '', '', '']) + }) + + it('cold-restores scrollback then blanks the viewport without erasing scrollback', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('fresh-pty') + const written: string[] = [] + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { + id: 'fresh-pty', + coldRestore: { scrollback: 'cold TUI row one\r\ncold TUI row two', cwd: '/tmp/wt-1' } + } + } + return 'fresh-pty' + }) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty' }] + } + } as StoreState + + const pane = createPane(1) + pane.terminal.rows = 4 + pane.terminal.cols = 20 + pane.terminal.write = vi.fn((data: string, callback?: () => void) => { + written.push(data) + callback?.() + }) + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'lost-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + const blankViewport = buildFreshShellViewportBlankingSequence(4) + expect(written).not.toContain('\x1b[2J\x1b[3J\x1b[H') + expect(written).toEqual( + expect.arrayContaining([ + 'cold TUI row one\r\ncold TUI row two', + POST_REPLAY_MODE_RESET, + blankViewport + ]) + ) + expect(written.indexOf('cold TUI row one\r\ncold TUI row two')).toBeLessThan( + written.indexOf(blankViewport) + ) + + const rendered = await renderHeadlessTerminalState([...written, 'PS >'], 20, 4) + expect(rendered.baseY).toBeGreaterThan(0) + expect(rendered.allLines.some((line) => line.includes('cold TUI row'))).toBe(true) + expect(rendered.visibleLines).toEqual(['PS >', '', '', '']) + }) + it('resumes the provider agent session when daemon reattach cold-restores a fresh shell', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('fresh-pty') @@ -4410,6 +5612,285 @@ describe('connectPanePty', () => { expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledWith(paneKey) }) + // #4557: an agent terminal that can't be resumed should come back as a fresh + // agent (prior agent, else default) instead of a blank shell. + const findReattachConnect = (transport: ReturnType) => { + const call = transport.connect.mock.calls.find( + (args) => (args[0] as { sessionId?: string })?.sessionId === 'lost-pty' + ) + return call?.[0] as + | { + command?: string + launchAgent?: string + launchConfig?: { + agentCommand?: string + agentArgs?: string + agentEnv?: Record + } + env?: Record + } + | undefined + } + + const mountColdRestore = async ( + transport: ReturnType, + depsOverrides: Record = {} + ) => { + const { connectPanePty } = await import('./pty-connection') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { id: 'fresh-pty', coldRestore: { scrollback: 'cold-payload', cwd: '/tmp/wt-1' } } + } + return 'fresh-pty' + }) + transportFactoryQueue.push(transport) + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'lost-pty' }, + ...depsOverrides + }) + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + await new Promise((resolve) => setTimeout(resolve, 70)) + return deps + } + + it('cold-restores a fresh agent from the sleeping record when its session is unresumable', async () => { + const transport = createMockTransport('fresh-pty') + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty' }] }, + settings: { ...mockStoreState.settings, agentCmdOverrides: {} }, + agentStatusByPaneKey: {}, + sleepingAgentSessionsByPaneKey: { + // Agent known, but no resumable provider session id. + [paneKey]: { + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'codex', + prompt: 'finish the task', + state: 'working', + capturedAt: 1, + updatedAt: 1 + } + } + } as StoreState + + await mountColdRestore(transport) + + const reattach = findReattachConnect(transport) + expect(reattach?.command).toMatch(/^codex /) + expect(reattach?.command).not.toContain('resume') + expect(reattach?.launchAgent).toBe('codex') + expect(reattach?.env?.ORCA_AGENT_LAUNCH_TOKEN).toMatch(new RegExp(`^${UUID_RE}$`)) + }) + + it('cold-restores a fresh agent with the sleeping record launch config when resume is unavailable', async () => { + const transport = createMockTransport('fresh-pty') + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty' }] }, + settings: { + ...mockStoreState.settings, + agentCmdOverrides: { codex: 'current-codex' }, + agentDefaultArgs: { codex: '--current-args' } + }, + agentStatusByPaneKey: {}, + sleepingAgentSessionsByPaneKey: { + [paneKey]: { + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'codex', + prompt: 'finish the task', + state: 'working', + capturedAt: 1, + updatedAt: 1, + launchConfig: { + agentCommand: 'sleeping-codex --stored-args', + agentArgs: '--stored-args', + agentEnv: { CODEX_HOME: '/tmp/orca-stored-codex' } + } + } + } + } as StoreState + + await mountColdRestore(transport) + + const reattach = findReattachConnect(transport) + expect(reattach?.command).toBe('sleeping-codex --stored-args') + expect(reattach?.launchAgent).toBe('codex') + expect(reattach?.launchConfig).toMatchObject({ + agentCommand: 'sleeping-codex --stored-args', + agentArgs: '--stored-args', + agentEnv: { CODEX_HOME: '/tmp/orca-stored-codex' } + }) + expect(reattach?.env).toMatchObject({ + CODEX_HOME: '/tmp/orca-stored-codex', + ORCA_AGENT_LAUNCH_TOKEN: expect.stringMatching(new RegExp(`^${UUID_RE}$`)) + }) + }) + + it('uses the known SSH remote platform when fresh-launching an unresumable agent', async () => { + const transport = createMockTransport('fresh-pty') + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty', launchAgent: 'claude-agent-teams' }] + }, + repos: [{ id: 'repo1', connectionId: 'ssh-win' }], + sshConnectionStates: new Map([['ssh-win', { status: 'connected', remotePlatform: 'win32' }]]), + settings: { ...mockStoreState.settings, agentCmdOverrides: {} }, + agentStatusByPaneKey: {}, + sleepingAgentSessionsByPaneKey: {} + } as StoreState + + await mountColdRestore(transport) + + const reattach = findReattachConnect(transport) + expect(reattach?.command).toMatch(/^orca\.cmd claude-teams/) + expect(reattach?.command).not.toContain('orca-ide') + expect(reattach?.launchAgent).toBe('claude-agent-teams') + }) + + it('cold-restores the prior agent from the persisted tab launchAgent after a restart', async () => { + const transport = createMockTransport('fresh-pty') + mockStoreState = { + ...mockStoreState, + // After a restart there is no live status nor sleeping record — only the + // persisted tab.launchAgent tells us this terminal ran an agent. + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty', launchAgent: 'claude' }] }, + settings: { ...mockStoreState.settings, agentCmdOverrides: {} }, + agentStatusByPaneKey: {}, + sleepingAgentSessionsByPaneKey: {} + } as StoreState + + await mountColdRestore(transport) + + const reattach = findReattachConnect(transport) + expect(reattach?.command).toContain('claude') + expect(reattach?.command).not.toContain('resume') + expect(reattach?.launchAgent).toBe('claude') + }) + + it('cold-restores the default agent when an agent terminal has no recoverable agent', async () => { + const transport = createMockTransport('fresh-pty') + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty' }] }, + settings: { ...mockStoreState.settings, agentCmdOverrides: {}, defaultTuiAgent: 'codex' }, + agentStatusByPaneKey: {}, + // A record exists (it was an agent terminal) but carries no usable agent id. + sleepingAgentSessionsByPaneKey: { + [paneKey]: { paneKey, tabId: 'tab-1', worktreeId: 'wt-1', capturedAt: 1, updatedAt: 1 } + } + } as StoreState + + await mountColdRestore(transport) + + const reattach = findReattachConnect(transport) + expect(reattach?.command).toMatch(/^codex /) + expect(reattach?.command).not.toContain('resume') + expect(reattach?.launchAgent).toBe('codex') + }) + + it('does not cold-restore a fresh agent from only a completed live status row', async () => { + const transport = createMockTransport('fresh-pty') + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty' }] }, + settings: { ...mockStoreState.settings, agentCmdOverrides: {}, defaultTuiAgent: 'codex' }, + agentStatusByPaneKey: { + [paneKey]: { + agentType: 'codex', + paneKey, + prompt: 'done task', + state: 'done', + stateHistory: [], + stateStartedAt: 1, + updatedAt: 1 + } + }, + sleepingAgentSessionsByPaneKey: {} + } as StoreState + + await mountColdRestore(transport) + + const reattach = findReattachConnect(transport) + expect(reattach?.command).toBeUndefined() + expect(reattach?.launchAgent).toBeUndefined() + }) + + it('ignores malformed sleeping launch config when a live resumable status exists', async () => { + const transport = createMockTransport('fresh-pty') + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty' }] }, + settings: { ...mockStoreState.settings, agentCmdOverrides: {} }, + agentStatusByPaneKey: { + [paneKey]: { + agentType: 'codex', + paneKey, + prompt: 'live task', + providerSession: { key: 'session_id', id: 'live-session' }, + state: 'working', + stateHistory: [], + stateStartedAt: 1, + updatedAt: 1 + } + }, + sleepingAgentSessionsByPaneKey: { + [paneKey]: { + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'codex', + prompt: 'malformed sleeping task', + state: 'working', + capturedAt: 1, + updatedAt: 1, + launchConfig: { + agentCommand: 'malformed-sleeping-codex', + agentArgs: '', + agentEnv: {} + } + } + } + } as StoreState + + await mountColdRestore(transport) + + const reattach = findReattachConnect(transport) + expect(reattach?.command).toContain("'resume' 'live-session'") + expect(reattach?.command).not.toBe('malformed-sleeping-codex') + expect(reattach?.launchAgent).toBe('codex') + }) + + it('leaves a plain shell blank on cold restore (no prior agent, no default)', async () => { + const transport = createMockTransport('fresh-pty') + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty' }] }, + settings: { ...mockStoreState.settings, agentCmdOverrides: {} }, + agentStatusByPaneKey: {}, + sleepingAgentSessionsByPaneKey: {} + } as StoreState + + await mountColdRestore(transport) + + const reattach = findReattachConnect(transport) + expect(reattach?.command).toBeUndefined() + expect(reattach?.launchAgent).toBeUndefined() + }) + it('resumes from an unambiguous legacy sleeping record when cold-restoring a preserved pane', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('fresh-pty') @@ -5374,6 +6855,269 @@ describe('connectPanePty', () => { } }) + it('schedules WebGL atlas recovery after hidden synchronized output parses', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + + connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ + isVisibleRef: { current: false } + }) as never + ) + await flushAsyncTicks(6) + + vi.useFakeTimers() + try { + const startChunk = '\x1b[?2026h' + const plainRowChunk = '| hidden Claude row |\r\n' + const endChunk = '\x1b[?2026l' + + capturedDataCallback.current?.(startChunk) + capturedDataCallback.current?.(plainRowChunk) + capturedDataCallback.current?.(endChunk) + + expect(writes).toEqual([]) + vi.advanceTimersByTime(50) + + expect(writes).toEqual([`${startChunk}${plainRowChunk}${endChunk}`]) + expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() + + parseCallbacks[0]?.() + + expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(3) + } finally { + vi.useRealTimers() + } + }) + + it('recognizes hidden synchronized output markers split across PTY chunks', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + + connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ + isVisibleRef: { current: false } + }) as never + ) + await flushAsyncTicks(6) + + vi.useFakeTimers() + try { + capturedDataCallback.current?.('\x1b[?202') + capturedDataCallback.current?.('6hbody row\r\n') + capturedDataCallback.current?.('tail\x1b[?20') + capturedDataCallback.current?.('26l') + + vi.advanceTimersByTime(50) + + expect(writes.join('')).toBe('\x1b[?2026hbody row\r\ntail\x1b[?2026l') + expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() + + parseCallbacks[0]?.() + + expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(3) + } finally { + vi.useRealTimers() + } + }) + + it('does not schedule hidden atlas recovery for ordinary rich text or metadata output', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const { parseCallbacks } = captureCallbackTerminalWrites(pane) + + connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ + isVisibleRef: { current: false } + }) as never + ) + await flushAsyncTicks(6) + + vi.useFakeTimers() + try { + capturedDataCallback.current?.('plain hidden emoji 😀 and CJK 没改什么\r\n') + capturedDataCallback.current?.('\x1b[48;2;52;52;52mcolored shell text\x1b[0m\r\n') + capturedDataCallback.current?.('\x1b]0;hidden title\x07\x1b]133;A\x07') + + vi.advanceTimersByTime(50) + for (const callback of parseCallbacks) { + callback() + } + + expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('schedules hidden atlas recovery for high-confidence TUI redraw controls', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const { parseCallbacks } = captureCallbackTerminalWrites(pane) + + connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ + isVisibleRef: { current: false } + }) as never + ) + await flushAsyncTicks(6) + + vi.useFakeTimers() + try { + capturedDataCallback.current?.('\x1b[2J\x1b[Hredrawn hidden table\x1b[K') + + vi.advanceTimersByTime(50) + expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() + + parseCallbacks[0]?.() + + expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + + it('advances hidden rewrite state when synchronized output already requests recovery', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + + connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ + isVisibleRef: { current: false } + }) as never + ) + await flushAsyncTicks(6) + + vi.useFakeTimers() + try { + capturedDataCallback.current?.('prompt rewrite\r') + vi.advanceTimersByTime(50) + expect(writes).toEqual(['prompt rewrite\r']) + parseCallbacks.shift()?.() + expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() + + capturedDataCallback.current?.('\x1b[?2026hredraw frame\x1b[?2026l') + vi.advanceTimersByTime(50) + expect(writes).toEqual(['prompt rewrite\r', '\x1b[?2026hredraw frame\x1b[?2026l']) + parseCallbacks.shift()?.() + expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1) + scheduleTerminalWebglAtlasRecovery.mockClear() + + capturedDataCallback.current?.('plain after frame') + vi.advanceTimersByTime(50) + parseCallbacks.shift()?.() + + expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('resets hidden synchronized state when hidden renderer output is skipped', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: ((data: string, meta?: { background?: boolean }) => void) | null + } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + const isVisibleRef = { current: false } + + connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ + isVisibleRef + }) as never + ) + await flushAsyncTicks(6) + + vi.useFakeTimers() + try { + capturedDataCallback.current?.('\x1b[?2026h') + vi.advanceTimersByTime(50) + parseCallbacks.shift()?.() + expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1) + scheduleTerminalWebglAtlasRecovery.mockClear() + writes.length = 0 + + isVisibleRef.current = true + ;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'alternate' + capturedDataCallback.current?.('\x1b[?2026l', { background: true }) + expect(writes).toEqual([]) + + isVisibleRef.current = false + ;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'normal' + capturedDataCallback.current?.('plain after skipped close\r\n') + vi.advanceTimersByTime(50) + parseCallbacks.shift()?.() + + expect(writes).toEqual(['plain after skipped close\r\n']) + expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + it('queues visible split-pane PTY bytes when the pane is not active', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport() @@ -6435,6 +8179,261 @@ describe('connectPanePty', () => { binding.dispose() }) + it('does not apply stale background Codex query chunks after hidden snapshot restore', async () => { + const visibilityChangeHandler: { current: (() => void) | null } = { current: null } + ;(globalThis as { document?: Document }).document = { + visibilityState: 'visible', + addEventListener: vi.fn((type: string, listener: EventListenerOrEventListenerObject) => { + if (type === 'visibilitychange') { + visibilityChangeHandler.current = listener as () => void + } + }), + removeEventListener: vi.fn() + } as unknown as Document + + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: + | (( + data: string, + meta?: { seq?: number; rawLength?: number; background?: boolean } + ) => void) + | null + } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + + const hiddenFrame = '\r\x1b[2KWorking hidden' + const staleQueryFrame = '\r\x1b[2KWorking stale\x1b[6n' + const currentFrame = '\r\x1b[2KWorking current' + const staleSeq = hiddenFrame.length + staleQueryFrame.length + const currentSeq = staleSeq + currentFrame.length + // The main emulator has already parsed every frame, so the snapshot covers + // the query frame that is still in flight on the pty:data channel. + getMainBufferSnapshot.mockResolvedValue({ + data: currentFrame, + cols: 80, + rows: 8, + seq: currentSeq + }) + + const pane = createPane(1) + pane.terminal.cols = 80 + pane.terminal.rows = 8 + const { writes } = captureCallbackTerminalWrites(pane) + const manager = createManager(1) + const deps = createDeps({ + isVisibleRef: { current: false }, + startup: { command: 'codex' } + }) + const binding = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.(hiddenFrame, { + seq: hiddenFrame.length, + rawLength: hiddenFrame.length + }) + ;(deps.isVisibleRef as { current: boolean }).current = true + visibilityChangeHandler.current?.() + await flushAsyncTicks(20) + + // The in-flight chunks arrive in channel order after the restore already + // rebuilt the buffer from the newer snapshot. + vi.useFakeTimers() + try { + capturedDataCallback.current?.(staleQueryFrame, { + seq: staleSeq, + rawLength: staleQueryFrame.length, + background: true + }) + capturedDataCallback.current?.(currentFrame, { + seq: currentSeq, + rawLength: currentFrame.length, + background: true + }) + vi.advanceTimersByTime(50) + await flushAsyncTicks(6) + } finally { + vi.useRealTimers() + } + + const rendererBuffer = await renderHeadlessBuffer(writes, 80, 8) + const referenceBuffer = await renderHeadlessBuffer( + [hiddenFrame, staleQueryFrame, currentFrame], + 80, + 8 + ) + + expect(writes).not.toContain(staleQueryFrame) + expect(rendererBuffer).toEqual(referenceBuffer) + binding.dispose() + }) + + it('restores hidden Codex output after a suppressed exit revives the same ptyId with a restarted seq counter', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: + | (( + data: string, + meta?: { seq?: number; rawLength?: number; background?: boolean } + ) => void) + | null + } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + + const hiddenFrame = '\r\x1b[2KWorking hidden' + const visibleFrame = '\r\x1b[2KWorking now' + const hiddenSeq = hiddenFrame.length + const visibleSeq = hiddenSeq + visibleFrame.length + // Why 3 extra bytes: the main emulator typically runs ahead of the chunks + // the renderer has received, so the snapshot seq exceeds the channel seq. + const preExitSnapshotSeq = visibleSeq + 3 + getMainBufferSnapshot.mockResolvedValue({ + data: visibleFrame, + cols: 80, + rows: 8, + seq: preExitSnapshotSeq + }) + + const pane = createPane(1) + pane.terminal.cols = 80 + pane.terminal.rows = 8 + const { writes } = captureCallbackTerminalWrites(pane) + const manager = createManager(1) + const deps = createDeps({ + isVisibleRef: { current: false }, + startup: { command: 'codex' }, + consumeSuppressedPtyExit: vi.fn(() => true) + }) + const binding = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.(hiddenFrame, { seq: hiddenSeq, rawLength: hiddenFrame.length }) + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.(visibleFrame, { + seq: visibleSeq, + rawLength: visibleFrame.length + }) + await flushAsyncTicks(20) + + const onPtyExit = createdTransportOptions[0]?.onPtyExit as ((ptyId: string) => void) | undefined + expect(onPtyExit).toBeTypeOf('function') + onPtyExit?.('pty-id') + + // Revived session: same ptyId, main seq counter restarted. The first + // revived chunk lands below the pre-exit snapshot seq but above the last + // channel seq, so only the exit reset can stop it being judged covered. + ;(deps.isVisibleRef as { current: boolean }).current = false + const revivedHiddenFrame = '\r\x1b[2KWorking revived' + capturedDataCallback.current?.(revivedHiddenFrame, { + seq: preExitSnapshotSeq - 1, + rawLength: revivedHiddenFrame.length + }) + + const revivedSnapshotFrame = '\r\x1b[2KREVIVED SNAPSHOT' + getMainBufferSnapshot.mockResolvedValue({ + data: revivedSnapshotFrame, + cols: 80, + rows: 8, + seq: preExitSnapshotSeq - 1 + }) + ;(deps.isVisibleRef as { current: boolean }).current = true + const revivedVisibleFrame = '\r\x1b[2Kafter revive' + capturedDataCallback.current?.(revivedVisibleFrame, { + seq: preExitSnapshotSeq - 1 + revivedVisibleFrame.length, + rawLength: revivedVisibleFrame.length + }) + await flushAsyncTicks(20) + + const written = writes.join('') + expect(written).toContain('REVIVED SNAPSHOT') + expect(written).toContain('after revive') + binding.dispose() + }) + + it('restores hidden Codex output when the pty seq counter restarts without an observed exit', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: + | (( + data: string, + meta?: { seq?: number; rawLength?: number; background?: boolean } + ) => void) + | null + } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + + const pane = createPane(1) + pane.terminal.cols = 80 + pane.terminal.rows = 8 + const { writes } = captureCallbackTerminalWrites(pane) + const manager = createManager(1) + const deps = createDeps({ + isVisibleRef: { current: true }, + startup: { command: 'codex' } + }) + const binding = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + const establishedFrame = '\r\x1b[2KWorking established' + capturedDataCallback.current?.(establishedFrame, { + seq: 50_000, + rawLength: establishedFrame.length + }) + await flushAsyncTicks(6) + + // Main lost the pty silently (no exit reached the renderer) and the seq + // counter restarted: the channel seq regression must invalidate the old + // high-water mark instead of covering the new stream. + ;(deps.isVisibleRef as { current: boolean }).current = false + const revivedHiddenFrame = '\r\x1b[2KWorking revived' + capturedDataCallback.current?.(revivedHiddenFrame, { + seq: revivedHiddenFrame.length, + rawLength: revivedHiddenFrame.length + }) + + const revivedSnapshotFrame = '\r\x1b[2KREVIVED SNAPSHOT2' + getMainBufferSnapshot.mockResolvedValue({ + data: revivedSnapshotFrame, + cols: 80, + rows: 8, + seq: revivedHiddenFrame.length + }) + ;(deps.isVisibleRef as { current: boolean }).current = true + const revivedVisibleFrame = '\r\x1b[2Kafter revive' + capturedDataCallback.current?.(revivedVisibleFrame, { + seq: revivedHiddenFrame.length + revivedVisibleFrame.length, + rawLength: revivedVisibleFrame.length + }) + await flushAsyncTicks(20) + + expect(writes.join('')).toContain('REVIVED SNAPSHOT2') + binding.dispose() + }) + it('writes ordinary hidden output live instead of proactively restoring a snapshot', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') @@ -6751,7 +8750,7 @@ describe('connectPanePty', () => { disposable.dispose() }) - it('preserves scrollback by skipping the destructive clear when restoring an alternate-screen snapshot', async () => { + it('clears only the alternate screen when restoring an alternate-screen snapshot', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') const capturedDataCallback: { @@ -6800,6 +8799,19 @@ describe('connectPanePty', () => { '\x1b[2J\x1b[3J\x1b[H', expect.any(Function) ) + // Why: the snapshot's ?1049h no-ops on an already-alt pane and serialized + // frames skip blank cells, so the pre-hide frame bleeds through unless the + // alt screen is cleared (without \x1b[3J) before the snapshot paints. + expect(pane.terminal.write).toHaveBeenCalledWith( + '\x1b[?1049h\x1b[2J\x1b[H', + expect.any(Function) + ) + const writes = (pane.terminal.write as ReturnType).mock.calls.map( + (call) => call[0] + ) + expect(writes.indexOf('\x1b[?1049h\x1b[2J\x1b[H')).toBeLessThan( + writes.indexOf('altscreen-snapshot\r\n') + ) expect(pane.terminal.write).toHaveBeenCalledWith('altscreen-snapshot\r\n', expect.any(Function)) disposable.dispose() }) @@ -7981,6 +9993,82 @@ describe('connectPanePty', () => { disposable.dispose() }) + it('preserves live agent modes when queued replay data carries the Cursor Agent screen', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const transport = createMockTransport('remote:env-1@@terminal-1') + const capturedReplayCallback: { + current: ((data: string, meta?: { clearBeforeReplay?: boolean }) => void) | null + } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedReplayCallback.current = callbacks.onReplayData ?? null + return { id: 'remote:env-1@@terminal-1', replay: '' } + }) + transportFactoryQueue.push(transport) + setReattachPaneTitle('renamed shell') + + const pane = createPane(1) + const textarea = {} as HTMLTextAreaElement + configureTerminalFocusMode(pane, textarea) + const manager = createManager(1) + const deps = createDeps() + const disposable = await withMockedDocumentActiveElement(textarea, async () => { + const connection = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedReplayCallback.current?.(ANSI_POSITIONED_CURSOR_AGENT_REATTACH_SCREEN) + await flushAsyncTicks(12) + + expect(pane.terminal.write).toHaveBeenCalledWith( + POST_REPLAY_LIVE_AGENT_REATTACH_RESET, + expect.any(Function) + ) + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[I') + return connection + }) + disposable.dispose() + }) + + it('downgrades a scrollback-only Cursor Agent signal when the parsed viewport shows a shell', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const transport = createMockTransport('remote:env-1@@terminal-1') + const capturedReplayCallback: { + current: ((data: string, meta?: { clearBeforeReplay?: boolean }) => void) | null + } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedReplayCallback.current = callbacks.onReplayData ?? null + return { id: 'remote:env-1@@terminal-1', replay: '' } + }) + transportFactoryQueue.push(transport) + setReattachPaneTitle('renamed shell') + + const pane = createPane(1) + // Why: an inspectable buffer whose visible rows carry no Cursor Agent + // screen models a shell foreground after a dead run left its screen in + // scrollback. + Object.assign(pane.terminal.buffer.active, { + cursorX: 2, + getLine: () => undefined + }) + const textarea = {} as HTMLTextAreaElement + configureTerminalFocusMode(pane, textarea) + const manager = createManager(1) + const deps = createDeps() + const disposable = await withMockedDocumentActiveElement(textarea, async () => { + const connection = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedReplayCallback.current?.(ANSI_POSITIONED_CURSOR_AGENT_REATTACH_SCREEN) + await flushAsyncTicks(12) + + expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[?1004l', expect.any(Function)) + expect(transport.sendInput).not.toHaveBeenCalledWith('\x1b[I') + return connection + }) + disposable.dispose() + }) + it('does not clear restored scrollback when eager metadata replay opts out', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') @@ -8262,6 +10350,341 @@ describe('connectPanePty', () => { expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1) }) + it('schedules WebGL atlas recovery for Vim-style foreground alternate-screen redraws', async () => { + const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + ;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'alternate' + const refresh = vi.fn() + let parseCallback: (() => void) | undefined + const terminal = pane.terminal as typeof pane.terminal & { + _core?: { refresh: typeof refresh } + } + terminal._core = { refresh } + terminal.write = vi.fn((_data: string, callback?: () => void) => { + parseCallback = callback + }) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.( + '\x1b[2J\x1b[H{"name":"eepo"}\r\n\x1b[2;1H{"name":"expo"}\x1b[K' + ) + + expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() + parseCallback?.() + expect(refresh).toHaveBeenCalledWith(0, 39, true) + expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1) + } finally { + restoreNavigator() + } + }) + + it('schedules WebGL atlas recovery when a foreground rewrite enters alternate screen', async () => { + const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const refresh = vi.fn() + let parseCallback: (() => void) | undefined + const terminal = pane.terminal as typeof pane.terminal & { + _core?: { refresh: typeof refresh } + } + terminal._core = { refresh } + terminal.write = vi.fn((_data: string, callback?: () => void) => { + parseCallback = callback + }) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\x1b[?1049h\x1b[2J\x1b[HVim package.json') + + expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() + // Why: xterm switches to the alternate buffer while parsing the chunk; + // the write callback observes the post-parse buffer state. + ;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'alternate' + parseCallback?.() + expect(refresh).toHaveBeenCalledWith(0, 39, true) + expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1) + } finally { + restoreNavigator() + } + }) + + it('schedules WebGL atlas recovery when the alternate-screen enter sequence splits across chunks', async () => { + const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const refresh = vi.fn() + let parseCallback: (() => void) | undefined + const terminal = pane.terminal as typeof pane.terminal & { + _core?: { refresh: typeof refresh } + } + terminal._core = { refresh } + terminal.write = vi.fn((_data: string, callback?: () => void) => { + parseCallback = callback + }) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(6) + + // Why: PTY reads split CSI sequences at arbitrary byte boundaries (real + // vim sessions split cursor moves at 1024-byte chunk edges), so the + // enter sequence itself can straddle two onData chunks. + capturedDataCallback.current?.('\x1b[?104') + parseCallback?.() + expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() + + capturedDataCallback.current?.('9h\x1b[2J\x1b[H~\x1b[K') + ;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'alternate' + parseCallback?.() + expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1) + } finally { + restoreNavigator() + } + }) + + it('schedules WebGL atlas recovery when a foreground rewrite leaves alternate screen', async () => { + const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + ;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'alternate' + const refresh = vi.fn() + let parseCallback: (() => void) | undefined + const terminal = pane.terminal as typeof pane.terminal & { + _core?: { refresh: typeof refresh } + } + terminal._core = { refresh } + terminal.write = vi.fn((_data: string, callback?: () => void) => { + parseCallback = callback + }) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(6) + + // Vim's final frame erases the status line and restores the normal + // buffer in one chunk; the atlas must still rebuild for the restored + // prompt even though the post-parse buffer is back to normal. + capturedDataCallback.current?.('\x1b[34;1H\x1b[K\x1b[34;1H\x1b[?1049l\x1b[?25h') + ;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'normal' + parseCallback?.() + expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1) + } finally { + restoreNavigator() + } + }) + + it('schedules WebGL atlas recovery when one chunk enters and exits alternate screen', async () => { + const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + let bufferChangeListener: (() => void) | undefined + ;( + pane.terminal.buffer as { + onBufferChange?: (listener: () => void) => { dispose: () => void } + } + ).onBufferChange = (listener) => { + bufferChangeListener = listener + return { dispose: vi.fn() } + } + const refresh = vi.fn() + let parseCallback: (() => void) | undefined + const terminal = pane.terminal as typeof pane.terminal & { + _core?: { refresh: typeof refresh } + } + terminal._core = { refresh } + terminal.write = vi.fn((_data: string, callback?: () => void) => { + parseCallback = callback + }) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(6) + + // A coalesced backlog flush can parse a whole enter -> draw -> exit TUI + // interaction in one write, netting buffer type back to 'normal'; the + // buffer-switch count is what still marks it as alternate-screen work. + capturedDataCallback.current?.('\x1b[?1049h\x1b[2J\x1b[Hpager frame\x1b[K\x1b[?1049l') + bufferChangeListener?.() + bufferChangeListener?.() + parseCallback?.() + expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1) + } finally { + restoreNavigator() + } + }) + + it('schedules WebGL atlas recovery for real captured Vim redraw chunks split mid-sequence', async () => { + const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + ;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'alternate' + const refresh = vi.fn() + let parseCallback: (() => void) | undefined + const terminal = pane.terminal as typeof pane.terminal & { + _core?: { refresh: typeof refresh } + } + terminal._core = { refresh } + terminal.write = vi.fn((_data: string, callback?: () => void) => { + parseCallback = callback + }) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(6) + + // Captured from a real `vim package.json` session: a 1024-byte PTY read + // boundary cuts the cursor move \x1b[30;5H into "\x1b[30" + ";5H". + capturedDataCallback.current?.('"rules": {\x1b[29;15H\x1b[K\x1b[30') + parseCallback?.() + expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1) + + capturedDataCallback.current?.( + ';5H "js-combine-iterations": "off"\r\n }\x1b[31;6H\x1b[K\x1b[33;1H\x1b[?25h' + ) + parseCallback?.() + expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(2) + } finally { + restoreNavigator() + } + }) + + it('does not schedule WebGL atlas recovery for ordinary foreground shell rewrites', async () => { + const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const refresh = vi.fn() + let parseCallback: (() => void) | undefined + const terminal = pane.terminal as typeof pane.terminal & { + _core?: { refresh: typeof refresh } + } + terminal._core = { refresh } + terminal.write = vi.fn((_data: string, callback?: () => void) => { + parseCallback = callback + }) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\r\x1b[Korca % npm test') + + parseCallback?.() + expect(refresh).toHaveBeenCalledWith(0, 39, true) + expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() + } finally { + restoreNavigator() + } + }) + + it('does not schedule WebGL atlas recovery for plain synchronized foreground frames', async () => { + const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + let parseCallback: (() => void) | undefined + pane.terminal.write = vi.fn((_data: string, callback?: () => void) => { + parseCallback = callback + }) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\x1b[?2026hplain claude frame\x1b[?2026l') + + expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() + parseCallback?.() + expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() + } finally { + restoreNavigator() + } + }) + it('forces a viewport refresh when foreground background SGR is split across PTY chunks', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport() @@ -11428,6 +13851,83 @@ describe('connectPanePty', () => { expect(manager.closePane).toHaveBeenCalledWith(2) }) + it('closes a split pane when targeted liveness says its local session is missing', async () => { + const { connectPanePty } = await import('./pty-connection') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + const transport = createMockTransport('pty-pane-2') + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return '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')]]) } + }) + const hasPty = vi.fn(async () => false) + + const binding = connectPanePty(createPane(2) as never, manager as never, deps as never) + capturedDataCallback.current?.('shell prompt') + + binding.reconcileIfSessionMissing(hasPty) + await flushAsyncTicks() + + expect(hasPty).toHaveBeenCalledWith('pty-pane-2') + expect(manager.closePane).toHaveBeenCalledWith(2) + }) + + it('does not close when targeted liveness is live or unknown', async () => { + 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')]]) } + }) + + const binding = connectPanePty(createPane(2) as never, manager as never, deps as never) + + binding.reconcileIfSessionMissing(vi.fn(async () => true)) + binding.reconcileIfSessionMissing(vi.fn(async () => null)) + await flushAsyncTicks() + + expect(manager.closePane).not.toHaveBeenCalled() + expect(deps.onPtyExitRef.current).not.toHaveBeenCalled() + }) + + it('does not apply a stale targeted liveness result after reattach', async () => { + const { connectPanePty } = await import('./pty-connection') + let resolveHasPty: (value: boolean) => void = () => { + throw new Error('hasPty promise resolver was not initialized') + } + const hasPty = vi.fn( + () => + new Promise((resolve) => { + resolveHasPty = resolve + }) + ) + 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')]]) } + }) + + const binding = connectPanePty(createPane(2) as never, manager as never, deps as never) + binding.reconcileIfSessionMissing(hasPty) + transport.getPtyId.mockReturnValue('pty-pane-2-reattached') + resolveHasPty(false) + await flushAsyncTicks() + + expect(manager.closePane).not.toHaveBeenCalled() + expect(deps.onPtyExitRef.current).not.toHaveBeenCalled() + }) + it('does NOT tear down a newborn pane when the snapshot was requested before it bound', async () => { // Why (regression): a snapshot requested before the spawn bound cannot // prove the fresh ptyId dead. Drives the REAL reconcile body to prove the @@ -11728,13 +14228,13 @@ describe('connectPanePty', () => { }) }) - describe('input liveness re-check IPC gating (perf)', () => { + describe('terminal input liveness IPC gating (perf)', () => { // Why (perf regression guard): listSessions() is a renderer→main→daemon - // round-trip. The input-driven liveness re-check must fire at most once per - // resume window, never once per keystroke, or every healthy local pane puts - // a process-enumeration round-trip on the typing hot path. + // round-trip over every live session. Terminal input must never start that + // enumeration; visibility reconcile and daemon exit events own liveness. async function connectActivePaneWithInput(): Promise<{ binding: { noteVisibilityResume: () => void } + transport: MockTransport typeKeystroke: (data?: string) => void }> { const { connectPanePty } = await import('./pty-connection') @@ -11751,6 +14251,7 @@ describe('connectPanePty', () => { } return { binding, + transport, // Drives the real xterm onData (terminal input) handler. typeKeystroke: (data = 'a') => sendTerminalInputThroughPane(pane, data) } @@ -11768,7 +14269,7 @@ describe('connectPanePty', () => { expect(listSessions).not.toHaveBeenCalled() }) - it('fires listSessions once for the first input after a visibility resume', async () => { + it('does not fire listSessions for input after a visibility resume', async () => { const listSessions = vi.mocked(window.api.pty.listSessions) listSessions.mockClear() const { binding, typeKeystroke } = await connectActivePaneWithInput() @@ -11777,73 +14278,42 @@ describe('connectPanePty', () => { typeKeystroke('a') typeKeystroke('b') - expect(listSessions).toHaveBeenCalledTimes(1) - }) - - it('re-arms one re-check after a second visibility resume', async () => { - const listSessions = vi.mocked(window.api.pty.listSessions) - listSessions.mockClear() - const { binding, typeKeystroke } = await connectActivePaneWithInput() - - binding.noteVisibilityResume() - typeKeystroke('a') - typeKeystroke('b') - expect(listSessions).toHaveBeenCalledTimes(1) - - binding.noteVisibilityResume() - typeKeystroke('c') - typeKeystroke('d') - expect(listSessions).toHaveBeenCalledTimes(2) - }) - - it('never fires listSessions for a remote: web-runtime pane (liveness owned by host snapshot)', async () => { - const listSessions = vi.mocked(window.api.pty.listSessions) - listSessions.mockClear() - const { connectPanePty } = await import('./pty-connection') - // Remote panes report a null connectionId but a remote:-prefixed ptyId, so - // the SSH/connectionId guard alone would not exclude them — the remote - // prefix guard must. - const transport = createMockTransport('remote:env-1@@terminal-2') - transport.getConnectionId.mockReturnValue(null) - transportFactoryQueue.push(transport) - const manager = createManager(2) - const deps = createDeps({ - restoredLeafId: LEAF_2, - paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } - }) - const pane = createPane(2) - const binding = connectPanePty(pane as never, manager as never, deps as never) as unknown as { - noteVisibilityResume: () => void - } - - binding.noteVisibilityResume() - sendTerminalInputThroughPane(pane, 'x') - sendTerminalInputThroughPane(pane, 'y') - expect(listSessions).not.toHaveBeenCalled() }) - it('never fires listSessions for an SSH pane after resume', async () => { + it('sends the first post-resume input without starting the liveness re-check', async () => { const listSessions = vi.mocked(window.api.pty.listSessions) listSessions.mockClear() - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('ssh-pty-2') - transport.getConnectionId.mockReturnValue('ssh-connection-1') - transportFactoryQueue.push(transport) - const manager = createManager(2) - const deps = createDeps({ - restoredLeafId: LEAF_2, - paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } + const calls: string[] = [] + listSessions.mockImplementation(() => { + calls.push('listSessions') + return Promise.resolve([]) + }) + const { binding, transport, typeKeystroke } = await connectActivePaneWithInput() + transport.sendInput.mockImplementation(() => { + calls.push('sendInput') + return true }) - const pane = createPane(2) - const binding = connectPanePty(pane as never, manager as never, deps as never) as unknown as { - noteVisibilityResume: () => void - } binding.noteVisibilityResume() - sendTerminalInputThroughPane(pane, 'x') - sendTerminalInputThroughPane(pane, 'y') + typeKeystroke('a') + expect(calls).toEqual(['sendInput']) + }) + + it('does not re-arm input-driven listSessions across repeated visibility resumes', async () => { + const listSessions = vi.mocked(window.api.pty.listSessions) + listSessions.mockClear() + const { binding, typeKeystroke } = await connectActivePaneWithInput() + + binding.noteVisibilityResume() + typeKeystroke('a') + typeKeystroke('b') + expect(listSessions).not.toHaveBeenCalled() + + binding.noteVisibilityResume() + typeKeystroke('c') + typeKeystroke('d') expect(listSessions).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 00db0461cdf..8d009829be7 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -1,7 +1,8 @@ /* oxlint-disable max-lines */ import type { PaneManager, ManagedPane } from '@/lib/pane-manager/pane-manager' import type { ManagedPaneInternal } from '@/lib/pane-manager/pane-manager-types' -import type { IDisposable } from '@xterm/xterm' +import type { IBuffer, IDisposable } from '@xterm/xterm' +import { resolveCursorAgentImeAnchor } from '@/lib/pane-manager/terminal-ime-anchor' import { detectAgentStatusFromTitle, isGeminiTerminalTitle, @@ -23,7 +24,11 @@ import { hasCachedWindowsTerminalCapabilities } from '@/lib/windows-terminal-capabilities' import { shouldSeedCacheTimerOnInitialTitle } from './cache-timer-seeding' -import { shouldReconcileDeadSession } from './terminal-dead-session-reconcile' +import { + shouldReconcileDeadSession, + shouldReconcileMissingSession, + type HasPty +} from './terminal-dead-session-reconcile' import type { PtyConnectionDeps } from './pty-connection-types' import { safeFit } from '@/lib/pane-manager/pane-tree-ops' import { requestStablePaneFit } from '@/lib/pane-manager/pane-fit-resize-observer' @@ -45,12 +50,14 @@ import { type PanePtyResizeHoldFlushDetail } from '@/lib/pane-manager/pane-pty-resize-hold' import { + POST_REPLAY_LIVE_AGENT_REATTACH_RESET, POST_REPLAY_LIVE_SNAPSHOT_RESET, POST_REPLAY_MODE_RESET, POST_REPLAY_REATTACH_RESET, RESET_KITTY_KEYBOARD_PROTOCOL, RESET_TERMINAL_CURSOR_STYLE } from './layout-serialization' +import { buildFreshShellViewportBlankingSequence } from './terminal-restored-viewport' import { createShellReadyMarkerScanState, scanForShellReadyMarker } from './shell-ready-marker-scan' import { shouldUseShellReadyStartupDelivery } from '../../../../shared/codex-startup-delivery' import { resolveSetupAgentSequenceLaunchCommand } from '../../../../shared/setup-agent-sequencing' @@ -61,6 +68,7 @@ import { scanMode2031Sequences } 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 { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream' import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection' @@ -81,8 +89,15 @@ import { import { createBrowserUuid } from '@/lib/browser-uuid' import { makePaneKey, parseLegacyNumericPaneKey } from '../../../../shared/stable-pane-id' import { createTerminalCommandLifecycle } from './terminal-command-lifecycle' +import { createPaneForegroundAgentTracker } from './pane-foreground-agent-tracker' +import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' +import { dispatchTerminalCommandFinishedEvent } from '@/hooks/terminal-command-finished-event' import { e2eConfig } from '@/lib/e2e-config' -import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-status-types' +import { + AGENT_STATUS_STALE_AFTER_MS, + type AgentStatusEntry, + type AgentType +} from '../../../../shared/agent-status-types' import { isWebTerminalSurfaceTabId } from '@/runtime/web-terminal-surface-id' import { createAgentInterruptInference, @@ -133,22 +148,27 @@ import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { CLIENT_PLATFORM } from '@/lib/new-workspace' -import { buildAgentResumeStartupPlan } from '@/lib/tui-agent-startup' +import { buildAgentResumeStartupPlan, buildAgentStartupPlan } from '@/lib/tui-agent-startup' import { resolveAgentStatusTerminalTitle } from '@/lib/agent-status-terminal-title' import { resolveTuiAgentLaunchArgs, resolveTuiAgentLaunchEnv } from '../../../../shared/tui-agent-launch-defaults' +import { isTuiAgent } from '../../../../shared/tui-agent-config' +import { isTuiAgentEnabled } from '../../../../shared/tui-agent-selection' import { isResumableTuiAgent, normalizeAgentProviderSession, - type ResumableTuiAgent, type SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' import { normalizeCompatibleAgentTitleForOwner, resolveCompatibleAgentTypeForOwner } from '../../../../shared/agent-title-owner' +import { + isExpectedAgentProcess, + recognizeAgentProcessFromCommandLine +} from '../../../../shared/agent-process-recognition' import type { SetupSplitDirection, TuiAgent } from '../../../../shared/types' import { isWslUncPath } from '../../../../shared/wsl-paths' import { TUI_AGENT_CONFIG } from '../../../../shared/tui-agent-config' @@ -158,7 +178,6 @@ import { beginAgentStartupDeliveryAttempt, releaseAgentStartupDeliveryAttempt } from '@/lib/agent-startup-delayed-delivery' -import { isExpectedAgentProcess } from '../../../../shared/agent-process-recognition' const pendingSpawnByPaneKey = new Map>() const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED' @@ -169,6 +188,7 @@ const AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS = 1500 const AGENT_TASK_COMPLETE_NOTIFICATION_DETAIL_MAX_AGE_MS = 10_000 const COMMAND_CODE_OUTPUT_DONE_SETTLE_MS = 1500 const SSH_SHELL_READY_STARTUP_FALLBACK_MS = 1500 +const MANUAL_AGENT_COMMAND_MAX_CHARS = 4096 const STARTUP_DRAFT_PASTE_QUIET_MS = 1500 const STARTUP_DRAFT_PASTE_TIMEOUT_MS = 8000 const HIDDEN_OUTPUT_RESTORE_SCROLLBACK_ROWS = 5000 @@ -177,8 +197,13 @@ const HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MS = 50 const HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MAX = 3 const HIDDEN_OUTPUT_RESTORE_FOREGROUND_TIMEOUT_MS = 750 const TERMINAL_RENDERER_RISK_SCAN_TAIL_CHARS = 256 +const SYNCHRONIZED_OUTPUT_START_SEQUENCE = '\x1b[?2026h' +const SYNCHRONIZED_OUTPUT_END_SEQUENCE = '\x1b[?2026l' +const SYNCHRONIZED_OUTPUT_MARKER_TAIL_CHARS = SYNCHRONIZED_OUTPUT_START_SEQUENCE.length - 1 const CURSOR_SHOW_SEQUENCE = '\x1b[?25h' const CURSOR_HIDE_SEQUENCE = '\x1b[?25l' +const TERMINAL_FOCUS_IN_SEQUENCE = '\x1b[I' +const FOCUS_REPORTING_DISABLE_SEQUENCE = '\x1b[?1004l' const REATTACH_IDLE_AGENT_CURSOR_RESET_DELAY_MS = 250 const FOREGROUND_THROUGHPUT_IMMEDIATE_CHARS = 2048 const FOREGROUND_INTERACTIVE_REDRAW_CHARS = 128 * 1024 @@ -204,6 +229,103 @@ type E2eTerminalPtyDataInjectionApi = { keys: () => string[] } +type TerminalWithFocusMode = { + textarea?: HTMLTextAreaElement | null + modes?: { + sendFocusMode?: boolean + } +} + +type TerminalWithInspectableBuffer = { + cols: number + rows: number + buffer?: { + active?: IBuffer + } +} + +// Why: replay bytes can carry a dead run's screen in scrollback — or still +// painted in the viewport with a shell prompt below it — so once xterm has +// parsed the replay the confirmation needs both the cursor-agent screen shape +// AND the parked cursor. A dead screen leaves the shell cursor after its +// prompt; a live agent that needs the focus-in is by definition parked, and a +// live agent that is not parked only loses focus reporting the way the +// pre-fix reattach always did. Returns null when the buffer is not +// inspectable (e.g. test doubles). +function parsedViewportShowsParkedCursorAgentScreen( + terminal: TerminalWithInspectableBuffer +): boolean | null { + const buffer = terminal.buffer?.active + if ( + !buffer || + typeof buffer.getLine !== 'function' || + typeof buffer.cursorX !== 'number' || + typeof buffer.cursorY !== 'number' + ) { + return null + } + return ( + resolveCursorAgentImeAnchor({ + buffer, + rows: terminal.rows, + cols: terminal.cols, + cursorX: buffer.cursorX, + cursorY: buffer.cursorY + }) !== null + ) +} + +function terminalHasFocusReportingEnabled(terminal: TerminalWithFocusMode): boolean { + return terminal.modes?.sendFocusMode === true +} + +function terminalOwnsDomFocus(terminal: TerminalWithFocusMode): boolean { + if (typeof document === 'undefined' || !terminal.textarea) { + return false + } + return document.activeElement === terminal.textarea +} + +function stripAnsiCsiSequences(data: string): string { + let normalized = '' + let index = 0 + while (index < data.length) { + if (data.charCodeAt(index) === 0x1b && data[index + 1] === '[') { + index += 2 + while (index < data.length) { + const code = data.charCodeAt(index) + index += 1 + if (code >= 0x40 && code <= 0x7e) { + break + } + } + continue + } + normalized += data[index] + index += 1 + } + return normalized +} + +const CURSOR_AGENT_REATTACH_HEADER = 'Cursor Agent' +const CURSOR_AGENT_REATTACH_INPUT_MARKER = '→' +const CURSOR_AGENT_REATTACH_SCREEN_SIGNAL_MAX_CHARS = 5000 + +function hasCursorAgentReattachPayloadScreenSignal(data: string): boolean { + const normalized = stripAnsiCsiSequences(data) + // Why: anchor on the LAST header occurrence — replay buffers keep scrollback, + // and an earlier finished run must not classify the current screen. + const headerIndex = normalized.lastIndexOf(CURSOR_AGENT_REATTACH_HEADER) + if (headerIndex === -1) { + return false + } + const screenTail = normalized.slice( + headerIndex + CURSOR_AGENT_REATTACH_HEADER.length, + headerIndex + CURSOR_AGENT_REATTACH_SCREEN_SIGNAL_MAX_CHARS + ) + return screenTail.includes(`${CURSOR_AGENT_REATTACH_INPUT_MARKER} `) +} + type E2eTerminalPtyDataInjectionWindow = Window & { __terminalPtyDataInjection?: E2eTerminalPtyDataInjectionApi __terminalHiddenSnapshotOverride?: E2eTerminalHiddenSnapshotOverrideApi @@ -244,8 +366,14 @@ type PendingStartupCommand = { env?: Record } +type FreshSpawnOptions = { + forceBlankRestoredViewport?: boolean +} + type ColdRestoreAgentResumeStartup = PendingStartupCommand & { - agent: ResumableTuiAgent + // TuiAgent (not just ResumableTuiAgent): the fresh-launch fallback can spawn any + // agent the terminal previously ran, including ones without resume support. + agent: TuiAgent launchConfig: NonNullable>['launchConfig'] launchToken: string useLiveEntry: boolean @@ -542,6 +670,7 @@ type PanePtyBinding = IDisposable & { syncProcessTracking: () => void noteVisibilityResume: () => void reconcileIfSessionDead: (liveSessionIds: Set, snapshotRequestedAt?: number) => void + reconcileIfSessionMissing: (hasPty: HasPty, livenessRequestedAt?: number) => void } function isAgentTaskCompleteNotificationEnabled(): boolean { @@ -791,16 +920,16 @@ function shouldWritePtyOutputForeground(isPaneVisible: boolean): boolean { } function containsSynchronizedOutputStart(data: string): boolean { - return data.includes('\x1b[?2026h') + return data.includes(SYNCHRONIZED_OUTPUT_START_SEQUENCE) } function containsSynchronizedOutputEnd(data: string): boolean { - return data.includes('\x1b[?2026l') + return data.includes(SYNCHRONIZED_OUTPUT_END_SEQUENCE) } function shouldSynchronizedOutputRemainActive(data: string, wasActive: boolean): boolean { - const lastStartIndex = data.lastIndexOf('\x1b[?2026h') - const lastEndIndex = data.lastIndexOf('\x1b[?2026l') + const lastStartIndex = data.lastIndexOf(SYNCHRONIZED_OUTPUT_START_SEQUENCE) + const lastEndIndex = data.lastIndexOf(SYNCHRONIZED_OUTPUT_END_SEQUENCE) if (lastStartIndex === -1 && lastEndIndex === -1) { return wasActive } @@ -925,6 +1054,7 @@ export function connectPanePty( let unregisterDocumentVisibilityRecovery: (() => void) | null = null let cleanupHiddenOutputRestoreDeferredRetry = (): void => {} let cleanupHiddenOutputRestoreForegroundDeadline = (): void => {} + let resetRendererOrderedSeqForPtyExit: (exitedPtyId: string) => void = () => {} let cleanupStartupDraftPasteTimers = (): void => {} let unregisterE2ePtyDataInjection = (): void => {} let startupInjectTimer: ReturnType | null = null @@ -993,32 +1123,34 @@ export function connectPanePty( ) } ) + const legacyProviderSessionKeys = legacyMatches.map(([, record]) => { + const providerSession = normalizeAgentProviderSession(record.providerSession) + return providerSession + ? [record.worktreeId, record.agent, providerSession.key, providerSession.id].join('\0') + : null + }) const exactLegacyMatch = legacyMatches.find(([paneKey]) => { const legacy = parseLegacyNumericPaneKey(paneKey) return legacy?.numericPaneId === String(pane.id) }) const providerSessionKeys = new Set( - legacyMatches.map(([, record]) => - [ - record.worktreeId, - record.agent, - record.providerSession.key, - record.providerSession.id - ].join('\0') - ) + legacyProviderSessionKeys.filter((key): key is string => key !== null) ) const oldestLegacyMatch = legacyMatches .slice() .sort(([, a], [, b]) => a.capturedAt - b.capturedAt || a.updatedAt - b.updatedAt)[0] // Why: duplicate legacy aliases can point at one provider session; consume // the oldest capture as canonical and clear its aliases after resume. + const allLegacyMatchesHaveProviderSession = legacyProviderSessionKeys.every( + (key) => key !== null + ) const selectedLegacyMatch = exactLegacyMatch ?? - (providerSessionKeys.size === 1 - ? legacyMatches.length === 1 - ? legacyMatches[0] - : oldestLegacyMatch - : null) + (legacyMatches.length === 1 + ? legacyMatches[0] + : allLegacyMatchesHaveProviderSession && providerSessionKeys.size === 1 + ? oldestLegacyMatch + : null) if (!selectedLegacyMatch) { return null } @@ -1030,13 +1162,19 @@ export function connectPanePty( consumed: { paneKey: string; record: SleepingAgentSessionRecord } ): void => { state.clearSleepingAgentSession(consumed.paneKey) + const consumedSession = normalizeAgentProviderSession(consumed.record.providerSession) + // No provider session (e.g. a fresh-launch fallback record) means there is no + // shared session to alias, so there is nothing else to clear. + if (!consumedSession) { + return + } for (const [paneKey, record] of Object.entries(state.sleepingAgentSessionsByPaneKey)) { if ( paneKey !== consumed.paneKey && record.worktreeId === consumed.record.worktreeId && record.agent === consumed.record.agent && - record.providerSession.key === consumed.record.providerSession.key && - record.providerSession.id === consumed.record.providerSession.id + record.providerSession?.key === consumedSession.key && + record.providerSession?.id === consumedSession.id ) { // Why: legacy pane aliases can leave multiple sleeping rows for one // provider session; once this pane resumes it, every alias is stale. @@ -1125,13 +1263,227 @@ export function connectPanePty( ) return tab?.defaultTitle?.trim() || 'Terminal' } + // Why: infer pane ownership from a manually typed agent command (e.g. `omp`) by + // shadowing the shell's current command line, for generic terminals where no + // launch metadata exists. Consumed by getAuthoritativePaneAgent below. + let commandInferredPaneAgent: TuiAgent | null = null + let pendingShellCommandLine = '' + let pendingShellCommandCursor = 0 + let commandInferredPaneAgentGeneration = 0 + let shellCommandInferenceSuspendedUntilCommandEnd = false + const resetPendingShellCommandLine = (): void => { + pendingShellCommandLine = '' + pendingShellCommandCursor = 0 + } + const rememberCommandInferredPaneAgent = (): void => { + const commandLine = pendingShellCommandLine.trim() + resetPendingShellCommandLine() + const nextAgent = commandLine + ? (recognizeAgentProcessFromCommandLine(commandLine)?.agent ?? null) + : null + commandInferredPaneAgent = nextAgent + commandInferredPaneAgentGeneration += 1 + } + const clearCommandInferredPaneAgent = (): void => { + commandInferredPaneAgent = null + resetPendingShellCommandLine() + commandInferredPaneAgentGeneration += 1 + } + const clearCommandInferredPaneAgentAfterPtySideEffects = (): void => { + const generation = commandInferredPaneAgentGeneration + resetPendingShellCommandLine() + queueMicrotask(() => { + setTimeout(() => { + if (commandInferredPaneAgentGeneration === generation) { + clearCommandInferredPaneAgent() + } + }, 0) + }) + } + const appendPendingShellCommandInput = (text: string): void => { + const available = MANUAL_AGENT_COMMAND_MAX_CHARS - pendingShellCommandLine.length + if (available <= 0) { + shellCommandInferenceSuspendedUntilCommandEnd = true + return + } + const inserted = text.slice(0, available) + pendingShellCommandLine = + pendingShellCommandLine.slice(0, pendingShellCommandCursor) + + inserted + + pendingShellCommandLine.slice(pendingShellCommandCursor) + pendingShellCommandCursor += inserted.length + if (inserted.length < text.length) { + shellCommandInferenceSuspendedUntilCommandEnd = true + } + } + const deletePendingShellCommandWord = (): void => { + const beforeCursor = pendingShellCommandLine.slice(0, pendingShellCommandCursor) + const afterCursor = pendingShellCommandLine.slice(pendingShellCommandCursor) + const nextBeforeCursor = beforeCursor.replace(/[^\S\r\n]*\S+[^\S\r\n]*$/, '') + pendingShellCommandLine = nextBeforeCursor + afterCursor + pendingShellCommandCursor = nextBeforeCursor.length + } + const cancelSuspendedShellCommandInference = (): void => { + if (!shellCommandInferenceSuspendedUntilCommandEnd) { + return + } + shellCommandInferenceSuspendedUntilCommandEnd = false + resetPendingShellCommandLine() + } + const deletePendingShellCommandCharacter = (): void => { + if (pendingShellCommandCursor === 0) { + return + } + pendingShellCommandLine = + pendingShellCommandLine.slice(0, pendingShellCommandCursor - 1) + + pendingShellCommandLine.slice(pendingShellCommandCursor) + pendingShellCommandCursor -= 1 + } + const deletePendingShellCommandCharacterAtCursor = (): void => { + if (pendingShellCommandCursor >= pendingShellCommandLine.length) { + return + } + pendingShellCommandLine = + pendingShellCommandLine.slice(0, pendingShellCommandCursor) + + pendingShellCommandLine.slice(pendingShellCommandCursor + 1) + } + const movePendingShellCommandCursor = (delta: number): void => { + pendingShellCommandCursor = Math.min( + pendingShellCommandLine.length, + Math.max(0, pendingShellCommandCursor + delta) + ) + } + const consumeShellCommandCsiSequence = (data: string, index: number): number | null => { + if (data.charCodeAt(index) !== 0x1b || data[index + 1] !== '[') { + return null + } + let cursor = index + 2 + while (cursor < data.length && /[0-9;?]/.test(data[cursor]!)) { + cursor += 1 + } + const final = data[cursor] + if (!final || !/[~A-Za-z]/.test(final)) { + return null + } + const params = data.slice(index + 2, cursor) + // Why: only emulate a bare one-column move. Parameterized/modified cursor keys + // (e.g. Ctrl+Left `\x1b[1;5D` = word-jump) move the real cursor by more than + // one, so tracking them as ±1 would desync the shadow line — fall through to + // reset instead of silently corrupting the sampled command. + if (final === 'D' && params === '') { + movePendingShellCommandCursor(-1) + } else if (final === 'C' && params === '') { + movePendingShellCommandCursor(1) + } else if (final === 'H' || (final === '~' && params === '1')) { + pendingShellCommandCursor = 0 + } else if (final === 'F' || (final === '~' && params === '4')) { + pendingShellCommandCursor = pendingShellCommandLine.length + } else if (final === '~' && params === '3') { + deletePendingShellCommandCharacterAtCursor() + } else if (final === '~' && (params === '200' || params === '201')) { + // Bracketed paste wrappers are terminal framing, not shell command text. + } else { + resetPendingShellCommandLine() + } + return cursor + 1 + } + const getLivePaneAgentTitle = (): string | null => { + const state = useAppStore.getState() + const runtimeTitle = state.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id] + const tabTitle = (state.tabsByWorktree[deps.worktreeId] ?? []).find( + (entry) => entry.id === deps.tabId + )?.title + return runtimeTitle ?? tabTitle ?? null + } + const hasFreshPaneAgentSurface = (): boolean => { + const state = useAppStore.getState() + const entry = state.agentStatusByPaneKey[cacheKey] + const now = Date.now() + const entryIsFresh = + entry && + typeof entry.updatedAt === 'number' && + now - entry.updatedAt <= AGENT_STATUS_STALE_AFTER_MS + if (entryIsFresh && entry.state !== 'done') { + return true + } + const liveTitle = getLivePaneAgentTitle() + return detectAgentStatusFromTitle(liveTitle ?? '') !== null + } + const observeAcceptedShellCommandInput = (data: string): void => { + if (commandInferredPaneAgent) { + return + } + // Why: bytes typed inside a live agent TUI are prompt text, not shell + // commands, even if they spell another agent binary name. + if (hasFreshPaneAgentSurface()) { + resetPendingShellCommandLine() + return + } + if (shellCommandInferenceSuspendedUntilCommandEnd) { + if (data.includes('\x03') || data.includes('\x15')) { + shellCommandInferenceSuspendedUntilCommandEnd = false + resetPendingShellCommandLine() + } + if (data.includes('\r') || data.includes('\n')) { + shellCommandInferenceSuspendedUntilCommandEnd = false + } + return + } + if (data.length > MANUAL_AGENT_COMMAND_MAX_CHARS) { + resetPendingShellCommandLine() + shellCommandInferenceSuspendedUntilCommandEnd = !data.includes('\r') && !data.includes('\n') + return + } + for (let index = 0; index < data.length; index += 1) { + const char = data[index]! + if (char === '\r' || char === '\n') { + shellCommandInferenceSuspendedUntilCommandEnd = false + rememberCommandInferredPaneAgent() + if (commandInferredPaneAgent) { + return + } + continue + } + if (char === '\x7f' || char === '\b') { + deletePendingShellCommandCharacter() + continue + } + if (char === '\x17') { + deletePendingShellCommandWord() + continue + } + if (char === '\x03' || char === '\x15') { + resetPendingShellCommandLine() + continue + } + if (char === '\x1b') { + const nextIndex = consumeShellCommandCsiSequence(data, index) + if (nextIndex !== null) { + index = nextIndex - 1 + continue + } + resetPendingShellCommandLine() + continue + } + if (char < ' ') { + resetPendingShellCommandLine() + continue + } + if (char >= ' ') { + appendPendingShellCommandInput(char) + if (shellCommandInferenceSuspendedUntilCommandEnd) { + return + } + } + } + } /** * Resolves the authoritative owner agent type for this pane, checking tab launch, - * pane startup, and store state configuration. + * pane startup, typed command ownership, and store state configuration. * * Why: launch ownership wins so Pi-compatible live titles/hooks can't repaint an - * OMP-owned pane back to Pi; the stored status agentType is only the last-resort - * fallback because it can itself be a Pi-compatible frame. + * OMP-owned pane back to Pi; command ownership covers manually typed `omp` + * in generic terminals where launch metadata does not exist. */ const getAuthoritativePaneAgent = (): AgentType | undefined => { const state = useAppStore.getState() @@ -1142,6 +1494,7 @@ export function connectPanePty( tab?.launchAgent ?? paneStartup?.launchAgent ?? paneStartup?.initialAgentStatus?.agent ?? + commandInferredPaneAgent ?? state.agentStatusByPaneKey[cacheKey]?.agentType ) } @@ -1219,6 +1572,52 @@ export function connectPanePty( )?.title return runtimeTitle ?? tabTitle ?? null } + let reattachReplayPayloadHasCursorAgentSignal = false + // Why: post-parse veto callbacks must only judge the latest replay frame; a + // newer frame bumps the generation so a stale callback stands down. + let reattachReplayPayloadSignalGeneration = 0 + const rememberReattachPayloadAgentSignal = ( + data: string, + opts: { fullScreenReplay: boolean } + ): void => { + reattachReplayPayloadSignalGeneration += 1 + // Why: ordinary scrollback can mention agent names. Treat replay bytes as + // a live Cursor Agent signal only when they look like its restored screen. + const signal = hasCursorAgentReattachPayloadScreenSignal(data) + // Why: incremental (non-clearing) replay frames repaint only part of the + // screen, so their bytes can only add evidence — a full-screen replay is + // the authoritative repaint that may clear the flag. + reattachReplayPayloadHasCursorAgentSignal = opts.fullScreenReplay + ? signal + : reattachReplayPayloadHasCursorAgentSignal || signal + } + const isCursorAgentNativeTitle = (title: string): boolean => { + return title.trim().toLowerCase() === CURSOR_AGENT_REATTACH_HEADER.toLowerCase() + } + const hasLiveAgentReattachStatusOrTitleSignal = (): boolean => { + // Why: launch ownership (tab.launchAgent) never decays after the agent + // exits, so it must not count as liveness here — only live status, live + // titles, and the replayed screen shape do. + if (useAppStore.getState().agentStatusByPaneKey[cacheKey]) { + return true + } + const title = getCurrentTerminalTitle() ?? '' + // Why: broad token matching (getAgentLabel) fires on titles like + // "ssh devin@host"; that surface is too loose to gate mode preservation + // and PTY byte injection, so only exact/status titles count here. + return detectAgentStatusFromTitle(title) !== null || isCursorAgentNativeTitle(title) + } + const hasLiveAgentReattachSignal = (): boolean => { + return hasLiveAgentReattachStatusOrTitleSignal() || reattachReplayPayloadHasCursorAgentSignal + } + const shouldPreserveAgentReattachModes = (): boolean => { + // Why: ordinary shells can inherit stale ?25l/?1004h from replay bytes. + // Preserve those modes only when reattach still looks agent-owned. + return hasLiveAgentReattachSignal() + } + const shouldSendFocusedAgentReattachFocusIn = (): boolean => { + return terminalOwnsDomFocus(pane.terminal) && shouldPreserveAgentReattachModes() + } const scheduleReattachIdleAgentCursorReset = (): void => { const status = detectAgentStatusFromTitle(getCurrentTerminalTitle() ?? '') if (status !== 'idle' && status !== 'permission') { @@ -1363,8 +1762,20 @@ export function connectPanePty( } return pendingWrite.then(() => interruptInference.flushPending()) } + const paneForegroundAgentTracker = createPaneForegroundAgentTracker({ + getPtyId: () => transport.getPtyId(), + isTrackablePtyId: (id) => !isRemoteRuntimePtyId(id) && parseAppSshPtyId(id) === null, + readForegroundProcess: (id) => window.api.pty.getForegroundProcess(id), + publish: (entry) => useAppStore.getState().setPaneForegroundAgent(cacheKey, entry) + }) const commandLifecycle = createTerminalCommandLifecycle({ + onCommandStarted: () => paneForegroundAgentTracker.onCommandStarted(), onCommandFinished: () => { + clearCommandInferredPaneAgentAfterPtySideEffects() + paneForegroundAgentTracker.onCommandFinished() + // Why: the finished command may have moved HEAD or the index (e.g. + // `git checkout`); nudge git UI now instead of waiting for a poll. + dispatchTerminalCommandFinishedEvent(deps.worktreeId) const state = useAppStore.getState() const entry = state.agentStatusByPaneKey[cacheKey] const inferenceResult = flushPendingInterruptInference() @@ -1447,9 +1858,14 @@ export function connectPanePty( const setPanePtyFitBinding = (ptyId: string): void => { bindPanePtyId(pane.id, ptyId, deps.tabId) pane.container.dataset.ptyId = ptyId + // Why: override hydration can arrive before this pane knows its PTY. Once + // data-pty-id is bound, safeFit can park xterm at the held phone grid. + if (getFitOverrideForPty(ptyId)) { + safeFit(pane) + } } let activePanePtyBinding: string | null = null - // Why: bind time so reconcile can ignore a listSessions snapshot requested + // Why: bind time lets async liveness reconcile ignore a request started // before this PTY bound (newborn race). Null disables the guard (fail-safe). let activePanePtyBindingBoundAt: number | null = null const clearPanePtyFitBinding = (): void => { @@ -1537,10 +1953,43 @@ export function connectPanePty( // on a brand-new worktree keeps its dead terminal visible instead of bouncing // the user to Landing. let spawnedFreshPtyId: string | null = null + // Why: hibernation suppresses its kill's exit while the pane is hidden, so + // onExit must not tear the pane down — but the pane still owes the user a + // wake. Remember the hibernated ptyId; the visibility-resume hook consumes + // it and relaunches the recorded agent session on next reveal. + let hibernatedWakePtyId: string | null = null + let wakeHibernatedAgentPane: (() => void) | null = null + // Why: reveal is the normal wake trigger, but a reveal that lands *during* the + // in-flight hibernation kill runs noteVisibilityResume before onExit arms the + // wake. Sharing the guarded consume lets both the reveal hook and the + // arm-time foreground check resume the pane exactly once. + const consumeHibernatedAgentWake = (): void => { + if (hibernatedWakePtyId === null || disposed) { + return + } + if (deps.paneTransportsRef.current.get(pane.id) !== transport) { + return + } + const currentPtyId = transport.getPtyId() + // Why: a real pty:exit clears the transport's ptyId before onExit while a + // reconcile-driven exit leaves it bound; both mean "nothing respawned since + // hibernation". A different non-null id means another flow (e.g. an + // intentional restart) already rebound the pane — its spawn wins. + if (currentPtyId !== null && currentPtyId !== hibernatedWakePtyId) { + hibernatedWakePtyId = null + return + } + hibernatedWakePtyId = null + // Why: reveal is the wake signal for a hibernated pane. Resume the recorded + // agent session (or fall back to a fresh shell) instead of leaving the + // frozen frame with no PTY behind it. + wakeHibernatedAgentPane?.() + } const onExit = (ptyId: string): void => { if (handledExitPtyId === ptyId) { return } + resetRendererOrderedSeqForPtyExit(ptyId) const currentPaneTransport = deps.paneTransportsRef.current.get(pane.id) if (currentPaneTransport && currentPaneTransport !== transport) { // Why: an old transport can deliver a late exit after this pane has @@ -1567,6 +2016,7 @@ export function connectPanePty( // Why: a dead terminal has no running agent — remove its explicit status // entry so the hover UI only shows what is running *now*. useAppStore.getState().removeAgentStatus(cacheKey) + useAppStore.getState().clearPaneForegroundAgent(cacheKey) // The runtime graph is the CLI's source for live terminal bindings, so // we must republish when a pane loses its PTY instead of waiting for a // broader layout change that may never happen. @@ -1579,6 +2029,21 @@ export function connectPanePty( // Why: the action that suppressed the exit owns whether the leaf binding // is a wake hint or should be discarded; runtime cleanup above is enough. manager.setPaneGpuRendering(pane.id, true) + if (getSleepingRecordForPane(useAppStore.getState())) { + // Why: hibernation killed this pane's PTY while hidden. The frozen TUI + // frame still has mouse-tracking/bracketed-paste armed, which silently + // eats every click and keystroke against a dead transport — disarm the + // modes now and arm the reveal-time wake. + replayIntoTerminal(pane, deps.replayingPanesRef, POST_REPLAY_MODE_RESET) + hibernatedWakePtyId = ptyId + if (deps.isVisibleRef.current) { + // Why: a reveal that raced this kill already ran noteVisibilityResume + // before the exit landed, so it saw nothing armed. Consume the wake + // now (deferred off the exit handler) so a foreground pane still + // resumes without needing a second hide/reveal. + queueMicrotask(consumeHibernatedAgentWake) + } + } return } manager.setPaneGpuRendering(pane.id, true) @@ -2031,6 +2496,7 @@ export function connectPanePty( } } const onAgentExited = (): void => { + clearCommandInferredPaneAgent() // Why: when the terminal title reverts to a plain shell (e.g., "bash", "zsh"), // the agent has exited. Clear any running cache timer so the sidebar doesn't // show a stale countdown for a tab that no longer has an active Claude session. @@ -2135,12 +2601,27 @@ export function connectPanePty( const markTerminalInputSent = (): void => { lastTerminalInputAt = performance.now() } - const recordAcceptedTerminalInputForHibernation = (): void => { + const recordTerminalInputForHibernation = (): void => { useAppStore.getState().recordTerminalInput(cacheKey) } + // Why: onData mixes real user input with xterm's parser auto-replies (focus + // reports, DA/DSR/CPR responses). Recording those replies as activity makes + // the hibernation planner treat a pane hidden after its agent finished as + // "input after done" forever. The core user-input signal fires only for real + // input, so hibernation activity records from it; onData recording remains + // solely as the fallback when the internal API is unavailable. + const userInputActivityDisposable = subscribeToTerminalUserInput( + pane.terminal, + recordTerminalInputForHibernation + ) + const recordTerminalInputForHibernationFallback = (): void => { + if (userInputActivityDisposable === null) { + recordTerminalInputForHibernation() + } + } const markAcceptedTerminalInputSent = (): void => { markTerminalInputSent() - recordAcceptedTerminalInputForHibernation() + recordTerminalInputForHibernationFallback() } const terminalTheme = pane.terminal.options.theme const terminalColorQueryReplies = terminalTheme @@ -2293,11 +2774,6 @@ export function connectPanePty( clearPendingTerminalInputIntent() return } - // Why (Defect #2): a keystroke against a pane whose daemon session was - // reaped while hidden is silently dropped — sendInput still returns true. - // Kick off a fire-and-forget liveness re-check so the dead pane is cleaned - // up without relying on (the never-occurring) sendInput false return. - recheckLivenessAfterInput() const intent = pendingTerminalInputIntent // Why: real xterm can deliver the terminal byte even when our DOM keydown // listener missed the press. Exact Ctrl+C/Escape bytes are still safe to @@ -2305,13 +2781,19 @@ export function connectPanePty( // excluded because those transports do not expose sendInputAccepted. const acknowledgedIntent = intent ?? inferIntentFromExactTerminalInput(data) if (acknowledgedIntent && transport.sendInputAccepted) { + if (acknowledgedIntent === 'ctrl-c') { + // Why: the accepted-write callback is async; let the next command be + // inferred if the user cancelled an oversized line and immediately typed. + cancelSuspendedShellCommandInference() + } clearPendingTerminalInputIntent() markTerminalInputSent() const writePromise = transport .sendInputAccepted(data) .then((accepted) => { if (accepted) { - recordAcceptedTerminalInputForHibernation() + recordTerminalInputForHibernationFallback() + observeAcceptedShellCommandInput(data) observeAcceptedTerminalInput(data, acknowledgedIntent) interruptInference.observeInputIntent(acknowledgedIntent) observeTitleOnlyInterrupt() @@ -2326,6 +2808,7 @@ export function connectPanePty( if (intent) { if (transport.sendInput(data)) { markAcceptedTerminalInputSent() + observeAcceptedShellCommandInput(data) observeAcceptedTerminalInput(data, intent) } clearPendingTerminalInputIntent() @@ -2333,6 +2816,7 @@ export function connectPanePty( } if (transport.sendInput(data)) { markAcceptedTerminalInputSent() + observeAcceptedShellCommandInput(data) observeAcceptedTerminalInput(data) observeSentTerminalInputIntent(data) } else { @@ -2394,6 +2878,14 @@ export function connectPanePty( forwardPtyResize(cols, rows) }) + // Why: a rewrite chunk can enter AND exit the alternate screen in one parse + // (fast-quitting TUI), netting buffer.active.type back to 'normal'; counting + // switches keeps those redraws visible to the atlas-recovery check. + let alternateScreenBufferSwitches = 0 + const onBufferChangeDisposable = pane.terminal.buffer.onBufferChange?.(() => { + alternateScreenBufferSwitches += 1 + }) + // Why: renderer resize forwarding is fire-and-forget. A visible pane can // finish with xterm at the right grid while the PTY silently kept an older // grid, so Codex keeps composing against stale columns. Fit first so xterm's @@ -2906,6 +3398,10 @@ export function connectPanePty( if (projectRuntime?.status === 'resolved' && projectRuntime.runtime.kind === 'wsl') { return 'linux' } + const sshRemotePlatform = getTerminalPasteSshRemotePlatform(connectionId) + if (sshRemotePlatform) { + return sshRemotePlatform + } if (connectionId || (worktree?.path && isWslUncPath(worktree.path))) { return 'linux' } @@ -2919,27 +3415,123 @@ export function connectPanePty( const entry = state.agentStatusByPaneKey[cacheKey] const sleepingRecordEntry = getSleepingRecordForPane(state) const sleepingRecord = sleepingRecordEntry?.record - const useLiveEntry = entry && entry.state !== 'done' - const agent = useLiveEntry ? entry.agentType : sleepingRecord?.agent + const liveEntry = entry && entry.state !== 'done' ? entry : null + const useLiveEntry = Boolean(liveEntry) + + // When the provider session can't be resumed, an agent terminal should still + // come back as its agent (or the default), not a blank shell (#4557). A plain + // shell - no agent ever ran here - stays blank (buildFreshFallback returns null). + const buildFreshFallback = (): ColdRestoreAgentResumeStartup | null => { + const tab = (state.tabsByWorktree[deps.worktreeId] ?? []).find( + (candidate) => candidate.id === deps.tabId + ) + const liveAgent = + liveEntry && isTuiAgent(liveEntry.agentType) ? liveEntry.agentType : undefined + const priorAgent = + liveAgent ?? sleepingRecord?.agent ?? tab?.launchAgent ?? paneStartup?.launchAgent + const defaultPref = state.settings?.defaultTuiAgent + const defaultAgent = + defaultPref && + defaultPref !== 'blank' && + isTuiAgentEnabled(defaultPref, state.settings?.disabledTuiAgents) + ? defaultPref + : undefined + // Fall back to the default agent only when this was an active/sleeping + // agent terminal whose specific agent is unrecoverable - never for a + // genuine plain shell or a stale completed live row. + const fallbackAgent = priorAgent ?? (liveEntry || sleepingRecord ? defaultAgent : undefined) + if (!fallbackAgent) { + return null + } + const liveLaunchConfig = + liveEntry && liveAgent === fallbackAgent + ? state.getAgentLaunchConfigForStatusEntry(liveEntry) + : undefined + const fallbackLaunchConfig = + liveLaunchConfig ?? + (sleepingRecord?.agent === fallbackAgent ? sleepingRecord.launchConfig : undefined) ?? + (paneStartup?.launchAgent === fallbackAgent ? paneStartup.launchConfig : undefined) + if (fallbackLaunchConfig?.agentCommand?.trim()) { + const freshLaunchToken = createBrowserUuid() + return { + agent: fallbackAgent, + command: fallbackLaunchConfig.agentCommand.trim(), + env: { + ...fallbackLaunchConfig.agentEnv, + ORCA_AGENT_LAUNCH_TOKEN: freshLaunchToken + }, + launchConfig: { + agentCommand: fallbackLaunchConfig.agentCommand.trim(), + agentArgs: fallbackLaunchConfig.agentArgs, + agentEnv: { ...fallbackLaunchConfig.agentEnv } + }, + launchToken: freshLaunchToken, + useLiveEntry, + // A fresh launch is a new session, not a restored one, so no + // "restored" banner; the stale sleeping record is still cleared + // after the spawn. + hasSleepingRecord: false, + sleepingRecordEntry + } + } + const freshPlan = buildAgentStartupPlan({ + agent: fallbackAgent, + prompt: '', + cmdOverrides: state.settings?.agentCmdOverrides ?? {}, + agentArgs: + fallbackLaunchConfig !== undefined + ? fallbackLaunchConfig.agentArgs + : resolveTuiAgentLaunchArgs(fallbackAgent, state.settings?.agentDefaultArgs), + agentEnv: + fallbackLaunchConfig !== undefined + ? fallbackLaunchConfig.agentEnv + : resolveTuiAgentLaunchEnv(fallbackAgent, state.settings?.agentDefaultEnv), + platform: getColdRestoreAgentResumePlatform(), + allowEmptyPromptLaunch: true + }) + if (!freshPlan) { + return null + } + const freshLaunchToken = createBrowserUuid() + return { + agent: fallbackAgent, + command: freshPlan.launchCommand, + env: { + ...freshPlan.env, + ORCA_AGENT_LAUNCH_TOKEN: freshLaunchToken + }, + launchConfig: freshPlan.launchConfig, + launchToken: freshLaunchToken, + useLiveEntry, + // A fresh launch is a new session, not a restored one, so no + // "restored" banner; the stale sleeping record is still cleared after + // the spawn. + hasSleepingRecord: false, + sleepingRecordEntry + } + } + + const agent = liveEntry ? liveEntry.agentType : sleepingRecord?.agent if (!agent || !isResumableTuiAgent(agent)) { - return null + return buildFreshFallback() } const providerSession = normalizeAgentProviderSession( - useLiveEntry ? entry.providerSession : sleepingRecord?.providerSession + liveEntry ? liveEntry.providerSession : sleepingRecord?.providerSession ) if (!providerSession) { - return null + return buildFreshFallback() } + const sleepingProviderSession = normalizeAgentProviderSession(sleepingRecord?.providerSession) const matchingSleepingLaunchConfig = sleepingRecord?.launchConfig && (!useLiveEntry || (sleepingRecord.agent === agent && - sleepingRecord.providerSession.key === providerSession.key && - sleepingRecord.providerSession.id === providerSession.id)) + sleepingProviderSession?.key === providerSession.key && + sleepingProviderSession?.id === providerSession.id)) ? sleepingRecord.launchConfig : undefined const launchConfig = - (useLiveEntry && entry ? state.getAgentLaunchConfigForStatusEntry(entry) : undefined) ?? + (liveEntry ? state.getAgentLaunchConfigForStatusEntry(liveEntry) : undefined) ?? matchingSleepingLaunchConfig const resumePlatform = getColdRestoreAgentResumePlatform() const startupPlan = buildAgentResumeStartupPlan({ @@ -2958,7 +3550,7 @@ export function connectPanePty( platform: resumePlatform }) if (!startupPlan) { - return null + return buildFreshFallback() } const coldRestoreLaunchToken = createBrowserUuid() // Why: cold restore means the PTY process is gone but the agent provider @@ -3012,11 +3604,15 @@ export function connectPanePty( } : undefined const startFreshColdRestoreAgentResume = ( - startup: ColdRestoreAgentResumeStartup | null = buildColdRestoreAgentResumeStartup() + startup: ColdRestoreAgentResumeStartup | null = buildColdRestoreAgentResumeStartup(), + options: FreshSpawnOptions = {} ): void => { applyColdRestoreAgentResumeStartup(startup) - startFreshSpawn(startup) + startFreshSpawn(startup, options) } + // Why: the hibernation wake fires from noteVisibilityResume in the outer + // connection scope, long after this deferred-connect closure has run. + wakeHibernatedAgentPane = () => startFreshColdRestoreAgentResume() const isStartupPasteTargetCurrent = (ptyId: string | null): boolean => !disposed && deps.paneTransportsRef.current.get(pane.id) === transport && @@ -3089,9 +3685,13 @@ export function connectPanePty( }, 50) } - const startFreshSpawn = (startupOverride?: PendingStartupCommand | null): void => { + const startFreshSpawn = ( + startupOverride?: PendingStartupCommand | null, + options: FreshSpawnOptions = {} + ): void => { clearPaneMode2031State() clearHiddenOutputRestoreState() + prepareFreshShellViewportForSpawn(options) if (connectionId && startupOverride?.command) { // Why: SSH providers use `command` only as spawn metadata; the renderer // must still submit the resume command to the fresh remote shell. @@ -3244,6 +3844,96 @@ export function connectPanePty( return prefersRefresh } + function resetHiddenRendererRiskState(ptyId: string | null = null): void { + hiddenRiskPtyId = ptyId + hiddenSynchronizedOutputActive = false + hiddenSynchronizedOutputMarkerTail = '' + hiddenRewriteChunkEndedWithCarriageReturn = false + hiddenRewriteCsiScanTail = '' + } + + function ensureHiddenRendererRiskStateForCurrentPty(): void { + const ptyId = transport.getPtyId() + if (hiddenRiskPtyId === ptyId) { + return + } + resetHiddenRendererRiskState(ptyId) + } + + function resetSkippedHiddenRendererRiskState(): void { + // Why: skipped/backlog bytes were not parsed by xterm; reset any live hidden + // frame instead of letting dropped DEC starts make later plain bytes risky. + resetHiddenRendererRiskState(transport.getPtyId()) + } + + function hiddenSynchronizedOutputTouchesParsedFrame(data: string): boolean { + const scanData = hiddenSynchronizedOutputMarkerTail + ? `${hiddenSynchronizedOutputMarkerTail}${data}` + : data + const currentChunkStartIndex = scanData.length - data.length + let active = hiddenSynchronizedOutputActive + let touchesParsedFrame = active && data.length > 0 + let offset = 0 + + while (offset < scanData.length) { + const startIndex = scanData.indexOf(SYNCHRONIZED_OUTPUT_START_SEQUENCE, offset) + const endIndex = scanData.indexOf(SYNCHRONIZED_OUTPUT_END_SEQUENCE, offset) + if (startIndex === -1 && endIndex === -1) { + break + } + if (endIndex !== -1 && (startIndex === -1 || endIndex < startIndex)) { + if ( + active && + endIndex + SYNCHRONIZED_OUTPUT_END_SEQUENCE.length > currentChunkStartIndex + ) { + touchesParsedFrame = true + } + active = false + offset = endIndex + SYNCHRONIZED_OUTPUT_END_SEQUENCE.length + continue + } + if (startIndex !== -1) { + active = true + if (startIndex + SYNCHRONIZED_OUTPUT_START_SEQUENCE.length > currentChunkStartIndex) { + touchesParsedFrame = true + } + offset = startIndex + SYNCHRONIZED_OUTPUT_START_SEQUENCE.length + continue + } + } + + if (active && data.length > 0) { + touchesParsedFrame = true + } + hiddenSynchronizedOutputActive = active + hiddenSynchronizedOutputMarkerTail = scanData.slice(-SYNCHRONIZED_OUTPUT_MARKER_TAIL_CHARS) + return touchesParsedFrame + } + + function hiddenTuiRedrawOutputPrefersAtlasRecovery(data: string): boolean { + if (!data) { + return false + } + const scanData = hiddenRewriteCsiScanTail ? `${hiddenRewriteCsiScanTail}${data}` : data + const decision = terminalRewriteOutputRenderRefreshDecision(data, { + previousChunkEndsWithCarriageReturn: hiddenRewriteChunkEndedWithCarriageReturn, + previousRewriteCsiScanTail: hiddenRewriteCsiScanTail + }) + hiddenRewriteChunkEndedWithCarriageReturn = decision.nextChunkEndsWithCarriageReturn + hiddenRewriteCsiScanTail = decision.nextRewriteCsiScanTail + return decision.prefersRenderRefresh || containsCursorPositionSequence(scanData) + } + + function hiddenOutputNeedsAtlasRecoveryAfterParse(data: string): boolean { + if (!data) { + return false + } + ensureHiddenRendererRiskStateForCurrentPty() + const synchronizedOutputTouchesParsedFrame = hiddenSynchronizedOutputTouchesParsedFrame(data) + const tuiRedrawOutputPrefersAtlasRecovery = hiddenTuiRedrawOutputPrefersAtlasRecovery(data) + return synchronizedOutputTouchesParsedFrame || tuiRedrawOutputPrefersAtlasRecovery + } + // The replay path uses the guard so xterm auto-replies to embedded query // sequences don't leak into the shell. xterm.write() buffers internally // regardless of DOM visibility and the guard stays engaged via the @@ -3262,6 +3952,70 @@ export function connectPanePty( return replayIntoTerminalAsync(pane, deps.replayingPanesRef, data) } + const reattachReplayResetSequence = (): string => { + return shouldPreserveAgentReattachModes() + ? POST_REPLAY_LIVE_AGENT_REATTACH_RESET + : POST_REPLAY_REATTACH_RESET + } + + const consumeRestoredViewportBlankingMarker = (): boolean => { + return deps.restoredViewportBlankingPanesRef?.current.delete(pane.id) ?? false + } + + const writeFreshShellViewportBlanking = (): void => { + writeReplayData(buildFreshShellViewportBlankingSequence(pane.terminal.rows)) + } + + const prepareFreshShellViewportForSpawn = (options: FreshSpawnOptions): void => { + const hadRestoredViewport = consumeRestoredViewportBlankingMarker() + if (!options.forceBlankRestoredViewport && !hadRestoredViewport) { + return + } + // Why: fresh Windows ConPTY output paints at screen coordinates, so + // restored rows must leave the viewport before the first prompt redraw. + writeFreshShellViewportBlanking() + } + + const sendFocusedReattachFocusInAfterReplay = (): void => { + const scheduledGeneration = reattachReplayPayloadSignalGeneration + void waitForTerminalOutputParsed(pane.terminal).then(() => { + if (disposed) { + return + } + // Why: a newer replay frame owns the judgment; its own post-parse + // callback will re-evaluate against its own viewport. + if (scheduledGeneration !== reattachReplayPayloadSignalGeneration) { + return + } + // Why: the replay-byte signal also matches a dead run's screen — in + // scrollback or still painted above a fresh shell prompt. The parsed + // viewport is the ground truth; unless it shows a parked-cursor + // cursor-agent screen and no status/title corroborates, downgrade to + // the plain-shell behavior (drop focus reporting, skip focus-in). + if ( + !hasLiveAgentReattachStatusOrTitleSignal() && + reattachReplayPayloadHasCursorAgentSignal + ) { + if (parsedViewportShowsParkedCursorAgentScreen(pane.terminal) === false) { + reattachReplayPayloadHasCursorAgentSignal = false + writeReplayData(FOCUS_REPORTING_DISABLE_SEQUENCE) + return + } + } + // Why: a live TUI such as cursor-agent parks the real terminal cursor off + // its own input caret and moves it back only on a focus-in. Reattach + // reuses the same live PTY and the xterm textarea already holds DOM + // focus, so xterm never emits the focus-in the agent needs and the parked + // cursor anchors the IME/caret to the wrong cell. Gated on ?1004h so a + // bare shell never receives a stray \x1b[I. + const sendFocusMode = terminalHasFocusReportingEnabled(pane.terminal) + if (!shouldSendFocusedAgentReattachFocusIn() || !sendFocusMode) { + return + } + transport.sendInput(TERMINAL_FOCUS_IN_SEQUENCE) + }) + } + let replayWriteQueue = Promise.resolve() type PendingReplayData = { data: string @@ -3280,9 +4034,15 @@ export function connectPanePty( if (clearBeforeReplay) { await writeReplayDataAsync('\x1b[2J\x1b[3J\x1b[H') } + if (clearBeforeReplay || data.length > 0) { + // Why: an empty clearing frame is still an authoritative repaint and + // must clear a stale agent signal from an earlier payload. + rememberReattachPayloadAgentSignal(data, { fullScreenReplay: clearBeforeReplay }) + } await writeReplayDataAsync(data) if (clearBeforeReplay || data.length > 0) { - await writeReplayDataAsync(POST_REPLAY_REATTACH_RESET) + await writeReplayDataAsync(reattachReplayResetSequence()) + sendFocusedReattachFocusInAfterReplay() } if (disposed) { pendingReplayData = null @@ -3345,6 +4105,15 @@ export function connectPanePty( const shouldSnapshotHiddenCodexOutput = shouldKeepHiddenStartupRendererQueriesLive(paneStartup) let hiddenStartupRendererQueryPending = '' let hiddenRendererStateDirty = false + let hiddenRiskPtyId: string | null = null + let hiddenSynchronizedOutputActive = false + let hiddenSynchronizedOutputMarkerTail = '' + let hiddenRewriteChunkEndedWithCarriageReturn = false + let hiddenRewriteCsiScanTail = '' + let rendererOrderedPtyId: string | null = null + let rendererOrderedSeq: number | null = null + let rendererChannelSeqPtyId: string | null = null + let rendererChannelSeq: number | null = null function canUseMainBufferSnapshot(ptyId: string | null): ptyId is string { return Boolean(ptyId) && !isRemoteRuntimePtyId(ptyId) @@ -3470,6 +4239,25 @@ export function connectPanePty( return decision.prefersRenderRefresh } + // Why: Vim-style TUI redraws are plain-ASCII in-place rewrites whose erased + // cells can keep stale WebGL glyphs until the shared atlas rebuilds. Whether + // a rewrite touched the alternate screen is only authoritative once xterm + // parses the chunk (enter/exit sequences can split across PTY chunks), so + // capture the pre-parse state and decide the rest at parse completion. + function alternateScreenRewriteAtlasRecoveryOnParsed(): () => void { + const wasAlternateScreenBuffer = pane.terminal.buffer.active.type === 'alternate' + const switchesBeforeParse = alternateScreenBufferSwitches + return () => { + if ( + wasAlternateScreenBuffer || + alternateScreenBufferSwitches !== switchesBeforeParse || + pane.terminal.buffer.active.type === 'alternate' + ) { + scheduleTerminalWebglAtlasRecovery() + } + } + } + function shouldForceForegroundRenderRefresh(data: string): { refresh: boolean inPlaceRewrite: boolean @@ -3519,6 +4307,7 @@ export function connectPanePty( ): void { if (foreground) { resetHiddenOutputRestoreIfPtyChanged() + resetHiddenRendererRiskState() } const parseHiddenStartupOutput = !foreground && @@ -3552,6 +4341,17 @@ export function connectPanePty( const renderRefreshDecision = foregroundOutput ? shouldForceForegroundRenderRefresh(data) : { refresh: false, inPlaceRewrite: false, recoverWebglAtlasAfterParse: false } + const recoverHiddenWebglAtlasAfterParse = + !foregroundOutput && hiddenOutputNeedsAtlasRecoveryAfterParse(data) + const recoverWebglAtlasAfterParse = + renderRefreshDecision.recoverWebglAtlasAfterParse || recoverHiddenWebglAtlasAfterParse + // Why: atlas recovery must repaint from the parsed xterm buffer, not a + // pre-write snapshot that a late TUI redraw can immediately stale. + const onParsedAtlasRecovery = recoverWebglAtlasAfterParse + ? scheduleTerminalWebglAtlasRecovery + : renderRefreshDecision.inPlaceRewrite + ? alternateScreenRewriteAtlasRecoveryOnParsed() + : undefined const foregroundRenderRefreshNeeded = renderRefreshDecision.refresh // Why: see nativeWindowsRewriteNeedsFollowupRenderRefresh — Claude Code's // in-place prompt redraws on Windows ConPTY can paint one frame late, so a @@ -3598,11 +4398,7 @@ export function connectPanePty( foregroundRenderRefreshNeeded), followupForegroundRefresh: nativeWindowsCursorRestore || nativeWindowsInPlaceRewriteFollowup, - // Why: atlas recovery must repaint from the parsed xterm buffer, not - // a pre-write snapshot that a late TUI redraw can immediately stale. - onForegroundParsed: renderRefreshDecision.recoverWebglAtlasAfterParse - ? scheduleTerminalWebglAtlasRecovery - : undefined, + onParsed: onParsedAtlasRecovery, stripTransientCursorShows: shouldProtectNativeWindowsSynchronizedOutput && foreground, coalesceForeground: synchronizedForegroundOutput && synchronizedOutputEnded, holdForeground: synchronizedForegroundOutput && nextSynchronizedForegroundOutputActive @@ -3620,6 +4416,7 @@ export function connectPanePty( } function markHiddenOutputRestoreNeeded(): void { + resetSkippedHiddenRendererRiskState() const ptyId = transport.getPtyId() if (!canUseHiddenOutputSnapshot(ptyId)) { return @@ -3815,6 +4612,79 @@ export function connectPanePty( return chunk.data.slice(offset) } + function recordRendererOrderedSeq(meta?: Pick): void { + if (typeof meta?.seq !== 'number') { + return + } + const ptyId = transport.getPtyId() + if (!ptyId) { + return + } + if (rendererOrderedPtyId !== ptyId) { + rendererOrderedPtyId = ptyId + rendererOrderedSeq = meta.seq + return + } + rendererOrderedSeq = Math.max(rendererOrderedSeq ?? 0, meta.seq) + } + + resetRendererOrderedSeqForPtyExit = (exitedPtyId: string): void => { + // Why: an exit ends this ptyId's seq domain. A revived session can reuse + // the id with a restarted main-side counter, and a stale high-water mark + // would wrongly cover — and silently drop — every hidden byte it emits. + if (rendererOrderedPtyId === exitedPtyId) { + rendererOrderedPtyId = null + rendererOrderedSeq = null + } + if (rendererChannelSeqPtyId === exitedPtyId) { + rendererChannelSeqPtyId = null + rendererChannelSeq = null + } + } + + function observeRendererOrderedSeqRegression(meta: PtyDataMeta | undefined): void { + if (typeof meta?.seq !== 'number') { + return + } + const ptyId = transport.getPtyId() + if (!ptyId) { + return + } + if (rendererChannelSeqPtyId !== ptyId) { + rendererChannelSeqPtyId = ptyId + rendererChannelSeq = meta.seq + return + } + if (rendererChannelSeq !== null && meta.seq < rendererChannelSeq) { + // Why: pty:data delivery is FIFO per pty, so seq only moves backwards + // when the session was revived without an observed exit and restarted + // its counter. Drop the stale ordered baseline instead of letting it + // cover the new stream's bytes. + if (rendererOrderedPtyId === ptyId) { + rendererOrderedPtyId = null + rendererOrderedSeq = null + } + } + rendererChannelSeq = meta.seq + } + + function getHiddenRendererDataAfterOrderedSeq( + data: string, + meta: PtyDataMeta | undefined + ): string | null { + if ( + rendererOrderedPtyId === null || + rendererOrderedSeq === null || + transport.getPtyId() !== rendererOrderedPtyId + ) { + return data + } + return getChunkDataAfterSnapshot( + { data, seq: meta?.seq, rawLength: meta?.rawLength }, + rendererOrderedSeq + ) + } + function drainPendingLiveChunksAfterSnapshot(snapshotSeq: number | undefined): boolean { if (hiddenOutputRestorePendingOverflow) { hiddenOutputRestorePendingOverflow = false @@ -3838,6 +4708,7 @@ export function connectPanePty( } if (data) { writePtyOutputToXterm(data, true) + recordRendererOrderedSeq(chunk) } } if (hiddenOutputRestorePendingOverflow) { @@ -3932,6 +4803,7 @@ export function connectPanePty( hiddenOutputRestoreScheduled = false hiddenStartupRendererQueryPending = '' hiddenRendererStateDirty = false + resetHiddenRendererRiskState() cancelScheduledHiddenOutputRestore(pane.terminal) clearHiddenOutputRestoreDeferredRetryTimer() clearHiddenOutputRestoreForegroundDeadlineTimer() @@ -3982,6 +4854,7 @@ export function connectPanePty( clearPendingLiveChunksDuringRestore() hiddenStartupRendererQueryPending = '' hiddenRendererStateDirty = false + resetHiddenRendererRiskState() hiddenOutputRestoreNeeded = false hiddenOutputRestorePtyId = null hiddenOutputRestoreGeneration += 1 @@ -4014,6 +4887,7 @@ export function connectPanePty( function skipBackgroundAlternateScreenOutput(data: string): void { writeHiddenStartupRendererQueries(data) respondToSkippedMode2031Subscribe(data) + resetSkippedHiddenRendererRiskState() hiddenRendererStateDirty = true recordHiddenRendererSkip(data.length) const ptyId = transport.getPtyId() @@ -4085,10 +4959,19 @@ export function connectPanePty( // 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 { + // 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[?1049h\x1b[2J\x1b[H') } writeReplayData(snapshot.data) writeReplayData(POST_REPLAY_LIVE_SNAPSHOT_RESET) hiddenRendererStateDirty = false + recordRendererOrderedSeq(snapshot) + resetHiddenRendererRiskState() recordTerminalOutput(pane.terminal) const currentPtyId = transport.getPtyId() if (currentPtyId && !getFitOverrideForPty(currentPtyId)) { @@ -4316,6 +5199,21 @@ export function connectPanePty( meta, pendingForegroundQuery?.consumedCurrentChars ?? 0 ) + observeRendererOrderedSeqRegression(meta) + const orderedRendererData = foreground + ? rendererData + : getHiddenRendererDataAfterOrderedSeq(rendererData, rendererMeta) + if (orderedRendererData === null) { + // Why: renderer-side filtering cannot map cleaned text back onto raw + // sequence offsets. Rebuild from main instead of risking stale bytes. + markHiddenOutputRestoreNeeded() + schedulePendingStartupCommandDelivery() + return + } + if (!foreground && orderedRendererData.length === 0) { + schedulePendingStartupCommandDelivery() + return + } if (pendingForegroundQuery?.statelessQueryData) { writePtyOutputToXterm(pendingForegroundQuery.statelessQueryData, true, { hiddenStartupRendererQuery: true @@ -4334,11 +5232,11 @@ export function connectPanePty( meta?.background === true && shouldWritePtyOutputForeground(deps.isVisibleRef.current) && pane.terminal.buffer.active.type === 'alternate' && - !containsStatefulRendererQuery(data) + !containsStatefulRendererQuery(orderedRendererData) if (skipBackgroundAlternateScreenFrame) { - skipBackgroundAlternateScreenOutput(data) - } else if (shouldSkipHiddenRendererOutput(foreground, data)) { - skipHiddenRendererOutput(data) + skipBackgroundAlternateScreenOutput(orderedRendererData) + } else if (shouldSkipHiddenRendererOutput(foreground, orderedRendererData)) { + skipHiddenRendererOutput(orderedRendererData) } else if ( (hiddenOutputRestoreNeeded || hiddenOutputRestoreInFlight) && restoreAppliesToCurrentPty @@ -4347,9 +5245,10 @@ export function connectPanePty( if (pendingForegroundQuery?.statefulQueryData) { queueLiveChunkDuringRestore(pendingForegroundQuery.statefulQueryData) } - queueLiveChunkDuringRestore(rendererData, rendererMeta) + queueLiveChunkDuringRestore(orderedRendererData, rendererMeta) requestHiddenOutputRestoreIfNeeded() } else if (hiddenOutputRestoreInFlight) { + resetSkippedHiddenRendererRiskState() hiddenOutputRestoreNeeded = true hiddenOutputRestoreFreshSnapshotNeeded = true } @@ -4359,7 +5258,10 @@ export function connectPanePty( hiddenStartupRendererQuery: true }) } - writePtyOutputToXterm(rendererData, foreground) + writePtyOutputToXterm(orderedRendererData, foreground) + if (foreground) { + recordRendererOrderedSeq(rendererMeta) + } } schedulePendingStartupCommandDelivery() @@ -4401,7 +5303,9 @@ export function connectPanePty( if (staleSessionId) { deps.clearTabPtyId(deps.tabId, staleSessionId) } - startFreshColdRestoreAgentResume(coldRestoreStartup) + startFreshColdRestoreAgentResume(coldRestoreStartup, { + forceBlankRestoredViewport: true + }) return } registerEffectiveLaunchConfig(connectResult?.launchConfig, { @@ -4420,7 +5324,9 @@ export function connectPanePty( // Why: SSH sleep/reconnect can invalidate the relay-held PTY while // leaving the tab mounted. Replace the dead lease in-place instead of // stranding the pane behind a stale expired-session overlay. - startFreshColdRestoreAgentResume(coldRestoreStartup) + startFreshColdRestoreAgentResume(coldRestoreStartup, { + forceBlankRestoredViewport: true + }) return } setPanePtyFitBinding(ptyId) @@ -4446,12 +5352,14 @@ export function connectPanePty( // 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 }) writeReplayData('\x1b[2J\x1b[3J\x1b[H') writeReplayData(connectResult.snapshot) // Snapshot reattach keeps a live session, so avoid the broader mode - // reset. We only drop stale cursor/focus state that should not leak - // from replay bytes into the restored renderer terminal. - writeReplayData(POST_REPLAY_REATTACH_RESET) + // reset. We only drop renderer-owned state that should not leak from + // replay bytes into the restored renderer terminal. + writeReplayData(reattachReplayResetSequence()) + sendFocusedReattachFocusInAfterReplay() if (connectResult.coldRestore) { // Snapshot superseded the cold-restore payload — ack it so the // daemon does not redeliver it on the next reattach. @@ -4460,27 +5368,25 @@ export function connectPanePty( } } } 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 prevents stale cursor/focus mode - // bits in the replayed data from leaking into the restored terminal. + // 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') writeReplayData(connectResult.replay) - writeReplayData(POST_REPLAY_REATTACH_RESET) + writeReplayData(reattachReplayResetSequence()) + sendFocusedReattachFocusInAfterReplay() if (connectResult.coldRestore) { if (!isRemoteRuntimePtyId(ptyId)) { window.api.pty.ackColdRestore(ptyId) } } } else if (connectResult?.coldRestore) { - // restoreScrollbackBuffers() already wrote the saved xterm buffer - // before this rAF ran. The cold-restore scrollback overlaps with - // that content; clear first. // 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('\x1b[2J\x1b[3J\x1b[H') writeReplayData(connectResult.coldRestore.scrollback) const preparedStartup = coldRestoreStartup ?? buildColdRestoreAgentResumeStartup() const didPrepareResume = applyColdRestoreAgentResumeStartup(preparedStartup) @@ -4495,6 +5401,8 @@ export function connectPanePty( // 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) + consumeRestoredViewportBlankingMarker() + writeFreshShellViewportBlanking() if (!isRemoteRuntimePtyId(ptyId)) { window.api.pty.ackColdRestore(ptyId) } @@ -4740,7 +5648,9 @@ export function connectPanePty( } deps.clearExitedPanePtyLayoutBinding(pane.id, pendingSessionId) deps.clearTabPtyId(deps.tabId, pendingSessionId) - startFreshColdRestoreAgentResume(coldRestoreStartup) + startFreshColdRestoreAgentResume(coldRestoreStartup, { + forceBlankRestoredViewport: true + }) return } handleReattachResult(result, pendingSessionId, coldRestoreStartup) @@ -4763,10 +5673,14 @@ export function connectPanePty( if (isSshSessionExpiredError(err)) { deps.clearExitedPanePtyLayoutBinding(pane.id, pendingSessionId) deps.clearTabPtyId(deps.tabId, pendingSessionId) - startFreshColdRestoreAgentResume(coldRestoreStartup) + startFreshColdRestoreAgentResume(coldRestoreStartup, { + forceBlankRestoredViewport: true + }) return } - startFreshColdRestoreAgentResume(coldRestoreStartup) + startFreshColdRestoreAgentResume(coldRestoreStartup, { + forceBlankRestoredViewport: true + }) }) } else { startFreshColdRestoreAgentResume() @@ -4909,7 +5823,9 @@ export function connectPanePty( } deps.clearExitedPanePtyLayoutBinding(pane.id, deferredReattachSessionId) deps.clearTabPtyId(deps.tabId, deferredReattachSessionId) - startFreshColdRestoreAgentResume(coldRestoreStartup) + startFreshColdRestoreAgentResume(coldRestoreStartup, { + forceBlankRestoredViewport: true + }) return } handleReattachResult(result, deferredReattachSessionId, coldRestoreStartup) @@ -4937,11 +5853,15 @@ export function connectPanePty( deps.clearExitedPanePtyLayoutBinding(pane.id, deferredReattachSessionId) deps.clearTabPtyId(deps.tabId, deferredReattachSessionId) if (connectionId && isSshSessionExpiredError(err)) { - startFreshColdRestoreAgentResume(coldRestoreStartup) + startFreshColdRestoreAgentResume(coldRestoreStartup, { + forceBlankRestoredViewport: true + }) return } reportError(message) - startFreshColdRestoreAgentResume(coldRestoreStartup) + startFreshColdRestoreAgentResume(coldRestoreStartup, { + forceBlankRestoredViewport: true + }) }) } else if (detachedRemoteLeafPtyId || detachedLivePtyId || eagerLivePtyId) { // Why: mirrored web terminal layouts mount one pane per host leaf. @@ -5083,49 +6003,49 @@ export function connectPanePty( onExit(currentPtyId) } - // Why (perf + startup correctness): listSessions() is authoritative only - // after a real visibility resume. Fresh PTY startup can briefly lag the daemon - // listing, so newborn terminals start disarmed and noteVisibilityResume grants - // exactly one first-input liveness probe for the next resume window. - let livenessRecheckArmedForResume = false - - // Why (Defect #2 defense-in-depth): in the broken state sendInput returns - // true (connected/ptyId still set) so the dropped keystroke is invisible to - // the renderer. A fire-and-forget liveness re-check on the FIRST input after - // a resume cleans the pane up promptly instead of waiting for the resume - // pass alone. It REDUCES but cannot eliminate the first-keystroke drop (that - // byte is already gone daemon-side). - const recheckLivenessAfterInput = (): void => { - if (disposed || !livenessRecheckArmedForResume) { - return - } - // Why: consume the resume token before inspecting provider details so SSH, - // remote-runtime, and concurrent keystrokes cannot retry this hot-path check - // until the lifecycle reports another true hidden-to-visible resume. - livenessRecheckArmedForResume = false - const currentPtyId = transport.getPtyId() - const currentConnectionId = transport.getConnectionId?.() + const reconcileIfSessionMissing = ( + hasPty: HasPty, + livenessRequestedAt = performance.now() + ): void => { + const requestedPtyId = transport.getPtyId() if ( - !currentPtyId || - // Why: this ptyId's exit was already handled — nothing left to reconcile. - handledExitPtyId === currentPtyId || - // Why: `remote:` web-runtime liveness is owned by the host snapshot, not - // listSessions; skip here so a remote pane's keystrokes never put a local - // daemon round-trip on the typing hot path (reconcile would no-op anyway). - isRemoteRuntimePtyId(currentPtyId) || - (currentConnectionId !== null && currentConnectionId !== undefined) + !requestedPtyId || + requestedPtyId === handledExitPtyId || + requestedPtyId.startsWith(REMOTE_PTY_ID_PREFIX) || + transport.getConnectionId?.() != null ) { return } - // Why: capture request time before the round-trip so a pane that bound after - // this request is not torn down by its (pre-bind) stale snapshot. - const requestedAt = performance.now() - void window.api.pty - .listSessions() - .then((sessions) => { - reconcileIfSessionDead(new Set(sessions.map((session) => session.id)), requestedAt) + + let livenessPromise: Promise + try { + livenessPromise = Promise.resolve(hasPty(requestedPtyId)) + } catch { + return + } + + void livenessPromise + .then((isLive) => { + if (disposed) { + return + } + const currentPtyId = transport.getPtyId() + if ( + !currentPtyId || + currentPtyId !== requestedPtyId || + handledExitPtyId === currentPtyId || + !shouldReconcileMissingSession({ + ptyId: currentPtyId, + connectionId: transport.getConnectionId?.(), + isLive, + ptyBoundAt: activePanePtyBindingBoundAt, + livenessRequestedAt + }) + ) { + return + } + onExit(currentPtyId) }) - // Why: a rejected listing is "unknown" — never close a pane on it. .catch(() => {}) } @@ -5133,17 +6053,15 @@ export function connectPanePty( syncProcessTracking() { agentCompletionCoordinator.startProcessTracking() }, - // Why: re-arm the once-per-resume input re-check when the pane becomes - // visible again. Called from the lifecycle visibility effect; the gate - // keeps the typing hot path off the listSessions IPC between resumes. + // Why: called from the lifecycle visibility effect so the visible-resume + // size readback can repair dropped hidden resizes without refitting against + // xterm's transient hidden DOM fallback. noteVisibilityResume() { - livenessRecheckArmedForResume = true - // Why: the visibility-resume path reattaches WebGL before doing the - // authoritative fit. Fitting here can measure xterm's hidden DOM fallback - // and send a transient narrow SIGWINCH before the renderer is restored. ptySizeReassertion.request({ fit: false }) + consumeHibernatedAgentWake() }, reconcileIfSessionDead, + reconcileIfSessionMissing, dispose() { disposed = true // Why: the post-spawn reconcile polls across frames; cancel its pending @@ -5220,8 +6138,10 @@ export function connectPanePty( connectFallbackTimer = null } onDataDisposable.dispose() + userInputActivityDisposable?.dispose() terminalCapabilityRepliesDisposable.dispose() onResizeDisposable.dispose() + onBufferChangeDisposable?.dispose() pane.container.removeEventListener(PANE_PTY_RESIZE_HOLD_FLUSH_EVENT, onHeldPtyResizeFlush) geometryReportObserver?.disconnect() if (pendingGeometryReportRaf !== null) { @@ -5229,6 +6149,7 @@ export function connectPanePty( pendingGeometryReportRaf = null } commandLifecycle.dispose() + paneForegroundAgentTracker.dispose() agentCompletionCoordinator.dispose() } } diff --git a/src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts b/src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts new file mode 100644 index 00000000000..ddf856f05ae --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from 'vitest' +import { + TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS, + createPtyInputWriteQueue +} from './pty-input-write-queue' +import { + TERMINAL_INPUT_CHUNK_MAX_BYTES, + TERMINAL_INPUT_MAX_BYTES +} from '../../../../shared/terminal-input' + +const WHEEL_UP_REPORT = '\x1b[<64;60;20M' + +type WriteRecord = { id: string; data: string } + +function createRecordingQueue(options: { writable?: () => boolean } = {}): { + writes: WriteRecord[] + queue: ReturnType +} { + const writes: WriteRecord[] = [] + const queue = createPtyInputWriteQueue({ + isWritable: () => options.writable?.() ?? true, + write: (id, data) => writes.push({ id, data }) + }) + return { writes, queue } +} + +describe('pty input write queue', () => { + it('coalesces a dense burst of wheel reports instead of one write per macrotask turn', async () => { + const { writes, queue } = createRecordingQueue() + + // Simulates a 2s aggressive trackpad gesture at 120Hz: 240 SGR reports + // enqueued while the drain cannot run between events. + for (let i = 0; i < 240; i += 1) { + expect(queue.enqueue('pty-1', WHEEL_UP_REPORT)).toBe(true) + } + await queue.waitForDrain() + + // First report flushes immediately (keystroke latency); everything queued + // behind it must drain as a single coalesced write, not 239 timer turns. + expect(writes.length).toBe(2) + expect(writes[0]?.data).toBe(WHEEL_UP_REPORT) + expect(writes[1]?.data).toBe(WHEEL_UP_REPORT.repeat(239)) + expect(writes.map((write) => write.id)).toEqual(['pty-1', 'pty-1']) + }) + + it('preserves byte order and content across coalesced writes', async () => { + const { writes, queue } = createRecordingQueue() + + const inputs = ['a', '\x1b[<65;1;1M', 'bc', '\x1b[A', 'd'] + for (const input of inputs) { + queue.enqueue('pty-1', input) + } + await queue.waitForDrain() + + expect(writes.map((write) => write.data).join('')).toBe(inputs.join('')) + }) + + it('does not coalesce across different PTY ids', async () => { + const writes: WriteRecord[] = [] + const queue = createPtyInputWriteQueue({ + isWritable: () => true, + write: (id, data) => writes.push({ id, data }) + }) + + queue.enqueue('pty-1', 'a') + queue.enqueue('pty-1', 'b') + queue.enqueue('pty-2', 'c') + queue.enqueue('pty-1', 'd') + await queue.waitForDrain() + + expect(writes).toEqual([ + { id: 'pty-1', data: 'a' }, + { id: 'pty-1', data: 'b' }, + { id: 'pty-2', data: 'c' }, + { id: 'pty-1', data: 'd' } + ]) + }) + + it('keeps coalesced payloads under the input chunk byte cap', async () => { + const { writes, queue } = createRecordingQueue() + + const piece = 'x'.repeat(1000) + for (let i = 0; i < 12; i += 1) { + queue.enqueue('pty-1', piece) + } + await queue.waitForDrain() + + expect(writes.map((write) => write.data).join('')).toBe(piece.repeat(12)) + for (const write of writes) { + expect(write.data.length).toBeLessThanOrEqual(TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS) + } + expect(writes.length).toBeGreaterThan(1) + }) + + it('still chunks oversized items and keeps trailing input ordered behind them', async () => { + const { writes, queue } = createRecordingQueue() + + const large = 'y'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES * 2 + 100) + queue.enqueue('pty-1', 'before') + queue.enqueue('pty-1', large) + queue.enqueue('pty-1', 'after') + await queue.waitForDrain() + + expect(writes.map((write) => write.data).join('')).toBe(`before${large}after`) + expect(writes.at(-1)?.data).toBe('after') + for (const write of writes) { + expect(write.data.length).toBeLessThanOrEqual(TERMINAL_INPUT_CHUNK_MAX_BYTES) + } + }) + + it('rejects input over the terminal input byte limit without writing', async () => { + const { writes, queue } = createRecordingQueue() + + expect(queue.enqueue('pty-1', 'z'.repeat(TERMINAL_INPUT_MAX_BYTES + 1))).toBe(false) + await queue.waitForDrain() + + expect(writes).toEqual([]) + }) + + it('drops queued input for PTYs that are no longer writable', async () => { + let writable = true + const { writes, queue } = createRecordingQueue({ writable: () => writable }) + + queue.enqueue('pty-1', 'a') + writable = false + queue.enqueue('pty-1', 'b') + await queue.waitForDrain() + + expect(writes).toEqual([{ id: 'pty-1', data: 'a' }]) + }) + + it('clear() drops pending input that has not been written yet', async () => { + const writes: WriteRecord[] = [] + const pendingYields: (() => void)[] = [] + const queue = createPtyInputWriteQueue({ + isWritable: () => true, + write: (id, data) => writes.push({ id, data }), + yieldBetweenWrites: () => + new Promise((resolve) => { + pendingYields.push(resolve) + }) + }) + + const large = 'y'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES * 3) + queue.enqueue('pty-1', large) + queue.enqueue('pty-1', 'tail') + // First chunk is written synchronously, then the drain parks on the yield. + expect(writes.length).toBe(1) + + queue.clear() + pendingYields.shift()?.() + await queue.waitForDrain() + + expect(writes.length).toBe(1) + expect(writes.map((write) => write.data).join('')).not.toContain('tail') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-input-write-queue.ts b/src/renderer/src/components/terminal-pane/pty-input-write-queue.ts new file mode 100644 index 00000000000..b20e764df15 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-input-write-queue.ts @@ -0,0 +1,155 @@ +import { + isTerminalInputTooLargeWithDeferredMeasurement, + iterateTerminalInputChunks +} from '../../../../shared/terminal-input' + +// Why: 4096 UTF-16 code units encode to at most ~12KB UTF-8, safely under the +// 16KB TERMINAL_INPUT_CHUNK_MAX_BYTES cap without paying byte measurement on +// the hot input path. +export const TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS = 4096 + +type PendingPtyInputWrite = { + id: string + text: string + tooLarge: boolean | Promise + chunks?: Iterator + nextChunk?: string +} + +export type PtyInputWriteQueue = { + enqueue: (id: string, data: string) => boolean + waitForDrain: () => Promise + clear: () => void +} + +export type PtyInputWriteQueueDeps = { + isWritable: (id: string) => boolean + write: (id: string, data: string) => void + yieldBetweenWrites?: () => Promise +} + +function defaultYieldBetweenWrites(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)) +} + +function isCoalescibleText(text: string): boolean { + return text.length <= TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS +} + +export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInputWriteQueue { + const yieldBetweenWrites = deps.yieldBetweenWrites ?? defaultYieldBetweenWrites + let pending: PendingPtyInputWrite[] = [] + let drainPromise: Promise | null = null + + async function drain(): Promise { + while (pending.length > 0) { + const next = pending[0] + if (!next) { + pending.shift() + continue + } + if (!deps.isWritable(next.id)) { + pending.shift() + continue + } + if (next.tooLarge !== false) { + next.tooLarge = await Promise.resolve(next.tooLarge).catch(() => true) + if (next.tooLarge) { + pending.shift() + continue + } + if (!deps.isWritable(next.id)) { + pending.shift() + continue + } + } + // Why: dense input streams (SGR wheel reports during trackpad momentum, + // key auto-repeat) enqueue one tiny item per event. Writing one item per + // macrotask turn lets Chromium's nested-timer clamp pace the drain at + // ≥4ms per item, so a fast gesture's reports reach the PTY seconds after + // the gesture ended and the TUI visibly replays them one by one. + // Coalescing consecutive validated small items into a single write keeps + // the PTY byte stream identical while draining the backlog in one turn. + if (next.chunks === undefined && isCoalescibleText(next.text)) { + let payload = next.text + pending.shift() + while (pending.length > 0) { + const peek = pending[0] + if ( + !peek || + peek.id !== next.id || + peek.tooLarge !== false || + peek.chunks !== undefined || + !isCoalescibleText(peek.text) || + payload.length + peek.text.length > TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS + ) { + break + } + payload += peek.text + pending.shift() + } + deps.write(next.id, payload) + if (pending.length > 0) { + await yieldBetweenWrites() + } + continue + } + next.chunks ??= iterateTerminalInputChunks(next.text) + const chunk = + next.nextChunk === undefined ? next.chunks.next() : { done: false, value: next.nextChunk } + next.nextChunk = undefined + if (chunk.done) { + pending.shift() + continue + } + deps.write(next.id, chunk.value) + const following = next.chunks.next() + if (following.done) { + pending.shift() + } else { + next.nextChunk = following.value + } + if (pending.length > 0) { + await yieldBetweenWrites() + } + } + } + + function scheduleDrain(): void { + if (drainPromise) { + return + } + drainPromise = drain().finally(() => { + drainPromise = null + if (pending.length > 0) { + scheduleDrain() + } + }) + } + + return { + enqueue(id: string, data: string): boolean { + try { + const tooLarge = isTerminalInputTooLargeWithDeferredMeasurement(data) + if (tooLarge === true) { + return false + } + pending.push({ id, text: data, tooLarge }) + scheduleDrain() + return true + } catch { + return false + } + }, + + async waitForDrain(): Promise { + while (drainPromise) { + await drainPromise + } + }, + + clear(): void { + pending = [] + } + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index 474ee267832..f15f6ea990b 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -23,6 +23,7 @@ import { getEagerPtyBufferHandle } from './pty-dispatcher' import { drainPreHandlerPtyData, drainPreHandlerPtyExit } from './pty-pre-handler-buffer' +import { createPtyInputWriteQueue } from './pty-input-write-queue' import type { PtyDataMeta } from './pty-dispatcher' import type { IpcPtyTransportOptions, PtyConnectResult, PtyTransport } from './pty-transport-types' import { createBellDetector } from './bell-detector' @@ -59,14 +60,6 @@ const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED' const STALE_TITLE_TIMEOUT = 3000 // ms before stale working title is cleared const MAX_PTY_SIDE_EFFECTS_PER_DRAIN = 64 -type PendingPtyInputWrite = { - id: string - text: string - tooLarge: boolean | Promise - chunks?: Iterator - nextChunk?: string -} - // Why: onAgentStatus callback added to IpcPtyTransportOptions in pty-dispatcher // so the OSC 9999 status payloads can be forwarded to the store. @@ -472,8 +465,10 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra // unread marks, or notifications for unrelated worktrees just because Orca // is reconnecting background terminals on launch. let suppressAttentionEvents = false - let pendingInputWrites: PendingPtyInputWrite[] = [] - let inputWriteDrainPromise: Promise | null = null + const inputWriteQueue = createPtyInputWriteQueue({ + isWritable: (id) => connected && ptyId === id, + write: (id, data) => window.api.pty.write(id, data) + }) const outputProcessor = createPtyOutputProcessor({ onTitleChange, onBell, @@ -541,84 +536,6 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra return new Promise((resolve) => setTimeout(resolve, 0)) } - async function drainPendingInputWrites(): Promise { - while (pendingInputWrites.length > 0) { - const next = pendingInputWrites[0] - if (!next) { - continue - } - if (!connected || ptyId !== next.id) { - pendingInputWrites.shift() - continue - } - if (next.tooLarge !== false) { - next.tooLarge = await Promise.resolve(next.tooLarge).catch(() => true) - if (next.tooLarge) { - pendingInputWrites.shift() - continue - } - if (!connected || ptyId !== next.id) { - pendingInputWrites.shift() - continue - } - } - next.chunks ??= iterateTerminalInputChunks(next.text) - const chunk = - next.nextChunk === undefined ? next.chunks.next() : { done: false, value: next.nextChunk } - next.nextChunk = undefined - if (chunk.done) { - pendingInputWrites.shift() - continue - } - window.api.pty.write(next.id, chunk.value) - const following = next.chunks.next() - if (following.done) { - pendingInputWrites.shift() - } else { - next.nextChunk = following.value - } - if (pendingInputWrites.length > 0) { - await yieldToInputWriteDrain() - } - } - } - - function schedulePendingInputWriteDrain(): void { - if (inputWriteDrainPromise) { - return - } - inputWriteDrainPromise = drainPendingInputWrites().finally(() => { - inputWriteDrainPromise = null - if (pendingInputWrites.length > 0) { - schedulePendingInputWriteDrain() - } - }) - } - - function clearPendingInputWrites(): void { - pendingInputWrites = [] - } - - function enqueuePtyInputWrite(id: string, data: string): boolean { - try { - const tooLarge = isTerminalInputTooLargeWithDeferredMeasurement(data) - if (tooLarge === true) { - return false - } - pendingInputWrites.push({ id, text: data, tooLarge }) - schedulePendingInputWriteDrain() - return true - } catch { - return false - } - } - - async function waitForPendingInputWrites(): Promise { - while (inputWriteDrainPromise) { - await inputWriteDrainPromise - } - } - async function writeAcceptedPtyInput(id: string, data: string): Promise { try { const tooLarge = isTerminalInputTooLargeWithDeferredMeasurement(data) @@ -887,7 +804,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra disconnect() { clearAccumulatedState() - clearPendingInputWrites() + inputWriteQueue.clear() if (ptyId) { const id = ptyId window.api.pty.kill(id) @@ -900,7 +817,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra detach() { clearAccumulatedState() - clearPendingInputWrites() + inputWriteQueue.clear() if (ptyId) { // Why: detach() is used for in-session remounts such as moving a tab // between split groups. Stop delivering data/title events into the @@ -918,7 +835,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra if (!connected || !ptyId) { return false } - return enqueuePtyInputWrite(ptyId, data) + return inputWriteQueue.enqueue(ptyId, data) }, ...(connectionId @@ -929,7 +846,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra return false } const id = ptyId - await waitForPendingInputWrites() + await inputWriteQueue.waitForDrain() if (!connected || ptyId !== id) { return false } diff --git a/src/renderer/src/components/terminal-pane/terminal-alternate-screen-parse.test.ts b/src/renderer/src/components/terminal-pane/terminal-alternate-screen-parse.test.ts new file mode 100644 index 00000000000..7ce6e3854bd --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-alternate-screen-parse.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { Terminal } from '@xterm/headless' + +function writeChunk(term: Terminal, data: string): Promise { + return new Promise((resolve) => term.write(data, resolve)) +} + +// Pins the xterm contract the alternate-screen atlas recovery relies on: by the +// time a chunk's write callback runs, buffer.active.type reflects any +// alternate-screen enter/exit parsed from that chunk — even when the sequence +// splits across PTY chunk boundaries. +describe('alternate-screen buffer state at write-callback time', () => { + it('reflects an enter sequence split across two chunks', async () => { + const term = new Terminal({ cols: 120, rows: 34, allowProposedApi: true }) + await writeChunk(term, '\x1b[?104') + expect(term.buffer.active.type).toBe('normal') + await writeChunk(term, '9h\x1b[2J\x1b[H~\x1b[K') + expect(term.buffer.active.type).toBe('alternate') + term.dispose() + }) + + it('fires onBufferChange for each switch when one chunk enters and exits', async () => { + const term = new Terminal({ cols: 120, rows: 34, allowProposedApi: true }) + let switches = 0 + const disposable = term.buffer.onBufferChange(() => { + switches += 1 + }) + await writeChunk(term, '\x1b[?1049h\x1b[2J\x1b[Hpager frame\x1b[K\x1b[?1049l') + expect(term.buffer.active.type).toBe('normal') + expect(switches).toBe(2) + disposable.dispose() + term.dispose() + }) + + it('tracks enter, split redraw, and exit from a real captured vim session', async () => { + const term = new Terminal({ cols: 120, rows: 34, allowProposedApi: true }) + // Captured from `vim package.json` (macOS, TERM=xterm-256color): startup chunk. + await writeChunk( + term, + '\x1b[?1049h\x1b[>4;2m\x1b[?1h\x1b=\x1b[?2004h\x1b[?1004h\x1b[1;34r\x1b[?12h\x1b[?12l\x1b[22;2t\x1b[22;1t' + ) + expect(term.buffer.active.type).toBe('alternate') + // Mid-session redraw where a 1024-byte PTY read split \x1b[30;5H in two. + await writeChunk(term, '"rules": {\x1b[29;15H\x1b[K\x1b[30') + await writeChunk( + term, + ';5H "js-combine-iterations": "off"\r\n }\x1b[31;6H\x1b[K\x1b[33;1H\x1b[?25h' + ) + expect(term.buffer.active.type).toBe('alternate') + // Vim quit: erase the status line and restore the normal buffer in one chunk. + await writeChunk( + term, + '\x1b[23;2t\x1b[23;1t\x1b[34;1H\x1b[K\x1b[34;1H\x1b[?1004l\x1b[?2004l\x1b[?1l\x1b>\x1b[?1049l\x1b[?25h\x1b[>4;m' + ) + expect(term.buffer.active.type).toBe('normal') + term.dispose() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-command-lifecycle.ts b/src/renderer/src/components/terminal-pane/terminal-command-lifecycle.ts index 3fb103efeac..41873fa4fd9 100644 --- a/src/renderer/src/components/terminal-pane/terminal-command-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/terminal-command-lifecycle.ts @@ -2,6 +2,8 @@ import type { Terminal, IDisposable } from '@xterm/xterm' type TerminalCommandLifecycleOptions = { onCommandFinished: (bestEffortExitCode: number | null) => void + /** OSC 133;C — the shell exec'd a command; the pane's foreground changed. */ + onCommandStarted?: () => void } type OscTerminator = { @@ -54,6 +56,10 @@ export function createTerminalCommandLifecycle(options: TerminalCommandLifecycle const handleOsc133 = (payload: string): void => { const [sequence, exitCode] = payload.split(';') + if (sequence === 'C') { + options.onCommandStarted?.() + return + } if (sequence === 'D') { options.onCommandFinished(parseBestEffortExitCode(exitCode)) } diff --git a/src/renderer/src/components/terminal-pane/terminal-ctrl-arrow-conpty.test.ts b/src/renderer/src/components/terminal-pane/terminal-ctrl-arrow-conpty.test.ts new file mode 100644 index 00000000000..8c784bdf3cf --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-ctrl-arrow-conpty.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { + isLocalWindowsConptyPaneForCtrlArrow, + type TerminalCtrlArrowConptyState +} from './terminal-ctrl-arrow-conpty' +import type { PtyTransport } from './pty-transport-types' + +const WINDOWS_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' + +function state( + overrides: Partial = {} +): TerminalCtrlArrowConptyState { + return { + repos: [], + worktreesByRepo: {}, + folderWorkspaces: [], + projectGroups: [], + settings: null, + tabsByWorktree: {}, + ...overrides + } +} + +function localTransport( + metadata: { cwd?: string; shellOverride?: string } = {} +): Pick { + return { + getPtyId: () => 'pty-1', + getConnectionId: () => null, + getLocalSessionMetadata: () => metadata + } +} + +function remoteRuntimeTransport(): Pick< + PtyTransport, + 'getPtyId' | 'getConnectionId' | 'getLocalSessionMetadata' +> { + return { + getPtyId: () => 'remote:env-1@@terminal-1', + getConnectionId: () => null, + getLocalSessionMetadata: () => null + } +} + +describe('isLocalWindowsConptyPaneForCtrlArrow', () => { + it('uses live local session metadata for synthetic floating terminals with no repo record', () => { + expect( + isLocalWindowsConptyPaneForCtrlArrow({ + isWindows: true, + userAgent: WINDOWS_UA, + state: state({ + tabsByWorktree: { + [FLOATING_TERMINAL_WORKTREE_ID]: [{ id: 'tab-1', shellOverride: 'powershell.exe' }] + } + }), + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + tabId: 'tab-1', + paneId: 1, + paneCwd: new Map(), + fallbackCwd: 'C:\\Users\\me', + transport: localTransport({ cwd: 'C:\\Users\\me', shellOverride: 'powershell.exe' }) + }) + ).toBe(true) + }) + + it('keeps an already-launched local PowerShell pane local after store ownership changes', () => { + const worktreeId = 'repo-1::C:\\repo' + expect( + isLocalWindowsConptyPaneForCtrlArrow({ + isWindows: true, + userAgent: WINDOWS_UA, + state: state({ + settings: { activeRuntimeEnvironmentId: 'env-1' }, + tabsByWorktree: { + [worktreeId]: [{ id: 'tab-1', shellOverride: 'wsl.exe' }] + } + }), + worktreeId, + tabId: 'tab-1', + paneId: 1, + paneCwd: new Map([[1, { cwd: '\\\\wsl.localhost\\Ubuntu\\home\\me', confirmed: true }]]), + fallbackCwd: '\\\\wsl.localhost\\Ubuntu\\home\\me', + transport: localTransport({ cwd: 'C:\\repo', shellOverride: 'powershell.exe' }) + }) + ).toBe(true) + }) + + it('does not treat a live remote-runtime terminal as local ConPTY', () => { + expect( + isLocalWindowsConptyPaneForCtrlArrow({ + isWindows: true, + userAgent: WINDOWS_UA, + state: state(), + worktreeId: 'repo-1::/remote/repo', + tabId: 'tab-1', + paneId: 1, + paneCwd: new Map([[1, { cwd: '/remote/repo', confirmed: true }]]), + fallbackCwd: '/remote/repo', + transport: remoteRuntimeTransport() + }) + ).toBe(false) + }) + + it('stays conservative for unresolved repo-backed panes before a live session exists', () => { + expect( + isLocalWindowsConptyPaneForCtrlArrow({ + isWindows: true, + userAgent: WINDOWS_UA, + state: state(), + worktreeId: 'missing-repo::/home/me/repo', + tabId: 'tab-1', + paneId: 1, + paneCwd: new Map([[1, { cwd: '/home/me/repo', confirmed: true }]]), + fallbackCwd: '/home/me/repo', + transport: null + }) + ).toBe(false) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-ctrl-arrow-conpty.ts b/src/renderer/src/components/terminal-pane/terminal-ctrl-arrow-conpty.ts new file mode 100644 index 00000000000..d2bc848886b --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-ctrl-arrow-conpty.ts @@ -0,0 +1,89 @@ +import type { ExecutionHostId } from '../../../../shared/execution-host' +import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' +import { getConnectionIdFromState } from '@/lib/connection-context' +import { + getExecutionHostIdForWorktree, + type WorktreeRuntimeOwnerState +} from '@/lib/worktree-runtime-owner' +import { isLocalNativeWindowsConpty } from '@/lib/pane-manager/windows-pty-compatibility' +import type { PaneCwdMap } from './resolve-split-cwd' +import type { PtyTransport } from './pty-transport-types' + +const REMOTE_RUNTIME_PTY_ID_PREFIX = 'remote:' + +type TerminalTabShellState = { + tabsByWorktree: Record< + string, + readonly { id: string; shellOverride?: string | null }[] | undefined + > +} + +export type TerminalCtrlArrowConptyState = Parameters[0] & + WorktreeRuntimeOwnerState & + TerminalTabShellState + +type TerminalCtrlArrowConptyTransport = Pick< + PtyTransport, + 'getPtyId' | 'getConnectionId' | 'getLocalSessionMetadata' +> + +type TerminalCtrlArrowConptyArgs = { + isWindows: boolean + userAgent: string + state: TerminalCtrlArrowConptyState + worktreeId: string + tabId: string + paneId: number + paneCwd: PaneCwdMap + fallbackCwd: string + transport: TerminalCtrlArrowConptyTransport | null +} + +function isRemoteRuntimePtyId(ptyId: string): boolean { + return ptyId.startsWith(REMOTE_RUNTIME_PTY_ID_PREFIX) +} + +export function isLocalWindowsConptyPaneForCtrlArrow({ + isWindows, + userAgent, + state, + worktreeId, + tabId, + paneId, + paneCwd, + fallbackCwd, + transport +}: TerminalCtrlArrowConptyArgs): boolean { + if (!isWindows) { + return false + } + + const ptyId = transport?.getPtyId() ?? null + if (ptyId !== null && isRemoteRuntimePtyId(ptyId)) { + return false + } + + const sessionMetadata = transport?.getLocalSessionMetadata?.() + const hasLiveLocalSession = + ptyId !== null && sessionMetadata !== null && sessionMetadata !== undefined + const transportConnectionId = transport?.getConnectionId?.() + const connectionId = hasLiveLocalSession + ? null + : transportConnectionId === undefined + ? getConnectionIdFromState(state, worktreeId) + : transportConnectionId + const tabShellOverride = state.tabsByWorktree[worktreeId]?.find( + (candidate) => candidate.id === tabId + )?.shellOverride + const executionHostId: ExecutionHostId = hasLiveLocalSession + ? LOCAL_EXECUTION_HOST_ID + : getExecutionHostIdForWorktree(state, worktreeId) + + return isLocalNativeWindowsConpty({ + userAgent, + connectionId, + cwd: sessionMetadata?.cwd ?? paneCwd.get(paneId)?.cwd ?? fallbackCwd, + shellOverride: sessionMetadata?.shellOverride ?? tabShellOverride, + executionHostId + }) +} diff --git a/src/renderer/src/components/terminal-pane/terminal-dead-session-reconcile.test.ts b/src/renderer/src/components/terminal-pane/terminal-dead-session-reconcile.test.ts index 9e5c5d2bc1f..163252403ca 100644 --- a/src/renderer/src/components/terminal-pane/terminal-dead-session-reconcile.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-dead-session-reconcile.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it, vi } from 'vitest' import { reconcileDeadSessions, + reconcileMissingSessions, + shouldReconcileMissingSession, shouldReconcileDeadSession } from './terminal-dead-session-reconcile' @@ -128,6 +130,80 @@ describe('shouldReconcileDeadSession', () => { }) }) +describe('shouldReconcileMissingSession', () => { + it('reconciles only an authoritative missing local PTY', () => { + expect( + shouldReconcileMissingSession({ + ptyId: 'wt@@dead', + connectionId: null, + isLive: false + }) + ).toBe(true) + + expect( + shouldReconcileMissingSession({ + ptyId: 'wt@@alive', + connectionId: null, + isLive: true + }) + ).toBe(false) + + expect( + shouldReconcileMissingSession({ + ptyId: 'wt@@unknown', + connectionId: null, + isLive: null + }) + ).toBe(false) + }) + + it('keeps the remote, SSH, and newborn guards from the broad reconcile path', () => { + expect( + shouldReconcileMissingSession({ + ptyId: 'remote:env-1:abc', + connectionId: null, + isLive: false + }) + ).toBe(false) + + expect( + shouldReconcileMissingSession({ + ptyId: 'wt@@ssh-dead', + connectionId: 'ssh-target-1', + isLive: false + }) + ).toBe(false) + + expect( + shouldReconcileMissingSession({ + ptyId: 'wt@@newborn', + connectionId: null, + isLive: false, + ptyBoundAt: 1000, + livenessRequestedAt: 900 + }) + ).toBe(false) + }) +}) + +describe('reconcileMissingSessions', () => { + it('invokes each binding with the targeted liveness probe and request timestamp', () => { + const hasPty = vi.fn(async () => true) + const bindingA = { reconcileIfSessionMissing: vi.fn() } + const bindingB = { reconcileIfSessionMissing: vi.fn() } + const before = performance.now() + + reconcileMissingSessions({ bindings: [bindingA, bindingB], hasPty }) + + const after = performance.now() + expect(bindingA.reconcileIfSessionMissing).toHaveBeenCalledWith(hasPty, expect.any(Number)) + expect(bindingB.reconcileIfSessionMissing).toHaveBeenCalledWith(hasPty, expect.any(Number)) + const [, requestedAt] = bindingA.reconcileIfSessionMissing.mock.calls[0]! + expect(requestedAt).toBeGreaterThanOrEqual(before) + expect(requestedAt).toBeLessThanOrEqual(after) + }) +}) + describe('reconcileDeadSessions', () => { function createBinding() { return { diff --git a/src/renderer/src/components/terminal-pane/terminal-dead-session-reconcile.ts b/src/renderer/src/components/terminal-pane/terminal-dead-session-reconcile.ts index 8aa0f22685f..7c9fe8a23fc 100644 --- a/src/renderer/src/components/terminal-pane/terminal-dead-session-reconcile.ts +++ b/src/renderer/src/components/terminal-pane/terminal-dead-session-reconcile.ts @@ -13,8 +13,11 @@ const REMOTE_PTY_ID_PREFIX = 'remote:' */ export type ReconcilableBinding = { reconcileIfSessionDead?: (liveSessionIds: Set, snapshotRequestedAt?: number) => void + reconcileIfSessionMissing?: (hasPty: HasPty, livenessRequestedAt?: number) => void } +export type HasPty = (ptyId: string) => Promise + /** * PURE decision: should the pane bound to `ptyId` be reconciled (torn down) * given the resolved set of live session ids? @@ -60,6 +63,37 @@ export function shouldReconcileDeadSession(args: { return !liveSessionIds.has(ptyId) } +export function shouldReconcileMissingSession(args: { + ptyId: string | null | undefined + connectionId: string | null | undefined + isLive: boolean | null | undefined + ptyBoundAt?: number | null + livenessRequestedAt?: number | null +}): boolean { + if (args.isLive !== false) { + return false + } + return shouldReconcileDeadSession({ + ptyId: args.ptyId, + connectionId: args.connectionId, + liveSessionIds: new Set(), + ptyBoundAt: args.ptyBoundAt, + snapshotRequestedAt: args.livenessRequestedAt + }) +} + +export function reconcileMissingSessions(args: { + bindings: Iterable + hasPty: HasPty +}): void { + // Why: the liveness request time must predate every async response so a + // stale response cannot close a PTY that bound after the request started. + const requestedAt = performance.now() + for (const binding of args.bindings) { + binding.reconcileIfSessionMissing?.(args.hasPty, requestedAt) + } +} + /** * Thin orchestration: fetch the live session listing once and invoke each * binding's `reconcileIfSessionDead` with the resolved set. diff --git a/src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.test.ts b/src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.test.ts new file mode 100644 index 00000000000..782113e1641 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest' +import type { TerminalLayoutSnapshot } from '../../../../shared/types' +import { detachTerminalLayoutLeaf } from './terminal-layout-leaf-detach' + +const LEAF_1 = '11111111-1111-4111-8111-111111111111' +const LEAF_2 = '22222222-2222-4222-8222-222222222222' +const LEAF_3 = '33333333-3333-4333-8333-333333333333' + +function splitLayout(): TerminalLayoutSnapshot { + return { + root: { + type: 'split', + direction: 'vertical', + ratio: 0.25, + first: { type: 'leaf', leafId: LEAF_1 }, + second: { + type: 'split', + direction: 'horizontal', + ratio: 0.7, + first: { type: 'leaf', leafId: LEAF_2 }, + second: { type: 'leaf', leafId: LEAF_3 } + } + }, + activeLeafId: LEAF_2, + expandedLeafId: LEAF_3, + ptyIdsByLeafId: { + [LEAF_1]: 'pty-1', + [LEAF_2]: 'remote:env-1@@terminal-1', + [LEAF_3]: 'pty-3' + }, + buffersByLeafId: { + [LEAF_1]: 'buffer-1', + [LEAF_2]: 'buffer-2', + [LEAF_3]: 'buffer-3' + }, + scrollbackRefsByLeafId: { + [LEAF_1]: 'scrollback-1', + [LEAF_2]: 'scrollback-2', + [LEAF_3]: 'scrollback-3' + }, + titlesByLeafId: { + [LEAF_1]: 'one', + [LEAF_2]: 'remote shell', + [LEAF_3]: 'three' + } + } +} + +describe('detachTerminalLayoutLeaf', () => { + it('extracts a nested leaf into a single-pane layout while preserving SSH PTY state', () => { + const detached = detachTerminalLayoutLeaf(splitLayout(), LEAF_2) + + expect(detached?.ptyId).toBe('remote:env-1@@terminal-1') + expect(detached?.detachedLayout).toEqual({ + root: { type: 'leaf', leafId: LEAF_2 }, + activeLeafId: LEAF_2, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_2]: 'remote:env-1@@terminal-1' }, + buffersByLeafId: { [LEAF_2]: 'buffer-2' }, + scrollbackRefsByLeafId: { [LEAF_2]: 'scrollback-2' }, + titlesByLeafId: { [LEAF_2]: 'remote shell' } + }) + expect(detached?.sourceLayout).toEqual({ + root: { + type: 'split', + direction: 'vertical', + ratio: 0.25, + first: { type: 'leaf', leafId: LEAF_1 }, + second: { type: 'leaf', leafId: LEAF_3 } + }, + activeLeafId: LEAF_1, + expandedLeafId: LEAF_3, + ptyIdsByLeafId: { + [LEAF_1]: 'pty-1', + [LEAF_3]: 'pty-3' + }, + buffersByLeafId: { + [LEAF_1]: 'buffer-1', + [LEAF_3]: 'buffer-3' + }, + scrollbackRefsByLeafId: { + [LEAF_1]: 'scrollback-1', + [LEAF_3]: 'scrollback-3' + }, + titlesByLeafId: { + [LEAF_1]: 'one', + [LEAF_3]: 'three' + } + }) + }) + + it('clears source expanded selection when the expanded leaf is detached', () => { + const detached = detachTerminalLayoutLeaf(splitLayout(), LEAF_3) + + expect(detached?.sourceLayout.expandedLeafId).toBeNull() + expect(detached?.sourceLayout.root).toEqual({ + type: 'split', + direction: 'vertical', + ratio: 0.25, + first: { type: 'leaf', leafId: LEAF_1 }, + second: { type: 'leaf', leafId: LEAF_2 } + }) + }) + + it('returns null for missing or only leaf layouts', () => { + expect(detachTerminalLayoutLeaf(splitLayout(), 'missing')).toBeNull() + expect( + detachTerminalLayoutLeaf( + { + root: { type: 'leaf', leafId: LEAF_1 }, + activeLeafId: LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_1]: 'pty-1' } + }, + LEAF_1 + ) + ).toBeNull() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.ts b/src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.ts new file mode 100644 index 00000000000..d43702057f1 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-layout-leaf-detach.ts @@ -0,0 +1,119 @@ +import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode } from '../../../../shared/types' +import { + collectLeafIdsInOrder, + normalizeTerminalLayoutSnapshot, + resolveTerminalLayoutActiveLeafId +} from './terminal-layout-leaf-ids' + +export type DetachedTerminalLayoutLeaf = { + sourceLayout: TerminalLayoutSnapshot + detachedLayout: TerminalLayoutSnapshot + ptyId: string | null +} + +function removeLeafFromTree( + node: TerminalPaneLayoutNode, + leafId: string +): { node: TerminalPaneLayoutNode | null; removed: boolean } { + if (node.type === 'leaf') { + return node.leafId === leafId ? { node: null, removed: true } : { node, removed: false } + } + + const first = removeLeafFromTree(node.first, leafId) + const second = removeLeafFromTree(node.second, leafId) + if (!first.removed && !second.removed) { + return { node, removed: false } + } + if (!first.node) { + return { node: second.node, removed: true } + } + if (!second.node) { + return { node: first.node, removed: true } + } + return { + node: { + ...node, + first: first.node, + second: second.node + }, + removed: true + } +} + +function omitLeafRecord( + source: Record | undefined, + leafId: string +): Record | undefined { + if (!source || !Object.prototype.hasOwnProperty.call(source, leafId)) { + return source + } + const next = { ...source } + delete next[leafId] + return Object.keys(next).length > 0 ? next : undefined +} + +function singleLeafRecord( + source: Record | undefined, + leafId: string +): Record | undefined { + const value = source?.[leafId] + return value ? { [leafId]: value } : undefined +} + +export function detachTerminalLayoutLeaf( + snapshot: TerminalLayoutSnapshot | null | undefined, + leafId: string +): DetachedTerminalLayoutLeaf | null { + const layout = normalizeTerminalLayoutSnapshot(snapshot).snapshot + if (!layout.root) { + return null + } + + const originalLeafIds = collectLeafIdsInOrder(layout.root) + if (!originalLeafIds.includes(leafId) || originalLeafIds.length <= 1) { + return null + } + + const removal = removeLeafFromTree(layout.root, leafId) + if (!removal.removed || !removal.node) { + return null + } + + const ptyIdsByLeafId = omitLeafRecord(layout.ptyIdsByLeafId, leafId) + const buffersByLeafId = omitLeafRecord(layout.buffersByLeafId, leafId) + const scrollbackRefsByLeafId = omitLeafRecord(layout.scrollbackRefsByLeafId, leafId) + const titlesByLeafId = omitLeafRecord(layout.titlesByLeafId, leafId) + const sourceLayout: TerminalLayoutSnapshot = { + root: removal.node, + activeLeafId: resolveTerminalLayoutActiveLeafId({ + root: removal.node, + activeLeafId: layout.activeLeafId === leafId ? null : layout.activeLeafId, + ptyIdsByLeafId + }), + expandedLeafId: layout.expandedLeafId === leafId ? null : layout.expandedLeafId, + ...(ptyIdsByLeafId ? { ptyIdsByLeafId } : {}), + ...(buffersByLeafId ? { buffersByLeafId } : {}), + ...(scrollbackRefsByLeafId ? { scrollbackRefsByLeafId } : {}), + ...(titlesByLeafId ? { titlesByLeafId } : {}) + } + + const detachedPtyIdsByLeafId = singleLeafRecord(layout.ptyIdsByLeafId, leafId) + const detachedBuffersByLeafId = singleLeafRecord(layout.buffersByLeafId, leafId) + const detachedScrollbackRefsByLeafId = singleLeafRecord(layout.scrollbackRefsByLeafId, leafId) + const detachedTitlesByLeafId = singleLeafRecord(layout.titlesByLeafId, leafId) + return { + sourceLayout, + detachedLayout: { + root: { type: 'leaf', leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ...(detachedPtyIdsByLeafId ? { ptyIdsByLeafId: detachedPtyIdsByLeafId } : {}), + ...(detachedBuffersByLeafId ? { buffersByLeafId: detachedBuffersByLeafId } : {}), + ...(detachedScrollbackRefsByLeafId + ? { scrollbackRefsByLeafId: detachedScrollbackRefsByLeafId } + : {}), + ...(detachedTitlesByLeafId ? { titlesByLeafId: detachedTitlesByLeafId } : {}) + }, + ptyId: detachedPtyIdsByLeafId?.[leafId] ?? null + } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-link-activation.ts b/src/renderer/src/components/terminal-pane/terminal-link-activation.ts new file mode 100644 index 00000000000..41def2095c1 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-link-activation.ts @@ -0,0 +1,7 @@ +import { isMacPlatform } from './terminal-link-open-hints' + +export function isTerminalLinkActivation( + event: Pick | undefined +): boolean { + return isMacPlatform() ? Boolean(event?.metaKey) : Boolean(event?.ctrlKey) +} diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts index 5cbc01a0903..c75dd3943e7 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts @@ -7,6 +7,7 @@ import { createFilePathLinkProvider, getTerminalFileOpenHint, getTerminalHtmlFileOpenHint, + getTerminalUrlOpenHint, installFilePathLinkClickFallback, isTerminalLinkActivation, openFilePathLinkAtBufferPosition, @@ -156,12 +157,28 @@ describe('isTerminalLinkActivation', () => { }) describe('handleOscLink', () => { - it('routes http links on ordinary click', () => { + it('ignores http links without the platform modifier on desktop', () => { setPlatform('Macintosh') storeState.settings = { openLinksInApp: true } const preventDefault = vi.fn() - handleOscLink('https://example.com', { metaKey: false, ctrlKey: false, preventDefault }, deps) + expect( + handleOscLink('https://example.com', { metaKey: false, ctrlKey: false, preventDefault }, deps) + ).toBe(false) + + expect(openUrlMock).not.toHaveBeenCalled() + expect(createBrowserTabMock).not.toHaveBeenCalled() + expect(preventDefault).not.toHaveBeenCalled() + }) + + it('routes http links with the platform modifier on desktop', () => { + setPlatform('Macintosh') + storeState.settings = { openLinksInApp: true } + const preventDefault = vi.fn() + + expect( + handleOscLink('https://example.com', { metaKey: true, ctrlKey: false, preventDefault }, deps) + ).toBe(true) expect(openUrlMock).not.toHaveBeenCalled() expect(createBrowserTabMock).toHaveBeenCalledWith('wt-1', 'https://example.com/', { @@ -438,18 +455,38 @@ describe('handleOscLink', () => { setPlatform('Macintosh') expect(getTerminalFileOpenHint()).toBe('⌘+click to open or ⇧⌘+click for default app') expect(getTerminalHtmlFileOpenHint()).toBe('⌘+click to open or ⇧⌘+click for default browser') + expect(getTerminalUrlOpenHint()).toBe('⌘+click to open or ⇧⌘+click for system browser') setPlatform('Windows') expect(getTerminalFileOpenHint()).toBe('Ctrl+click to open or Shift+Ctrl+click for default app') expect(getTerminalHtmlFileOpenHint()).toBe( 'Ctrl+click to open or Shift+Ctrl+click for default browser' ) + expect(getTerminalUrlOpenHint()).toBe( + 'Ctrl+click to open or Shift+Ctrl+click for system browser' + ) }) - it('opens local file URL links in Orca on ordinary click', async () => { + it('ignores local file URL links without the platform modifier on desktop', async () => { setPlatform('Windows') - handleOscLink('file:///tmp/test.txt', { metaKey: false, ctrlKey: false }, deps) + expect(handleOscLink('file:///tmp/test.txt', { metaKey: false, ctrlKey: false }, deps)).toBe( + false + ) + + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(authorizeExternalPathMock).not.toHaveBeenCalled() + expect(openFileMock).not.toHaveBeenCalled() + expect(openFilePathMock).not.toHaveBeenCalled() + }) + + it('opens local file URL links in Orca with the platform modifier on desktop', async () => { + setPlatform('Windows') + + expect(handleOscLink('file:///tmp/test.txt', { metaKey: false, ctrlKey: true }, deps)).toBe( + true + ) // openDetectedFilePath is async (fire-and-forget), so flush the microtask queue // before asserting on positive behavior. @@ -1708,7 +1745,7 @@ describe('createFilePathLinkProvider range bounds', () => { disposable.dispose() }) - it('opens regular URLs from a direct ordinary-click fallback when xterm did not handle them', async () => { + it('ignores regular URLs from a direct ordinary-click fallback on desktop', async () => { setPlatform('Macintosh') storeState.settings = { openLinksInApp: false } const rows = [ @@ -1732,6 +1769,39 @@ describe('createFilePathLinkProvider range bounds', () => { stopPropagation } as unknown as MouseEvent) + expect(openUrlMock).not.toHaveBeenCalled() + expect(preventDefault).not.toHaveBeenCalled() + expect(stopPropagation).not.toHaveBeenCalled() + expect(terminal.clearSelection).not.toHaveBeenCalled() + + disposable.dispose() + expect(element.removeEventListener).toHaveBeenCalledWith('mouseup', mouseUp) + }) + + it('opens regular URLs from a direct modifier-click fallback when xterm did not handle them', async () => { + setPlatform('Macintosh') + storeState.settings = { openLinksInApp: false } + const rows = [ + makeBufferLine('PR opened: https://github.com/stablyai/orca-marketing-website/pull/82') + ] + const { terminal, element } = makeFallbackTerminal(rows) + const disposable = installHttpLinkClickFallback(terminal, { worktreeId: 'wt-1' }) + const mouseUp = getRegisteredBubbleMouseUpHandler(element) + const preventDefault = vi.fn() + const stopPropagation = vi.fn() + + mouseUp({ + button: 0, + metaKey: true, + ctrlKey: false, + shiftKey: false, + defaultPrevented: false, + clientX: 230, + clientY: 25, + preventDefault, + stopPropagation + } as unknown as MouseEvent) + expect(openUrlMock).toHaveBeenCalledWith( 'https://github.com/stablyai/orca-marketing-website/pull/82' ) diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts index 0e111027e37..462dbdeeedc 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts @@ -33,14 +33,15 @@ import { getTerminalOrcaFileOpenHint, getTerminalWorktreePathOpenHint, getTerminalFileOpenHint, - getTerminalUrlOpenHint, - isMacPlatform + getTerminalUrlOpenHint } from './terminal-link-open-hints' import { resolveKnownWorktreeRootPathLink } from './terminal-worktree-path-link' +import { isTerminalLinkActivation } from './terminal-link-activation' export { openDetectedFilePath } from './terminal-file-open-routing' export { openFilePathLinkAtBufferPosition } from './terminal-file-link-hit-testing' export { getTerminalFileOpenHint, getTerminalHtmlFileOpenHint, getTerminalUrlOpenHint } +export { isTerminalLinkActivation } from './terminal-link-activation' export type LinkHandlerDeps = { worktreeId: string @@ -315,10 +316,3 @@ export function installFilePathLinkClickFallback( } } } - -export function isTerminalLinkActivation( - event: Pick | undefined -): boolean { - const isMac = isMacPlatform() - return isMac ? Boolean(event?.metaKey) : Boolean(event?.ctrlKey) -} diff --git a/src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts b/src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts index 246333c7cbd..0d51669a6c6 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts @@ -22,8 +22,8 @@ export function getTerminalHtmlFileOpenHint(): string { export function getTerminalUrlOpenHint(): string { return isMacPlatform() - ? 'click to open or ⇧+click for system browser' - : 'click to open or Shift+click for system browser' + ? '⌘+click to open or ⇧⌘+click for system browser' + : 'Ctrl+click to open or Shift+Ctrl+click for system browser' } export function getTerminalUrlSystemBrowserHint(): string { diff --git a/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts b/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts index 9d588b7857a..2b043d648cf 100644 --- a/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts +++ b/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts @@ -3,6 +3,7 @@ import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-pat import type { LinkHandlerDeps } from './terminal-link-handlers' import { resolveTerminalFileUrlTarget } from './terminal-file-url-target' import { openDetectedFilePath } from './terminal-file-open-routing' +import { isTerminalLinkActivation } from './terminal-link-activation' import { openTerminalHttpLink, type TerminalLinkRoutingPreferenceRequester @@ -11,16 +12,16 @@ import { type TerminalLinkEvent = Pick & Partial> -function isPrimaryOscLinkActivation(event: TerminalLinkEvent | undefined): boolean { +function isDesktopOscLinkActivation(event: TerminalLinkEvent | undefined): boolean { if (!event) { return false } if ('button' in event && event.button !== undefined && event.button !== 0) { return false } - // Why: macOS Ctrl-click is a context-menu gesture even when Chromium reports - // it as button 0; ordinary OSC links should not steal that secondary action. - return !(navigator.userAgent.includes('Mac') && event.ctrlKey && !event.metaKey) + // Why: desktop xterm links must not open while the user is just placing the + // cursor or selecting text. Mobile URL taps use a separate WebView path. + return isTerminalLinkActivation(event) } export function handleOscLink( @@ -30,9 +31,9 @@ export function handleOscLink( Partial> & { requestOpenLinksInAppPreference?: TerminalLinkRoutingPreferenceRequester } -): void { - if (!isPrimaryOscLinkActivation(event)) { - return +): boolean { + if (!isDesktopOscLinkActivation(event)) { + return false } // Why: xterm renders OSC 8 links as clickable anchors. Orca must suppress // default anchor navigation so link-routing settings can choose the target. @@ -67,15 +68,14 @@ export function handleOscLink( ) { // Why: `new URL("C:\\path\\file.ts")` succeeds with protocol `c:`; // Windows OSC links need file-path routing before generic URL parsing. - return + return true } let parsed: URL try { parsed = new URL(rawText) } catch { - openDetectedPathLink() - return + return openDetectedPathLink() } if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { @@ -84,7 +84,7 @@ export function handleOscLink( forceSystemBrowser: Boolean(event?.shiftKey), requestOpenLinksInAppPreference: deps.requestOpenLinksInAppPreference }) - return + return true } if (parsed.protocol === 'file:') { @@ -99,11 +99,13 @@ export function handleOscLink( !deps.runtimeEnvironmentId const resolved = resolveTerminalFileUrlTarget(parsed, { allowUncHost }) if (!resolved) { - return + return false } openDetectedFilePath(resolved.filePath, resolved.line, resolved.column, { ...deps, openWithSystemDefault: Boolean(event?.shiftKey) }) + return true } + return false } diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts new file mode 100644 index 00000000000..a92e03cce8c --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts @@ -0,0 +1,383 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AppState } from '@/store' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/types' +import { + detachTerminalPaneToTab, + resolveTerminalTabStripDropTarget, + type TerminalPaneTabDetachStore +} from './terminal-pane-tab-detach' + +const WORKTREE_ID = 'repo-1::/worktree' +const SOURCE_TAB_ID = 'tab-source' +const TARGET_GROUP_ID = 'group-target' +const EXISTING_TAB_1 = 'tab-existing-1' +const EXISTING_TAB_2 = 'tab-existing-2' +const LEAF_1 = '11111111-1111-4111-8111-111111111111' +const LEAF_2 = '22222222-2222-4222-8222-222222222222' + +function rect(args: { left: number; top: number; width: number; height: number }): DOMRect { + return { + left: args.left, + top: args.top, + right: args.left + args.width, + bottom: args.top + args.height, + width: args.width, + height: args.height + } as DOMRect +} + +function splitLayout(): TerminalLayoutSnapshot { + return { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEAF_1 }, + second: { type: 'leaf', leafId: LEAF_2 } + }, + activeLeafId: LEAF_2, + expandedLeafId: null, + ptyIdsByLeafId: { + [LEAF_1]: 'pty-left', + [LEAF_2]: 'remote:env-1@@terminal-1' + }, + buffersByLeafId: { + [LEAF_2]: 'remote-buffer' + }, + titlesByLeafId: { + [LEAF_2]: 'remote shell' + } + } +} + +function createTerminalTab(id: string, ptyId: string | null): TerminalTab { + return { + id, + ptyId, + worktreeId: WORKTREE_ID, + title: 'Terminal 2', + defaultTitle: 'Terminal 2', + customTitle: null, + color: null, + sortOrder: 1, + createdAt: 1 + } +} + +function createStore( + layout: TerminalLayoutSnapshot = splitLayout(), + targetTabOrder: string[] = [EXISTING_TAB_1, EXISTING_TAB_2] +): TerminalPaneTabDetachStore { + const store = { + createTab: vi.fn((_worktreeId, _targetGroupId, _shellOverride, options) => { + const tab = createTerminalTab('tab-detached', options?.initialPtyId ?? null) + const group = store.groupsByWorktree[WORKTREE_ID]?.find( + (candidate) => candidate.id === TARGET_GROUP_ID + ) + if (group && !group.tabOrder.includes(tab.id)) { + group.tabOrder = [...group.tabOrder, tab.id] + } + return tab + }), + groupsByWorktree: { + [WORKTREE_ID]: [ + { + id: TARGET_GROUP_ID, + worktreeId: WORKTREE_ID, + activeTabId: targetTabOrder[0] ?? null, + tabOrder: targetTabOrder, + recentTabIds: [] + } + ] + }, + reorderUnifiedTabs: vi.fn((groupId: string, tabIds: string[]) => { + const group = store.groupsByWorktree[WORKTREE_ID]?.find( + (candidate) => candidate.id === groupId + ) + if (group) { + group.tabOrder = tabIds + } + }), + setActiveTab: vi.fn(), + setActiveTabType: vi.fn(), + setTabLayout: vi.fn((tabId: string, nextLayout: TerminalLayoutSnapshot | null) => { + if (nextLayout) { + store.terminalLayoutsByTabId[tabId] = nextLayout + } else { + delete store.terminalLayoutsByTabId[tabId] + } + }), + syncPaneDetachPtyOwnership: vi.fn(), + terminalLayoutsByTabId: { + [SOURCE_TAB_ID]: layout + } + } + return store as unknown as TerminalPaneTabDetachStore +} + +describe('resolveTerminalTabStripDropTarget', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('finds a same-worktree tab strip under overlay elements', () => { + const stripRect = rect({ left: 0, top: 0, width: 300, height: 32 }) + const strip = { + dataset: { tabGroupStripId: TARGET_GROUP_ID, worktreeId: WORKTREE_ID }, + getBoundingClientRect: () => stripRect, + querySelectorAll: () => [] + } + const overlay = { closest: () => null } + const child = { closest: () => strip } + vi.stubGlobal('document', { + elementsFromPoint: vi.fn(() => [overlay, child]), + elementFromPoint: vi.fn() + }) + + expect( + resolveTerminalTabStripDropTarget({ + clientX: 10, + clientY: 10, + groupsByWorktree: { + [WORKTREE_ID]: [{ id: TARGET_GROUP_ID } as AppState['groupsByWorktree'][string][number]] + }, + worktreeId: WORKTREE_ID + }) + ).toEqual({ + id: TARGET_GROUP_ID, + groupId: TARGET_GROUP_ID, + worktreeId: WORKTREE_ID, + rect: stripRect + }) + }) + + it('resolves the insertion slot from the hovered tab side', () => { + const stripRect = rect({ left: 0, top: 0, width: 300, height: 32 }) + const firstTabRect = rect({ left: 0, top: 0, width: 80, height: 32 }) + const secondTabRect = rect({ left: 80, top: 0, width: 80, height: 32 }) + const firstTab = { + dataset: { tabId: EXISTING_TAB_1 }, + getBoundingClientRect: () => firstTabRect + } + const secondTab = { + dataset: { tabId: EXISTING_TAB_2 }, + getBoundingClientRect: () => secondTabRect + } + const strip = { + dataset: { tabGroupStripId: TARGET_GROUP_ID, worktreeId: WORKTREE_ID }, + getBoundingClientRect: () => stripRect, + querySelectorAll: () => [firstTab, secondTab] + } + vi.stubGlobal('document', { + elementsFromPoint: vi.fn(() => [{ closest: () => firstTab }, { closest: () => strip }]), + elementFromPoint: vi.fn() + }) + + expect( + resolveTerminalTabStripDropTarget({ + clientX: 60, + clientY: 10, + groupsByWorktree: { + [WORKTREE_ID]: [ + { + id: TARGET_GROUP_ID, + activeTabId: EXISTING_TAB_1, + tabOrder: [EXISTING_TAB_1, EXISTING_TAB_2], + worktreeId: WORKTREE_ID + } as AppState['groupsByWorktree'][string][number] + ] + }, + worktreeId: WORKTREE_ID + }) + ).toMatchObject({ + groupId: TARGET_GROUP_ID, + insertionIndex: 1, + overlayKind: 'insertion', + rect: rect({ left: 80, top: 0, width: 2, height: 32 }) + }) + }) + + it('ignores strips from another worktree', () => { + const strip = { + dataset: { tabGroupStripId: TARGET_GROUP_ID, worktreeId: 'other-worktree' }, + getBoundingClientRect: () => + ({ left: 0, top: 0, right: 300, bottom: 32, width: 300, height: 32 }) as DOMRect + } + vi.stubGlobal('document', { + elementsFromPoint: vi.fn(() => [{ closest: () => strip }]), + elementFromPoint: vi.fn() + }) + + expect( + resolveTerminalTabStripDropTarget({ + clientX: 10, + clientY: 10, + groupsByWorktree: { + [WORKTREE_ID]: [{ id: TARGET_GROUP_ID } as AppState['groupsByWorktree'][string][number]] + }, + worktreeId: WORKTREE_ID + }) + ).toBeNull() + }) +}) + +describe('detachTerminalPaneToTab', () => { + it('creates a new terminal tab with the detached leaf layout and PTY id', () => { + const store = createStore() + const manager = { + getPanes: vi.fn(() => [{ id: 1 }, { id: 2 }]), + getLeafId: vi.fn((paneId: number) => (paneId === 2 ? LEAF_2 : LEAF_1)), + detachPaneForExternalMove: vi.fn(() => true) + } + const persistLayoutSnapshot = vi.fn() + + const result = detachTerminalPaneToTab({ + manager, + getStore: () => store, + persistLayoutSnapshot, + sourcePaneId: 2, + sourceTabId: SOURCE_TAB_ID, + targetGroupId: TARGET_GROUP_ID, + worktreeId: WORKTREE_ID + }) + + expect(result?.ptyId).toBe('remote:env-1@@terminal-1') + expect(manager.detachPaneForExternalMove).toHaveBeenCalledWith(2) + expect(store.createTab).toHaveBeenCalledWith(WORKTREE_ID, TARGET_GROUP_ID, undefined, { + activate: true, + initialPtyId: 'remote:env-1@@terminal-1', + recordInteraction: true + }) + expect(store.setTabLayout).toHaveBeenCalledWith(SOURCE_TAB_ID, { + root: { type: 'leaf', leafId: LEAF_1 }, + activeLeafId: LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_1]: 'pty-left' } + }) + expect(store.setTabLayout).toHaveBeenCalledWith('tab-detached', { + root: { type: 'leaf', leafId: LEAF_2 }, + activeLeafId: LEAF_2, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_2]: 'remote:env-1@@terminal-1' }, + buffersByLeafId: { [LEAF_2]: 'remote-buffer' }, + titlesByLeafId: { [LEAF_2]: 'remote shell' } + }) + expect(store.syncPaneDetachPtyOwnership).toHaveBeenCalledWith({ + detachedPtyId: 'remote:env-1@@terminal-1', + sourceLayout: { + root: { type: 'leaf', leafId: LEAF_1 }, + activeLeafId: LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_1]: 'pty-left' } + }, + sourceTabId: SOURCE_TAB_ID, + targetTabId: 'tab-detached' + }) + expect(store.setActiveTab).toHaveBeenCalledWith('tab-detached') + expect(store.setActiveTabType).toHaveBeenCalledWith('terminal') + expect(persistLayoutSnapshot).toHaveBeenCalled() + }) + + it('syncs PTY ownership when the primary source pane is detached', () => { + const store = createStore() + const manager = { + getPanes: vi.fn(() => [{ id: 1 }, { id: 2 }]), + getLeafId: vi.fn((paneId: number) => (paneId === 1 ? LEAF_1 : LEAF_2)), + detachPaneForExternalMove: vi.fn(() => true) + } + + const result = detachTerminalPaneToTab({ + getStore: () => store, + manager, + persistLayoutSnapshot: vi.fn(), + sourcePaneId: 1, + sourceTabId: SOURCE_TAB_ID, + targetGroupId: TARGET_GROUP_ID, + worktreeId: WORKTREE_ID + }) + + expect(result?.ptyId).toBe('pty-left') + expect(store.setTabLayout).toHaveBeenCalledWith(SOURCE_TAB_ID, { + root: { type: 'leaf', leafId: LEAF_2 }, + activeLeafId: LEAF_2, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_2]: 'remote:env-1@@terminal-1' }, + buffersByLeafId: { [LEAF_2]: 'remote-buffer' }, + titlesByLeafId: { [LEAF_2]: 'remote shell' } + }) + expect(store.syncPaneDetachPtyOwnership).toHaveBeenCalledWith({ + detachedPtyId: 'pty-left', + sourceLayout: { + root: { type: 'leaf', leafId: LEAF_2 }, + activeLeafId: LEAF_2, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_2]: 'remote:env-1@@terminal-1' }, + buffersByLeafId: { [LEAF_2]: 'remote-buffer' }, + titlesByLeafId: { [LEAF_2]: 'remote shell' } + }, + sourceTabId: SOURCE_TAB_ID, + targetTabId: 'tab-detached' + }) + }) + + it('moves the detached tab into the requested group slot', () => { + const store = createStore(splitLayout(), [EXISTING_TAB_1, EXISTING_TAB_2]) + const manager = { + getPanes: vi.fn(() => [{ id: 1 }, { id: 2 }]), + getLeafId: vi.fn((paneId: number) => (paneId === 2 ? LEAF_2 : LEAF_1)), + detachPaneForExternalMove: vi.fn(() => true) + } + + detachTerminalPaneToTab({ + getStore: () => store, + manager, + persistLayoutSnapshot: vi.fn(), + sourcePaneId: 2, + sourceTabId: SOURCE_TAB_ID, + targetGroupId: TARGET_GROUP_ID, + targetIndex: 1, + worktreeId: WORKTREE_ID + }) + + expect(store.reorderUnifiedTabs).toHaveBeenCalledWith( + TARGET_GROUP_ID, + [EXISTING_TAB_1, 'tab-detached', EXISTING_TAB_2], + { recordInteraction: false } + ) + }) + + it('uses the live transport PTY id when the snapshot has not persisted it yet', () => { + const store = createStore({ + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEAF_1 }, + second: { type: 'leaf', leafId: LEAF_2 } + }, + activeLeafId: LEAF_2, + expandedLeafId: null + }) + const manager = { + getPanes: vi.fn(() => [{ id: 1 }, { id: 2 }]), + getLeafId: vi.fn(() => LEAF_2), + detachPaneForExternalMove: vi.fn(() => true) + } + + detachTerminalPaneToTab({ + fallbackPtyId: 'remote:env-2@@terminal-9', + getStore: () => store, + manager, + persistLayoutSnapshot: vi.fn(), + sourcePaneId: 2, + sourceTabId: SOURCE_TAB_ID, + targetGroupId: TARGET_GROUP_ID, + worktreeId: WORKTREE_ID + }) + + expect(store.setTabLayout).toHaveBeenCalledWith('tab-detached', { + root: { type: 'leaf', leafId: LEAF_2 }, + activeLeafId: LEAF_2, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_2]: 'remote:env-2@@terminal-9' } + }) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts new file mode 100644 index 00000000000..26f3d5996e3 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.ts @@ -0,0 +1,306 @@ +import type { PaneExternalDropTarget } from '@/lib/pane-manager/pane-manager' +import type { AppState } from '@/store' +import type { TerminalTab } from '../../../../shared/types' +import { detachTerminalLayoutLeaf } from './terminal-layout-leaf-detach' + +const TAB_GROUP_STRIP_SELECTOR = '[data-tab-group-strip-id][data-worktree-id]' + +export type TerminalPaneTabDetachStore = Pick< + AppState, + | 'createTab' + | 'groupsByWorktree' + | 'reorderUnifiedTabs' + | 'setActiveTab' + | 'setActiveTabType' + | 'setTabLayout' + | 'syncPaneDetachPtyOwnership' + | 'terminalLayoutsByTabId' +> + +type TerminalPaneTabDetachManager = { + getPanes: () => readonly { id: number }[] + getLeafId: (paneId: number) => string | null + detachPaneForExternalMove: (paneId: number) => boolean +} + +export type TerminalTabStripDropTarget = PaneExternalDropTarget & { + groupId: string + insertionIndex?: number + worktreeId: string +} + +export type DetachedTerminalPaneTab = { + tab: TerminalTab + leafId: string + ptyId: string | null +} + +function pointWithinRect(clientX: number, clientY: number, rect: DOMRect): boolean { + return ( + clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom + ) +} + +function rectFromBox(args: { left: number; top: number; width: number; height: number }): DOMRect { + return { + left: args.left, + top: args.top, + right: args.left + args.width, + bottom: args.top + args.height, + width: args.width, + height: args.height + } as DOMRect +} + +function clampIndex(index: number, max: number): number { + return Math.min(Math.max(index, 0), max) +} + +function getTabElements(strip: HTMLElement): HTMLElement[] { + return Array.from(strip.querySelectorAll('[data-tab-id]')).filter( + (element) => typeof element.dataset.tabId === 'string' && element.dataset.tabId.length > 0 + ) +} + +function getInsertionMarkerRect( + tabRects: DOMRect[], + insertionIndex: number, + stripRect: DOMRect +): DOMRect { + const markerWidth = 2 + const clampedIndex = clampIndex(insertionIndex, tabRects.length) + const rawLeft = + clampedIndex < tabRects.length + ? (tabRects[clampedIndex]?.left ?? stripRect.left) + : (tabRects.at(-1)?.right ?? stripRect.left) - markerWidth + const left = Math.min(Math.max(rawLeft, stripRect.left), stripRect.right - markerWidth) + return rectFromBox({ left, top: stripRect.top, width: markerWidth, height: stripRect.height }) +} + +function resolveTabStripInsertion(args: { + clientX: number + clientY: number + groupTabOrderLength: number + strip: HTMLElement + stripRect: DOMRect +}): { index: number; rect: DOMRect } | null { + const tabs = getTabElements(args.strip) + if (tabs.length === 0) { + return null + } + const tabRects = tabs.map((tab) => tab.getBoundingClientRect()) + + for (let index = 0; index < tabRects.length; index += 1) { + const tabRect = tabRects[index] + if (!tabRect) { + continue + } + if (args.clientX < tabRect.left) { + const insertionIndex = clampIndex(index, args.groupTabOrderLength) + return { + index: insertionIndex, + rect: getInsertionMarkerRect(tabRects, insertionIndex, args.stripRect) + } + } + if (pointWithinRect(args.clientX, args.clientY, tabRect)) { + const insertionIndex = clampIndex( + index + (args.clientX < tabRect.left + tabRect.width / 2 ? 0 : 1), + args.groupTabOrderLength + ) + return { + index: insertionIndex, + rect: getInsertionMarkerRect(tabRects, insertionIndex, args.stripRect) + } + } + } + + const insertionIndex = args.groupTabOrderLength + return { + index: insertionIndex, + rect: getInsertionMarkerRect(tabRects, insertionIndex, args.stripRect) + } +} + +function getElementsFromPoint(clientX: number, clientY: number): Element[] { + if (typeof document === 'undefined') { + return [] + } + const elements = document.elementsFromPoint?.(clientX, clientY) + if (elements && elements.length > 0) { + return elements + } + const element = document.elementFromPoint?.(clientX, clientY) + return element ? [element] : [] +} + +export function resolveTerminalTabStripDropTarget(args: { + clientX: number + clientY: number + groupsByWorktree: TerminalPaneTabDetachStore['groupsByWorktree'] + worktreeId: string +}): TerminalTabStripDropTarget | null { + const groups = args.groupsByWorktree[args.worktreeId] ?? [] + const groupById = new Map(groups.map((group) => [group.id, group])) + const validGroupIds = new Set(groups.map((group) => group.id)) + if (validGroupIds.size === 0) { + return null + } + + for (const element of getElementsFromPoint(args.clientX, args.clientY)) { + const strip = element.closest(TAB_GROUP_STRIP_SELECTOR) + const groupId = strip?.dataset.tabGroupStripId + const worktreeId = strip?.dataset.worktreeId + if (!strip || !groupId || worktreeId !== args.worktreeId || !validGroupIds.has(groupId)) { + continue + } + const rect = strip.getBoundingClientRect() + if (!pointWithinRect(args.clientX, args.clientY, rect)) { + continue + } + const group = groupById.get(groupId) + const insertion = group + ? resolveTabStripInsertion({ + clientX: args.clientX, + clientY: args.clientY, + groupTabOrderLength: group.tabOrder?.length ?? 0, + strip, + stripRect: rect + }) + : null + return insertion + ? { + id: groupId, + groupId, + insertionIndex: insertion.index, + overlayKind: 'insertion', + rect: insertion.rect, + worktreeId + } + : { id: groupId, groupId, worktreeId, rect } + } + + return null +} + +function withDetachedPtyFallback(args: { + leafId: string + ptyId: string | null + detachedLayout: NonNullable>['detachedLayout'] +}): NonNullable>['detachedLayout'] { + if (!args.ptyId || args.detachedLayout.ptyIdsByLeafId?.[args.leafId]) { + return args.detachedLayout + } + return { + ...args.detachedLayout, + ptyIdsByLeafId: { + ...args.detachedLayout.ptyIdsByLeafId, + [args.leafId]: args.ptyId + } + } +} + +export function isTerminalTabStripDropTarget( + target: PaneExternalDropTarget +): target is TerminalTabStripDropTarget { + const candidate = target as Partial + return typeof candidate.groupId === 'string' && typeof candidate.worktreeId === 'string' +} + +function moveCreatedTabToIndex(args: { + groupId: string + store: TerminalPaneTabDetachStore + tabId: string + targetIndex: number | undefined + worktreeId: string +}): void { + if (args.targetIndex === undefined) { + return + } + const group = args.store.groupsByWorktree[args.worktreeId]?.find( + (candidate) => candidate.id === args.groupId + ) + if (!group) { + return + } + const orderWithoutCreatedTab = (group.tabOrder ?? []).filter((id) => id !== args.tabId) + const insertionIndex = clampIndex(args.targetIndex, orderWithoutCreatedTab.length) + const nextOrder = [...orderWithoutCreatedTab] + nextOrder.splice(insertionIndex, 0, args.tabId) + args.store.reorderUnifiedTabs(args.groupId, nextOrder, { recordInteraction: false }) +} + +export function detachTerminalPaneToTab(args: { + fallbackPtyId?: string | null + getStore: () => TerminalPaneTabDetachStore + manager: TerminalPaneTabDetachManager | null + persistLayoutSnapshot: () => void + sourcePaneId: number + sourceTabId: string + targetGroupId: string + targetIndex?: number + worktreeId: string +}): DetachedTerminalPaneTab | null { + const initialStore = args.getStore() + const targetGroupExists = + initialStore.groupsByWorktree[args.worktreeId]?.some( + (group) => group.id === args.targetGroupId + ) ?? false + if (!args.manager || !targetGroupExists || args.manager.getPanes().length <= 1) { + return null + } + + const sourceLeafId = args.manager.getLeafId(args.sourcePaneId) + if (!sourceLeafId) { + return null + } + + args.persistLayoutSnapshot() + const store = args.getStore() + const detached = detachTerminalLayoutLeaf( + store.terminalLayoutsByTabId[args.sourceTabId], + sourceLeafId + ) + if (!detached) { + return null + } + + const ptyId = detached.ptyId ?? args.fallbackPtyId ?? null + const detachedLayout = withDetachedPtyFallback({ + leafId: sourceLeafId, + ptyId, + detachedLayout: detached.detachedLayout + }) + + // Why: remove the renderer pane only after the layout/PTY handoff has been + // computed; the close callback detaches listeners but must not kill the PTY. + if (!args.manager.detachPaneForExternalMove(args.sourcePaneId)) { + return null + } + + const latestStore = args.getStore() + const tab = latestStore.createTab(args.worktreeId, args.targetGroupId, undefined, { + activate: true, + initialPtyId: ptyId ?? undefined, + recordInteraction: true + }) + const afterCreateStore = args.getStore() + moveCreatedTabToIndex({ + groupId: args.targetGroupId, + store: afterCreateStore, + tabId: tab.id, + targetIndex: args.targetIndex, + worktreeId: args.worktreeId + }) + afterCreateStore.setTabLayout(args.sourceTabId, detached.sourceLayout) + afterCreateStore.setTabLayout(tab.id, detachedLayout) + afterCreateStore.syncPaneDetachPtyOwnership({ + detachedPtyId: ptyId, + sourceLayout: detached.sourceLayout, + sourceTabId: args.sourceTabId, + targetTabId: tab.id + }) + afterCreateStore.setActiveTab(tab.id) + afterCreateStore.setActiveTabType('terminal') + + return { tab, leafId: sourceLeafId, ptyId } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-replay-cursor-state.test.ts b/src/renderer/src/components/terminal-pane/terminal-replay-cursor-state.test.ts index 933de17d5fd..9e927552049 100644 --- a/src/renderer/src/components/terminal-pane/terminal-replay-cursor-state.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-replay-cursor-state.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { Terminal } from '@xterm/headless' import { + POST_REPLAY_LIVE_AGENT_REATTACH_RESET, POST_REPLAY_LIVE_SNAPSHOT_RESET, POST_REPLAY_MODE_RESET, POST_REPLAY_REATTACH_RESET, @@ -13,6 +14,7 @@ const OLD_REATTACH_RESET_WITHOUT_CURSOR_STYLE = '\x1b[?25h\x1b[?1004l' type DecPrivateCursorState = { cursorStyle?: string cursorBlink?: boolean + sendFocus?: boolean } type KittyKeyboardState = { @@ -28,10 +30,12 @@ type XtermWithCoreService = Terminal & { coreService?: { decPrivateModes?: DecPrivateCursorState kittyKeyboard?: KittyKeyboardState + isCursorHidden?: boolean } _coreService?: { decPrivateModes?: DecPrivateCursorState kittyKeyboard?: KittyKeyboardState + isCursorHidden?: boolean } } } @@ -42,6 +46,15 @@ function readDecPrivateCursorState(term: Terminal): DecPrivateCursorState { return cursorState ? { ...cursorState } : {} } +function readCursorHidden(term: Terminal): boolean | undefined { + const core = (term as XtermWithCoreService)._core + return core?.coreService?.isCursorHidden ?? core?._coreService?.isCursorHidden +} + +function readSendFocus(term: Terminal): boolean | undefined { + return readDecPrivateCursorState(term).sendFocus +} + function readKittyKeyboardState(term: Terminal): KittyKeyboardState | null { const core = (term as XtermWithCoreService)._core const keyboardState = core?.coreService?.kittyKeyboard ?? core?._coreService?.kittyKeyboard @@ -180,6 +193,104 @@ describe('terminal replay state reset', () => { } }) + it('resets hidden cursor visibility after ordinary reattach replay', async () => { + const term = new Terminal({ + cols: 80, + rows: 24, + allowProposedApi: true + }) + + try { + await writeTerminal(term, '\x1b[?25l') + expect(readCursorHidden(term)).toBe(true) + + await writeTerminal(term, POST_REPLAY_REATTACH_RESET) + + expect(readCursorHidden(term)).toBe(false) + } finally { + term.dispose() + } + }) + + it('resets focus reporting after ordinary reattach replay', async () => { + const term = new Terminal({ + cols: 80, + rows: 24, + allowProposedApi: true + }) + try { + await writeTerminal(term, '\x1b[?1004h') + expect(readSendFocus(term)).toBe(true) + + await writeTerminal(term, POST_REPLAY_REATTACH_RESET) + + expect(readSendFocus(term)).toBe(false) + } finally { + term.dispose() + } + }) + + it('resets hidden cursor visibility after live agent reattach replay', async () => { + const term = new Terminal({ + cols: 80, + rows: 24, + allowProposedApi: true + }) + + try { + await writeTerminal(term, '\x1b[?25l') + expect(readCursorHidden(term)).toBe(true) + + await writeTerminal(term, POST_REPLAY_LIVE_AGENT_REATTACH_RESET) + + // Why: agent detection can false-positive on a dead TUI's leftovers, so + // even the live-agent reset must not leave a shell cursor permanently + // invisible; a live agent re-hides it on the post-reattach repaint. + expect(readCursorHidden(term)).toBe(false) + } finally { + term.dispose() + } + }) + + it('preserves live focus reporting after live agent reattach replay', async () => { + const term = new Terminal({ + cols: 80, + rows: 24, + allowProposedApi: true + }) + try { + await writeTerminal(term, '\x1b[?1004h') + expect(readSendFocus(term)).toBe(true) + + await writeTerminal(term, POST_REPLAY_LIVE_AGENT_REATTACH_RESET) + + // Why: live TUIs such as cursor-agent can rely on focus events to repaint + // their own hidden-cursor input caret after renderer reattach. + expect(readSendFocus(term)).toBe(true) + } finally { + term.dispose() + } + }) + + it('shows the cursor after cold-restore replay reset', async () => { + const term = new Terminal({ + cols: 80, + rows: 24, + allowProposedApi: true + }) + + try { + await writeTerminal(term, '\x1b[?25l') + expect(readCursorHidden(term)).toBe(true) + + await writeTerminal(term, POST_REPLAY_MODE_RESET) + + expect(readCursorHidden(term)).toBe(false) + } finally { + term.dispose() + } + }) + it('preserves active-buffer Kitty keyboard state after hidden-output snapshot replay', async () => { const term = new Terminal({ cols: 80, diff --git a/src/renderer/src/components/terminal-pane/terminal-restored-viewport.test.ts b/src/renderer/src/components/terminal-pane/terminal-restored-viewport.test.ts new file mode 100644 index 00000000000..02517dc6735 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-restored-viewport.test.ts @@ -0,0 +1,37 @@ +import { Terminal } from '@xterm/headless' +import { describe, expect, it } from 'vitest' + +import { buildFreshShellViewportBlankingSequence } from './terminal-restored-viewport' + +function writeTerminal(term: Terminal, data: string): Promise { + return new Promise((resolve) => term.write(data, resolve)) +} + +function visibleLines(term: Terminal): string[] { + const buffer = term.buffer.active + return Array.from( + { length: term.rows }, + (_, row) => buffer.getLine(buffer.viewportY + row)?.translateToString(true) ?? '' + ) +} + +describe('buildFreshShellViewportBlankingSequence', () => { + it('preserves restored rows in scrollback even after a stale TUI scroll region', async () => { + const term = new Terminal({ cols: 20, rows: 5, allowProposedApi: true }) + try { + await writeTerminal(term, 'row1\r\nrow2\r\nrow3\r\nrow4\r\nrow5\x1b[2;4r\x1b[?6h\x1b[H') + await writeTerminal(term, buildFreshShellViewportBlankingSequence(term.rows)) + + expect(term.buffer.active.baseY).toBeGreaterThan(0) + expect(visibleLines(term)).toEqual(['', '', '', '', '']) + expect( + Array.from( + { length: term.buffer.active.length }, + (_, row) => term.buffer.active.getLine(row)?.translateToString(true) ?? '' + ) + ).toEqual(expect.arrayContaining(['row1', 'row2', 'row3', 'row4', 'row5'])) + } finally { + term.dispose() + } + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-restored-viewport.ts b/src/renderer/src/components/terminal-pane/terminal-restored-viewport.ts new file mode 100644 index 00000000000..6aff5b34d9a --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-restored-viewport.ts @@ -0,0 +1,10 @@ +import type { RefObject } from 'react' + +export type RestoredViewportBlankingPanesRef = RefObject> + +export function buildFreshShellViewportBlankingSequence(rows: number): string { + const viewportRows = Math.max(1, Math.floor(Number.isFinite(rows) ? rows : 24)) + // Why: newline scrolling preserves restored rows in xterm scrollback; CSI S + // drops them. Reset margins first so stale TUI scroll regions cannot trap it. + return `\x1b[?6l\x1b[r\x1b[${viewportRows};1H${'\r\n'.repeat(viewportRows)}\x1b[H` +} diff --git a/src/renderer/src/components/terminal-pane/terminal-shortcut-ctrl-arrow.test.ts b/src/renderer/src/components/terminal-pane/terminal-shortcut-ctrl-arrow.test.ts index 7e7559db5c0..af6750bee39 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shortcut-ctrl-arrow.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shortcut-ctrl-arrow.test.ts @@ -18,10 +18,9 @@ function event(overrides: Partial): TerminalShortcutEvent } describe('non-mac Ctrl+Left/Right word-nav', () => { - // Windows Terminal, GNOME Terminal, and Konsole all bind Ctrl+←/→ to - // word-nav. xterm.js emits \e[1;5D / \e[1;5C which default readline doesn't - // map, so translate to \eb / \ef (same bytes as our Alt+Arrow rule). - it('translates Ctrl+←/→ on Windows/Linux to readline \\eb / \\ef', () => { + // Linux and remote/WSL readline shells don't bind the \e[1;5D / \e[1;5C that + // xterm.js emits, so translate to \eb / \ef (same bytes as our Alt+Arrow rule). + it('translates Ctrl+←/→ on Linux to readline \\eb / \\ef', () => { expect( resolveTerminalShortcutAction( event({ key: 'ArrowLeft', code: 'ArrowLeft', ctrlKey: true }), @@ -36,6 +35,64 @@ describe('non-mac Ctrl+Left/Right word-nav', () => { ).toEqual({ type: 'sendInput', data: '\x1bf' }) }) + // Local Windows ConPTY shells (PowerShell/cmd via PSReadLine) already bind + // Ctrl+←/→ to word-nav and self-insert a stray "b"/"f" when fed \eb/\ef + // (Escape→RevertLine + self-insert), so the policy must defer to xterm's + // native \e[1;5D / \e[1;5C there. Signalled via the isLocalWindowsConptyPane + // getter (7th arg). + it('does NOT translate Ctrl+←/→ for a local Windows ConPTY pane', () => { + expect( + resolveTerminalShortcutAction( + event({ key: 'ArrowLeft', code: 'ArrowLeft', ctrlKey: true }), + false, + undefined, + undefined, + true, + undefined, + () => true + ) + ).toBeNull() + expect( + resolveTerminalShortcutAction( + event({ key: 'ArrowRight', code: 'ArrowRight', ctrlKey: true }), + false, + undefined, + undefined, + true, + undefined, + () => true + ) + ).toBeNull() + }) + + // A Windows client SSH'd into Linux (or running WSL) is NOT a local native + // Windows ConPTY, so the getter returns false and the readline translation + // must still apply — otherwise word-nav silently breaks on the remote shell. + it('still translates Ctrl+←/→ on Windows when the pane is not local ConPTY (SSH/WSL)', () => { + expect( + resolveTerminalShortcutAction( + event({ key: 'ArrowLeft', code: 'ArrowLeft', ctrlKey: true }), + false, + undefined, + undefined, + true, + undefined, + () => false + ) + ).toEqual({ type: 'sendInput', data: '\x1bb' }) + expect( + resolveTerminalShortcutAction( + event({ key: 'ArrowRight', code: 'ArrowRight', ctrlKey: true }), + false, + undefined, + undefined, + true, + undefined, + () => false + ) + ).toEqual({ type: 'sendInput', data: '\x1bf' }) + }) + it('does not translate Ctrl+Arrow on macOS (reserved by OS)', () => { // Mac uses Cmd+Arrow for line-nav and Option+Arrow for word-nav. // Ctrl+Arrow is the macOS Mission Control / Spaces chord. diff --git a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts index d90b6e5ea62..35cc6c00ffe 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts @@ -53,7 +53,12 @@ export function resolveTerminalShortcutAction( macOptionAsAlt: MacOptionAsAlt = 'false', optionKeyLocation: number = 0, isWindows: boolean = false, - keybindings?: KeybindingOverrides + keybindings?: KeybindingOverrides, + // Why: lazily reports whether the active pane is a local native Windows + // ConPTY (PowerShell/cmd via PSReadLine). Only consulted for the Ctrl+Arrow + // word-nav rule below, so the execution-host lookup it performs stays off the + // hot path for every other keystroke. + isLocalWindowsConptyPane?: () => boolean ): TerminalShortcutAction | null { const platform: NodeJS.Platform = isMac ? 'darwin' : isWindows ? 'win32' : 'linux' if (!event.repeat) { @@ -206,12 +211,21 @@ export function resolveTerminalShortcutAction( !event.shiftKey && (event.key === 'ArrowLeft' || event.key === 'ArrowRight') ) { - // Why: Windows Terminal, GNOME Terminal, and Konsole all bind Ctrl+←/→ for - // word navigation on Linux/Windows — but xterm.js emits \e[1;5D / \e[1;5C, - // which default readline (bash, zsh) does not bind to backward-word / - // forward-word. Translate to \eb / \ef (same bytes as our Alt+Arrow rule) - // so Ctrl+←/→ works for word-nav matching user expectations on those - // platforms without requiring a custom inputrc. + // Why: local Windows ConPTY shells (PowerShell/cmd via PSReadLine) already + // bind Ctrl+←/→ to word-nav, and they treat \eb/\ef (Alt+b/f) as + // Escape→RevertLine followed by a self-inserted "b"/"f" — so the translation + // below prints a stray letter instead of moving the cursor (issue: Ctrl+→ + // types "b"/"f" in PowerShell). Defer to xterm's native \e[1;5D / \e[1;5C + // there. Remote/WSL panes on a Windows client run readline and still need + // the translation, so this is gated on a genuine local native ConPTY, not + // merely on the client being Windows. + if (isLocalWindowsConptyPane?.()) { + return null + } + // Why: default readline (bash, zsh) does not bind the \e[1;5D / \e[1;5C that + // xterm.js emits for Ctrl+←/→, so Linux and remote/WSL shells need the + // translation to \eb / \ef (same bytes as our Alt+Arrow rule) for word-nav + // to work without a custom inputrc. // // Mac-gated: Ctrl+Arrow on macOS is reserved for Mission Control / Spaces // navigation at the OS level and should never reach the app. diff --git a/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts b/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts index 96ceafcce38..67505d5f622 100644 --- a/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts +++ b/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts @@ -1,6 +1,7 @@ import type { IBufferLine, IBufferRange, IDisposable, Terminal } from '@xterm/xterm' import { openHttpLink } from '@/lib/http-link-routing' import { buildCandidateLogicalLinesForBufferPosition } from './terminal-file-link-hit-testing' +import { isTerminalLinkActivation } from './terminal-link-activation' import { rangeForParsedFileLink } from './wrapped-terminal-link-ranges' type UrlLinkHitTestDeps = { @@ -48,13 +49,14 @@ export function extractTerminalHttpLinks(lineText: string): ParsedTerminalHttpLi return links } -function isPrimaryHttpLinkFallbackActivation(event: MouseEvent): boolean { +function isDesktopHttpLinkFallbackActivation(event: MouseEvent): boolean { if (event.defaultPrevented || event.button !== 0) { return false } - // Why: URL links now open on ordinary clicks, but macOS Ctrl-click must stay - // available for context menus even when Chromium reports it as button 0. - return !(navigator.userAgent.includes('Mac') && event.ctrlKey && !event.metaKey) + // Why: desktop terminal links require an intentional Cmd/Ctrl gesture so + // plain clicks remain available for cursor placement and selection. Mobile + // tap routing is handled separately under mobile/src/terminal. + return isTerminalLinkActivation(event) } function* iterateTerminalHttpUrlCandidates( @@ -220,7 +222,7 @@ export function installHttpLinkClickFallback( deps: UrlLinkClickFallbackDeps ): IDisposable { const handleMouseUp = (event: MouseEvent): void => { - if (!isPrimaryHttpLinkFallbackActivation(event)) { + if (!isDesktopHttpLinkFallbackActivation(event)) { return } @@ -230,8 +232,8 @@ export function installHttpLinkClickFallback( } // Why: xterm's WebLinksAddon only activates after hover state exists. This - // direct mouseup fallback preserves ordinary link clicks when the hover link - // was never established, while defaultPrevented avoids duplicate opens. + // direct mouseup fallback preserves modifier-clicks when the hover link was + // never established, while defaultPrevented avoids duplicate opens. const opened = openHttpLinkAtBufferPosition(terminal.buffer.active, position, terminal.cols, { worktreeId: deps.worktreeId, forceSystemBrowser: event.shiftKey, diff --git a/src/renderer/src/components/terminal-pane/terminal-user-input-signal.test.ts b/src/renderer/src/components/terminal-pane/terminal-user-input-signal.test.ts new file mode 100644 index 00000000000..6e76135ead0 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-user-input-signal.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, vi } from 'vitest' +import { Terminal } from '@xterm/xterm' +import { subscribeToTerminalUserInput } from './terminal-user-input-signal' + +type CoreServiceAccess = { + _core: { + coreService: { + triggerDataEvent: (data: string, wasUserInput?: boolean) => void + } + } +} + +// These tests run against the real vendored @xterm/xterm build on purpose: +// the subscription reaches a core-internal API, so an xterm upgrade that +// removes or reshapes it must fail here loudly instead of silently dropping +// terminal activity tracking to the onData fallback. +describe('subscribeToTerminalUserInput', () => { + it('fires for real user input and not for parser auto-replies', () => { + const terminal = new Terminal({ allowProposedApi: true }) + const listener = vi.fn() + const subscription = subscribeToTerminalUserInput(terminal, listener) + expect(subscription).not.toBeNull() + + const coreService = (terminal as unknown as CoreServiceAccess)._core.coreService + // Keyboard/IME/paste/mouse paths mark their data as user input. + coreService.triggerDataEvent('a', true) + expect(listener).toHaveBeenCalledTimes(1) + + // Parser-generated replies (focus reports, DA/DSR/CPR responses) are not + // user input and must not fire the signal, while still flowing to onData. + const onData = vi.fn() + terminal.onData(onData) + coreService.triggerDataEvent('\x1b[O', false) + coreService.triggerDataEvent('\x1b[?1;2c') + expect(listener).toHaveBeenCalledTimes(1) + expect(onData).toHaveBeenCalledTimes(2) + + subscription?.dispose() + coreService.triggerDataEvent('b', true) + expect(listener).toHaveBeenCalledTimes(1) + terminal.dispose() + }) + + it('returns null when the core signal is unavailable', () => { + const listener = vi.fn() + expect(subscribeToTerminalUserInput({} as never, listener)).toBeNull() + expect( + subscribeToTerminalUserInput({ _core: { coreService: {} } } as never, listener) + ).toBeNull() + expect( + subscribeToTerminalUserInput( + { + _core: { + coreService: { + onUserInput: () => { + throw new Error('unavailable') + } + } + } + } as never, + listener + ) + ).toBeNull() + // A reshaped internal that subscribes but returns no usable disposable + // must read as unavailable, so callers keep their onData fallback. + expect( + subscribeToTerminalUserInput( + { _core: { coreService: { onUserInput: () => undefined } } } as never, + listener + ) + ).toBeNull() + expect( + subscribeToTerminalUserInput( + { _core: { coreService: { onUserInput: () => ({}) } } } as never, + listener + ) + ).toBeNull() + expect(listener).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-user-input-signal.ts b/src/renderer/src/components/terminal-pane/terminal-user-input-signal.ts new file mode 100644 index 00000000000..714426b52f4 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-user-input-signal.ts @@ -0,0 +1,45 @@ +import type { Terminal } from '@xterm/xterm' + +// Why: xterm's public onData stream mixes real user input (keyboard, IME, +// paste, mouse reports) with parser-generated auto-replies (focus in/out +// reports, DA/DSR/CPR query responses). The core service already classifies +// the two — its onUserInput event fires only for real input — but that +// classification is not exposed on the public API. +type TerminalWithCoreUserInput = { + _core?: { + coreService?: { + onUserInput?: (listener: () => void) => { dispose?: unknown } | undefined + } + } +} + +/** + * Subscribe to xterm's core user-input signal. Fires only for real user + * input, never for the emulator's synthetic query replies that also flow + * through onData. + * + * Returns null when the internal API is unavailable (e.g. after an xterm + * upgrade) so callers can fall back to onData-based recording — degrading to + * the historical behavior instead of losing input tracking. + */ +export function subscribeToTerminalUserInput( + terminal: Terminal, + listener: () => void +): { dispose: () => void } | null { + const coreService = (terminal as unknown as TerminalWithCoreUserInput)._core?.coreService + if (!coreService || typeof coreService.onUserInput !== 'function') { + return null + } + try { + const subscription = coreService.onUserInput(listener) + // Why: a reshaped internal that subscribes but returns no disposable must + // not be treated as live — callers disable their onData fallback on a + // non-null return, and activity tracking would silently disappear. + if (subscription && typeof subscription.dispose === 'function') { + return subscription as { dispose: () => void } + } + return null + } catch { + return null + } +} 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 new file mode 100644 index 00000000000..d4b9762163a --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { PaneManager } from '@/lib/pane-manager/pane-manager' +import { + recoverVisibleTerminalWindowWake, + resumeTerminalVisibility +} from './terminal-visibility-resume' + +vi.mock('@/lib/pane-manager/pane-manager-registry', () => ({ + resetAndRefreshAllTerminalWebglAtlases: vi.fn() +})) +vi.mock('@/lib/pane-manager/pane-terminal-output-scheduler', () => ({ + flushTerminalOutput: vi.fn(), + requestTerminalBacklogRecovery: vi.fn() +})) +vi.mock('@/lib/pane-manager/terminal-scroll-intent', () => ({ + enforceTerminalCurrentScrollIntent: vi.fn() +})) +vi.mock('./pane-helpers', () => ({ + fitAndFocusPanes: vi.fn(), + fitPanes: vi.fn(), + focusActivePane: vi.fn() +})) +vi.mock('./terminal-webgl-atlas-recovery', () => ({ + scheduleTerminalWebglAtlasRecovery: vi.fn() +})) + +type FakeManager = { + getPanes: ReturnType + resumeRendering: ReturnType + scheduleRevealRepaint: ReturnType +} + +function createManager(order: string[] = []): FakeManager { + return { + getPanes: vi.fn(() => []), + resumeRendering: vi.fn(() => order.push('resume-rendering')), + scheduleRevealRepaint: vi.fn(() => order.push('reveal-repaint')) + } +} + +function resumeArgs(manager: FakeManager, shouldUseLightTabResume: boolean) { + return { + manager: manager as never as PaneManager, + isActive: true, + wasVisible: false, + shouldUseLightTabResume, + captureViewportPositions: vi.fn(() => new Map()), + withSuppressedScrollTracking: (callback: () => void) => callback() + } +} + +describe('resumeTerminalVisibility reveal repaint', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('schedules a pane-scoped repaint on a light tab reveal', () => { + // The light path is the "click the tab that was not open" gesture: it has + // no rendering resume or fit, so without this repaint a hidden-while- + // working pane keeps compositing pre-hide pixels. + const manager = createManager() + resumeTerminalVisibility(resumeArgs(manager, true)) + + expect(manager.scheduleRevealRepaint).toHaveBeenCalledTimes(1) + expect(manager.resumeRendering).not.toHaveBeenCalled() + }) + + it('schedules the repaint after rendering resumes on a heavy reveal', () => { + const order: string[] = [] + const manager = createManager(order) + resumeTerminalVisibility(resumeArgs(manager, false)) + + expect(order).toEqual(['resume-rendering', 'reveal-repaint']) + }) + + it('schedules the repaint on window-wake recovery', () => { + const manager = createManager() + recoverVisibleTerminalWindowWake({ + manager: manager as never as PaneManager, + isActive: false + }) + + expect(manager.scheduleRevealRepaint).toHaveBeenCalledTimes(1) + }) +}) 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 408f8afe952..fdf597c5587 100644 --- a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts @@ -75,6 +75,10 @@ export function resumeTerminalVisibility({ // terminals; refresh after reset so rebuilt atlases repaint from xterm. resetAndRefreshAllTerminalWebglAtlases() } + // Why: the synchronous recovery above can fire before the revealed pane is + // attached and laid out, where the WebGL renderer drops redraw requests + // without retry. Follow up with a settled-frame, pane-scoped repaint. + manager.scheduleRevealRepaint() }) } @@ -93,9 +97,8 @@ export function hideTerminalVisibility({ captureViewportPositions(false) } if (!isWorktreeActive && (wasVisible || surfaceBecameHidden)) { - // Suspend WebGL when going hidden. xterm.write() continues to land in - // the (now DOM-renderer-fallback or paused-canvas) terminal; the - // suspend is purely a GPU resource decision. + // Suspend WebGL when going hidden. xterm.write() continues to land in the + // DOM-renderer fallback terminal; the suspend is purely a GPU resource decision. manager.suspendRendering() return { hiddenReason: 'surface', renderingSuspended: true } } @@ -133,6 +136,7 @@ export function recoverVisibleTerminalWindowWake({ } enforceTerminalViewportIntents(manager) resetAndRefreshAllTerminalWebglAtlases() + manager.scheduleRevealRepaint() } function requestLightTabBacklogRecovery(manager: PaneManager): void { 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 61dc3a1533d..0ad8f80d141 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 @@ -130,6 +130,7 @@ function useMountForFileDrop( getPanes: ReturnType resumeRendering: ReturnType resetWebglTextureAtlases: ReturnType + scheduleRevealRepaint: ReturnType suspendRendering: ReturnType getActivePane: ReturnType } @@ -146,6 +147,7 @@ function useMountForFileDrop( getPanes: vi.fn(() => []), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), suspendRendering: vi.fn(), getActivePane: vi.fn(() => null) } @@ -222,6 +224,7 @@ describe('useTerminalPaneGlobalEffects', () => { ]), resumeRendering: vi.fn(() => order.push('resume')), resetWebglTextureAtlases: vi.fn(() => order.push('reset-atlas')), + scheduleRevealRepaint: vi.fn(() => order.push('reveal-repaint')), refreshAllPanes: vi.fn(() => order.push('refresh')), suspendRendering: vi.fn(), fitAllPanes: vi.fn(), @@ -280,7 +283,8 @@ describe('useTerminalPaneGlobalEffects', () => { 'intent:terminal-a', 'intent:terminal-b', 'reset-atlas', - 'refresh' + 'refresh', + 'reveal-repaint' ]) expect(mocks.restoreScrollStateAfterLayout).not.toHaveBeenCalled() expect(mocks.flushTerminalOutput).toHaveBeenNthCalledWith(1, terminalA, { @@ -308,6 +312,7 @@ describe('useTerminalPaneGlobalEffects', () => { getPanes: vi.fn(() => [{ id: 1, terminal }]), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), refreshAllPanes: vi.fn(), suspendRendering: vi.fn(), fitAllPanes: vi.fn(), @@ -387,6 +392,7 @@ describe('useTerminalPaneGlobalEffects', () => { getPanes: vi.fn(() => [{ id: 1, terminal }]), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), refreshAllPanes: vi.fn(), suspendRendering: vi.fn(), fitAllPanes: vi.fn(), @@ -448,6 +454,7 @@ describe('useTerminalPaneGlobalEffects', () => { getPanes: vi.fn(() => [{ id: 1, terminal }]), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), refreshAllPanes: vi.fn(), suspendRendering: vi.fn(), fitAllPanes: vi.fn(), @@ -501,6 +508,7 @@ describe('useTerminalPaneGlobalEffects', () => { getPanes: vi.fn(() => [{ id: 1, terminal }]), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), refreshAllPanes: vi.fn(), suspendRendering: vi.fn(), fitAllPanes: vi.fn(), @@ -578,6 +586,7 @@ describe('useTerminalPaneGlobalEffects', () => { getPanes: vi.fn(() => [{ id: 1, terminal: { name: 'terminal-a' } }]), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), suspendRendering: vi.fn(), getActivePane: vi.fn(() => ({ id: 1, terminal: { name: 'terminal-a' } })) } @@ -609,6 +618,7 @@ describe('useTerminalPaneGlobalEffects', () => { getPanes: vi.fn(() => [{ id: 1, terminal: terminalA }]), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), suspendRendering: vi.fn(), fitAllPanes: vi.fn(), getActivePane: vi.fn(() => null), @@ -667,6 +677,7 @@ describe('useTerminalPaneGlobalEffects', () => { getPanes: vi.fn(() => []), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), suspendRendering: vi.fn(), getActivePane: vi.fn(() => null) } @@ -711,6 +722,7 @@ describe('useTerminalPaneGlobalEffects', () => { getPanes: vi.fn(() => [{ id: 1, terminal }]), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), refreshAllPanes: vi.fn(), suspendRendering: vi.fn(), getActivePane: vi.fn(() => ({ id: 1, terminal })) @@ -775,6 +787,7 @@ describe('useTerminalPaneGlobalEffects', () => { getPanes: vi.fn(() => []), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), suspendRendering: vi.fn(), getActivePane: vi.fn(() => null) } @@ -829,6 +842,7 @@ describe('useTerminalPaneGlobalEffects', () => { getPanes: vi.fn(() => []), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), suspendRendering: vi.fn(), getActivePane: vi.fn(() => null) } @@ -874,6 +888,7 @@ describe('useTerminalPaneGlobalEffects', () => { getPanes: vi.fn(() => [pane]), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), suspendRendering: vi.fn(), getActivePane: vi.fn(() => pane) } @@ -929,6 +944,7 @@ describe('useTerminalPaneGlobalEffects', () => { getPanes: vi.fn(() => [pane]), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), suspendRendering: vi.fn(), getActivePane: vi.fn(() => pane) } @@ -1044,6 +1060,7 @@ describe('useTerminalPaneGlobalEffects', () => { getPanes: vi.fn(() => []), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), suspendRendering: vi.fn(), fitAllPanes: vi.fn(), getActivePane: vi.fn(() => null) @@ -1077,6 +1094,7 @@ describe('useTerminalPaneGlobalEffects', () => { getPanes: vi.fn(() => []), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), + scheduleRevealRepaint: vi.fn(), suspendRendering: vi.fn(), fitAllPanes: vi.fn(), getActivePane: vi.fn(() => null) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts index 8806cde6030..d3f35c8f5e1 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts @@ -8,7 +8,6 @@ import { resolvePaneLinkCwd, resolvePaneSeedCwd, resolveQueuedInitialCwd, - scheduleVisibilityReconcilePass, shouldDetachPaneTransportOnUnmount, splitPaneWithOneShotStartup, suppressIntentionalPaneCloseExit @@ -291,7 +290,7 @@ describe('suppressIntentionalPaneCloseExit', () => { }) }) -describe('scheduleVisibilityReconcilePass', () => { +describe('terminal pane visibility resume tracking', () => { it('ignores previous visibility from a different terminal identity', () => { expect( getPreviousVisibleForTerminalPane({ @@ -324,57 +323,4 @@ describe('scheduleVisibilityReconcilePass', () => { ) expect(isTerminalPaneVisibilityResume({ previousIsVisible: false, isVisible: true })).toBe(true) }) - - it('schedules a reconcile pass over the bindings when becoming visible', async () => { - const reconcileIfSessionDead = vi.fn() - const listSessions = vi - .fn<() => Promise<{ id: string; cwd: string; title: string }[]>>() - .mockResolvedValue([{ id: 'live-1', cwd: '/a', title: 'a' }]) - - const scheduled = scheduleVisibilityReconcilePass({ - previousIsVisible: false, - isVisible: true, - bindings: [{ reconcileIfSessionDead }], - listSessions - }) - - expect(scheduled).toBe(true) - // Fire-and-forget: let the async listSessions resolve before asserting. - await Promise.resolve() - await Promise.resolve() - expect(listSessions).toHaveBeenCalledTimes(1) - expect(reconcileIfSessionDead).toHaveBeenCalledWith(new Set(['live-1']), expect.any(Number)) - }) - - it('does not schedule on an initially visible mount', () => { - const listSessions = vi - .fn<() => Promise<{ id: string; cwd: string; title: string }[]>>() - .mockResolvedValue([]) - - const scheduled = scheduleVisibilityReconcilePass({ - previousIsVisible: null, - isVisible: true, - bindings: [{ reconcileIfSessionDead: vi.fn() }], - listSessions - }) - - expect(scheduled).toBe(false) - expect(listSessions).not.toHaveBeenCalled() - }) - - it('self-gates: does not schedule when hiding (isVisible false)', () => { - const listSessions = vi - .fn<() => Promise<{ id: string; cwd: string; title: string }[]>>() - .mockResolvedValue([]) - - const scheduled = scheduleVisibilityReconcilePass({ - previousIsVisible: true, - isVisible: false, - bindings: [{ reconcileIfSessionDead: vi.fn() }], - listSessions - }) - - expect(scheduled).toBe(false) - expect(listSessions).not.toHaveBeenCalled() - }) }) 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 442f64a05fd..e037e1e7f83 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -2,7 +2,11 @@ import { useEffect, useRef } from 'react' import type { IDisposable, Terminal } from '@xterm/xterm' import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-types' -import { PaneManager } from '@/lib/pane-manager/pane-manager' +import { + PaneManager, + type PaneExternalDropHandler, + type PaneExternalDropResolver +} from '@/lib/pane-manager/pane-manager' import { consumePendingWebRuntimeSplitMirrorTelemetry } from '@/runtime/web-runtime-session' import { normalizeTerminalFastScrollSensitivity, @@ -78,8 +82,11 @@ import { installMouseHideWhileTyping } from './mouse-hide-while-typing' import type { EffectiveMacOptionAsAlt } from '@/lib/keyboard-layout/detect-option-as-alt' import { resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme' import { connectPanePty } from './pty-connection' -import { reconcileDeadSessions, type ReconcilableBinding } from './terminal-dead-session-reconcile' import type { PtyTransport } from './pty-transport' +import { + reconcileMissingSessions, + type ReconcilableBinding +} from './terminal-dead-session-reconcile' import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream' import { getConnectionId } from '@/lib/connection-context' import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' @@ -270,6 +277,8 @@ type UseTerminalPaneLifecycleDeps = { // Why: same pane count does not imply same geometry; drag-reorder can move // panes without resizing them, so overlay rects need a layout-change tick. setPaneLayoutRevision: React.Dispatch> + resolveExternalPaneDropTarget?: PaneExternalDropResolver + onExternalPaneDrop?: PaneExternalDropHandler } export function suppressIntentionalPaneCloseExit( @@ -473,21 +482,6 @@ export function getPreviousVisibleForTerminalPane(args: { return args.previous.isVisible } -export function scheduleVisibilityReconcilePass(args: { - previousIsVisible: boolean | null - isVisible: boolean - bindings: Iterable - listSessions: () => Promise<{ id: string; cwd: string; title: string }[]> -}): boolean { - if (!isTerminalPaneVisibilityResume(args)) { - return false - } - // Why: fire-and-forget so the async listSessions IPC never blocks the - // synchronous WebGL/fit resume work the user sees first. - void reconcileDeadSessions({ bindings: args.bindings, listSessions: args.listSessions }) - return true -} - export function useTerminalPaneLifecycle({ tabId, worktreeId, @@ -544,7 +538,9 @@ export function useTerminalPaneLifecycle({ paneTitlesRef, setRenamingPaneId, setPaneCount, - setPaneLayoutRevision + setPaneLayoutRevision, + resolveExternalPaneDropTarget, + onExternalPaneDrop }: UseTerminalPaneLifecycleDeps): void { const terminalScrollbackRows = normalizeDesktopTerminalScrollbackRows( settings?.terminalScrollbackRows @@ -567,6 +563,7 @@ export function useTerminalPaneLifecycle({ const imeCompositionDisposablesRef = useRef(new Map()) const imeNativeTextForwarderDisposablesRef = useRef(new Map()) const queuedInitialCwdRef = useRef(undefined) + const restoredViewportBlankingPanesRef = useRef(new Set()) const applyAppearance = (manager: PaneManager): void => { const currentSettings = settingsRef.current @@ -730,6 +727,7 @@ export function useTerminalPaneLifecycle({ paneMode2031Ref, paneLastThemeModeRef, replayingPanesRef, + restoredViewportBlankingPanesRef, isActiveRef, isVisibleRef, onPtyExitRef, @@ -1029,7 +1027,7 @@ export function useTerminalPaneLifecycle({ pane.terminal.options.linkHandler = { allowNonHttpProtocols: true, activate: (event, text) => { - handleOscLink(text, event as MouseEvent | undefined, { + const handled = handleOscLink(text, event as MouseEvent | undefined, { ...linkDeps, startupCwd: getPaneLinkCwd(pane.id), runtimeEnvironmentId: linkDeps.getRuntimeEnvironmentIdForPane?.(pane.id) ?? null, @@ -1043,7 +1041,9 @@ export function useTerminalPaneLifecycle({ // moving the mouse extends a selection until the next click/Esc. // clearSelection() explicitly detaches those listeners (see // SelectionService._removeMouseDownListeners). - pane.terminal.clearSelection() + if (handled) { + pane.terminal.clearSelection() + } }, // Show bottom-left tooltip on hover for OSC 8 hyperlinks (e.g. // GitHub owner/repo#issue references emitted by CLI tools) — same @@ -1104,6 +1104,7 @@ export function useTerminalPaneLifecycle({ queueResizeAll(true) }, onPaneClosed: (paneId, closedPane) => { + const isDetachedToTab = closedPane?.reason === 'detach' const linkProviderDisposable = linkProviderDisposablesRef.current.get(paneId) if (linkProviderDisposable) { linkProviderDisposable.dispose() @@ -1179,36 +1180,44 @@ export function useTerminalPaneLifecycle({ panePtyBinding.dispose() panePtyBindings.delete(paneId) } - // Why: closing a pane is user-initiated teardown of this row — drop - // (not remove) so any retained `done` snapshot for this pane is also - // cleared and a same-frame live→gone transition cannot re-snapshot - // it via the retention sync. This is pane-keyed state, so it must - // clear even if the PTY transport was already removed. const leafId = closedPane?.leafId - if (leafId) { + if (leafId && !isDetachedToTab) { + // Why: closing a pane is user-initiated teardown of this row — drop + // (not remove) so any retained `done` snapshot for this pane is also + // cleared and a same-frame live→gone transition cannot re-snapshot + // it via the retention sync. This is pane-keyed state, so it must + // clear even if the PTY transport was already removed. const paneKey = makePaneKey(tabId, leafId) useAppStore.getState().setCacheTimerStartedAt(paneKey, null) clearTerminalPaneUnread(paneKey) useAppStore.getState().dropAgentStatus(paneKey) + useAppStore.getState().clearPaneForegroundAgent(paneKey) } if (transport) { - const ptyId = suppressIntentionalPaneCloseExit( - transport, - useAppStore.getState().suppressPtyExit - ) - if (ptyId) { - // Why: user/CLI pane closes intentionally tear down this PTY after - // PaneManager has already promoted the sibling. Suppress that exit - // so the last-surviving pane is not mistaken for an exited tab. - syncPanePtyLayoutBinding(paneId, null) - clearTabPtyId(tabId, ptyId) + if (isDetachedToTab) { + // Why: pane-to-tab detach hands the PTY to a newly-created tab; + // detach renderer listeners without sending a process teardown. + transport.detach?.() + } else { + const ptyId = suppressIntentionalPaneCloseExit( + transport, + useAppStore.getState().suppressPtyExit + ) + if (ptyId) { + // Why: user/CLI pane closes intentionally tear down this PTY after + // PaneManager has already promoted the sibling. Suppress that exit + // so the last-surviving pane is not mistaken for an exited tab. + syncPanePtyLayoutBinding(paneId, null) + clearTabPtyId(tabId, ptyId) + } + transport.destroy?.() } - transport.destroy?.() paneTransportsRef.current.delete(paneId) } clearRuntimePaneTitle(tabId, paneId) paneFontSizesRef.current.delete(paneId) replayingPanesRef.current.delete(paneId) + restoredViewportBlankingPanesRef.current.delete(paneId) // Clean up pane title state so closed panes don't leave stale entries. setPaneTitles((prev) => { if (!(paneId in prev)) { @@ -1302,6 +1311,8 @@ export function useTerminalPaneLifecycle({ releaseWebviewDragPassthrough?.() releaseWebviewDragPassthrough = null }, + resolveExternalPaneDropTarget, + onExternalPaneDrop, terminalOptions: () => { const currentSettings = settingsRef.current const terminalFontWeights = resolveTerminalFontWeights(currentSettings?.terminalFontWeight) @@ -1356,7 +1367,7 @@ export function useTerminalPaneLifecycle({ return } const activePane = managerRef.current?.getActivePane() - void handleOscLink(url, event, { + const handled = handleOscLink(url, event, { ...linkDeps, startupCwd: activePane ? getPaneLinkCwd(activePane.id) : startupCwd, runtimeEnvironmentId: activePane @@ -1371,7 +1382,9 @@ export function useTerminalPaneLifecycle({ // phantom selection until the next click/Esc. Explicitly clearing the // selection also detaches those listeners (see // SelectionService._removeMouseDownListeners). - managerRef.current?.getActivePane()?.terminal.clearSelection() + if (handled) { + managerRef.current?.getActivePane()?.terminal.clearSelection() + } }, formatLinkTooltip: (url, openLinkHint) => formatTerminalUrlTooltip(url, openLinkHint), // Why: TerminalPane instances stay mounted for hidden visited worktrees @@ -1396,7 +1409,13 @@ export function useTerminalPaneLifecycle({ const restoredPaneByLeafId = replayTerminalLayout(manager, initialLayoutRef.current, isActive) const restoredBuffers = initialLayoutRef.current.buffersByLeafId - restoreScrollbackBuffers(manager, restoredBuffers, restoredPaneByLeafId, replayingPanesRef) + restoreScrollbackBuffers( + manager, + restoredBuffers, + restoredPaneByLeafId, + replayingPanesRef, + restoredViewportBlankingPanesRef + ) if (restoredBuffers && initialLayoutRef.current.scrollbackRefsByLeafId) { const layoutWithoutRestoredBuffers = { ...initialLayoutRef.current } delete layoutWithoutRestoredBuffers.buffersByLeafId @@ -1694,23 +1713,20 @@ export function useTerminalPaneLifecycle({ noteVisibilityResume?: () => void } bindingWithVisibility.syncProcessTracking?.() - // Why: re-arm the once-per-resume input liveness re-check so the typing - // hot path stays off the listSessions IPC between resumes (the re-check - // is only useful right after a hidden→visible flip). + // Why: visible-resume repairs dropped hidden resizes, but it must not fit + // against xterm's transient hidden DOM fallback. if (resumedFromHidden) { bindingWithVisibility.noteVisibilityResume?.() } } - // Why: the reconcile pass self-gates on becoming visible (resume) — the - // effect also fires on hide and initial mount. Initial visible mounts are - // fresh PTY startup, so an early listSessions snapshot must not close the - // newborn tab before the daemon lists it. - scheduleVisibilityReconcilePass({ - previousIsVisible, - isVisible, - bindings: panePtyBindingsRef.current.values() as Iterable, - listSessions: () => window.api.pty.listSessions() - }) + if (resumedFromHidden && typeof window.api.pty.hasPty === 'function') { + // Why: preserve missed-exit recovery without daemon-wide listSessions; + // providers can answer a single-PTY liveness check from in-memory state. + reconcileMissingSessions({ + bindings: panePtyBindingsRef.current.values() as Iterable, + hasPty: window.api.pty.hasPty + }) + } // eslint-disable-next-line react-hooks/exhaustive-deps -- Why: visibility and terminal identity changes must refresh existing PTY process tracking even though the ref object identity is stable. }, [cwd, isVisible, isVisibleRef, panePtyBindingsRef, tabId]) 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 ba1443477bf..a070cdc9331 100644 --- a/src/renderer/src/components/terminal/terminal-tab-actions.test.ts +++ b/src/renderer/src/components/terminal/terminal-tab-actions.test.ts @@ -73,7 +73,7 @@ describe('createNewTerminalTab', () => { createNewTerminalTab('wt-1', 'zsh') - expect(createTab).toHaveBeenCalledWith('wt-1', undefined, 'zsh') + expect(createTab).toHaveBeenCalledWith('wt-1', undefined, 'zsh', undefined) expect(setActiveTabType).toHaveBeenCalledWith('terminal') expect(setTabBarOrder).toHaveBeenCalledWith('wt-1', ['tab-1']) expect(createWebRuntimeSessionTerminalMock).not.toHaveBeenCalled() @@ -123,6 +123,53 @@ describe('createNewTerminalTab', () => { }) expect(createTab).not.toHaveBeenCalled() }) + + it('creates local terminal tabs with a requested startup cwd', () => { + const createTab = vi.fn(() => ({ id: 'tab-1' })) + const setActiveTabType = vi.fn() + const setTabBarOrder = vi.fn() + getStateMock + .mockReturnValueOnce({ + settings: { activeRuntimeEnvironmentId: null }, + createTab, + setActiveTabType, + setTabBarOrder + }) + .mockReturnValueOnce({ + tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] }, + openFiles: [], + tabBarOrderByWorktree: {}, + setTabBarOrder + }) + + createNewTerminalTab('wt-1', undefined, { startupCwd: '/repo/packages/app' }) + + expect(createTab).toHaveBeenCalledWith('wt-1', undefined, undefined, { + startupCwd: '/repo/packages/app' + }) + expect(setActiveTabType).toHaveBeenCalledWith('terminal') + }) + + it('delegates requested startup cwd to host runtime terminals', () => { + const createTab = vi.fn(() => ({ id: 'tab-1' })) + isWebRuntimeSessionActiveMock.mockReturnValue(true) + getStateMock.mockReturnValue({ + settings: { activeRuntimeEnvironmentId: 'web-runtime' }, + createTab, + setActiveTabType: vi.fn() + }) + + createNewTerminalTab('wt-1', undefined, { startupCwd: '/repo/packages/app' }) + + expect(createWebRuntimeSessionTerminalMock).toHaveBeenCalledWith({ + worktreeId: 'wt-1', + environmentId: 'web-runtime', + command: undefined, + cwd: '/repo/packages/app', + activate: true + }) + expect(createTab).not.toHaveBeenCalled() + }) }) describe('closeTerminalTab', () => { diff --git a/src/renderer/src/components/terminal/terminal-tab-actions.ts b/src/renderer/src/components/terminal/terminal-tab-actions.ts index 77f604b7480..9991b7648ea 100644 --- a/src/renderer/src/components/terminal/terminal-tab-actions.ts +++ b/src/renderer/src/components/terminal/terminal-tab-actions.ts @@ -101,7 +101,8 @@ function isPinnedVisibleTab( export function createNewTerminalTab( activeWorktreeId: string | null, - shellOverride?: string + shellOverride?: string, + options?: { startupCwd?: string } ): void { if (!activeWorktreeId) { return @@ -116,11 +117,17 @@ export function createNewTerminalTab( worktreeId: activeWorktreeId, environmentId: runtimeEnvironmentId, command: shellOverride, + ...(options?.startupCwd ? { cwd: options.startupCwd } : {}), activate: true }) return } - const newTab = state.createTab(activeWorktreeId, undefined, shellOverride) + const newTab = state.createTab( + activeWorktreeId, + undefined, + shellOverride, + options?.startupCwd ? { startupCwd: options.startupCwd } : undefined + ) state.setActiveTabType('terminal') // Why: persist the tab bar order with the new terminal at the end of the // current visual order. Without this, reconcileTabOrder falls back to diff --git a/src/renderer/src/hooks/composer-branch-selection.test.ts b/src/renderer/src/hooks/composer-branch-selection.test.ts index 2d379924576..cf4e8fb7c61 100644 --- a/src/renderer/src/hooks/composer-branch-selection.test.ts +++ b/src/renderer/src/hooks/composer-branch-selection.test.ts @@ -4,6 +4,7 @@ import { resolveComposerBranchNameOverrideForCreate, resolveComposerBranchReuse, resolveComposerBranchSelection, + resolveComposerManualBranchNameChange, resolveComposerReuseOverride } from './composer-branch-selection' @@ -57,15 +58,28 @@ describe('resolveComposerBranchSelection', () => { }) }) - it('keeps resolver-provided PR branch overrides when the workspace name changes', () => { + it('keeps manual branch-name overrides when preserving workspace edits', () => { expect( resolveComposerBranchNameOverrideForCreate({ - branchNameOverride: 'feature/fix', - branchAutoName: '', + branchNameOverride: 'feature/manual', + branchAutoName: 'feature/auto', workspaceName: 'edited display name', preserveWorkspaceNameEdits: true }) - ).toBe('feature/fix') + ).toBe('feature/manual') + }) + + it('drops empty or missing branch-name overrides even when preserving workspace edits', () => { + for (const branchNameOverride of [undefined, '']) { + expect( + resolveComposerBranchNameOverrideForCreate({ + branchNameOverride, + branchAutoName: 'feature/fix', + workspaceName: 'edited display name', + preserveWorkspaceNameEdits: true + }) + ).toBeUndefined() + } }) it('keeps existing branch picker override behavior tied to the auto-name', () => { @@ -78,6 +92,110 @@ describe('resolveComposerBranchSelection', () => { }) ).toBeUndefined() }) + + it('uses a slash-containing typed branch name as the create override in branch mode', () => { + expect( + resolveComposerBranchNameOverrideForCreate({ + branchNameOverride: undefined, + branchAutoName: '', + workspaceName: 'feature/user-profile', + preserveWorkspaceNameEdits: false, + createBranchFromWorkspaceName: true + }) + ).toBe('feature/user-profile') + }) + + it('keeps plain typed branch names on the existing sanitized-name path', () => { + expect( + resolveComposerBranchNameOverrideForCreate({ + branchNameOverride: undefined, + branchAutoName: '', + workspaceName: 'feature-user-profile', + preserveWorkspaceNameEdits: false, + createBranchFromWorkspaceName: true + }) + ).toBeUndefined() + }) + + it('does not preserve a slash typed name outside branch mode (gate off)', () => { + // Why: only branch mode opts into slash preservation; every other mode keeps + // deriving the branch from the sanitized workspace name. + expect( + resolveComposerBranchNameOverrideForCreate({ + branchNameOverride: undefined, + branchAutoName: '', + workspaceName: 'feature/user-profile', + preserveWorkspaceNameEdits: false, + createBranchFromWorkspaceName: false + }) + ).toBeUndefined() + }) + + it('keeps a resolver-provided override even in branch mode with a slash name', () => { + // Why: a picked branch (override set) wins over the typed slash name so the + // branch-mode gate never hijacks an explicit branch selection. + expect( + resolveComposerBranchNameOverrideForCreate({ + branchNameOverride: 'feature/picked', + branchAutoName: 'feature/picked', + workspaceName: 'feature/user-profile', + preserveWorkspaceNameEdits: true, + createBranchFromWorkspaceName: true + }) + ).toBe('feature/picked') + }) +}) + +describe('resolveComposerManualBranchNameChange', () => { + const forkPushTarget = { + remoteName: 'contributor', + branchName: 'feature/from-pr', + remoteUrl: 'https://example.com/contributor/repo.git' + } + + it('clears a PR-derived push target when the manual branch changes to a different branch', () => { + expect( + resolveComposerManualBranchNameChange({ + value: 'feature/manual', + pushTarget: forkPushTarget, + forkPushWarning: 'Cannot push to fork' + }) + ).toEqual({ + branchNameOverride: 'feature/manual', + pushTarget: undefined, + forkPushWarning: null + }) + }) + + it('clears a PR-derived push target when manual branch input is empty or whitespace', () => { + for (const value of ['', ' ']) { + expect( + resolveComposerManualBranchNameChange({ + value, + pushTarget: forkPushTarget, + forkPushWarning: 'Cannot push to fork' + }) + ).toEqual({ + branchNameOverride: undefined, + pushTarget: undefined, + forkPushWarning: null + }) + } + }) + + it('preserves a PR-derived push target when the manual branch exactly matches its branch', () => { + expect( + resolveComposerManualBranchNameChange({ + value: 'feature/from-pr', + pushTarget: forkPushTarget, + forkPushWarning: 'Cannot push to fork' + }) + ).toEqual({ + branchNameOverride: 'feature/from-pr', + pushTarget: forkPushTarget, + forkPushWarning: 'Cannot push to fork' + }) + }) }) describe('isBranchCheckedOutInWorktrees', () => { diff --git a/src/renderer/src/hooks/composer-branch-selection.ts b/src/renderer/src/hooks/composer-branch-selection.ts index e254ac6c933..02f2dfce79f 100644 --- a/src/renderer/src/hooks/composer-branch-selection.ts +++ b/src/renderer/src/hooks/composer-branch-selection.ts @@ -3,6 +3,7 @@ export { resolveComposerBranchNameOverrideForCreate, resolveComposerBranchReuse, resolveComposerBranchSelection, + resolveComposerManualBranchNameChange, resolveComposerReuseOverride, type ComposerBranchSelection } from '../../../shared/composer-branch-selection' diff --git a/src/renderer/src/hooks/terminal-command-finished-event.ts b/src/renderer/src/hooks/terminal-command-finished-event.ts new file mode 100644 index 00000000000..5a304b41846 --- /dev/null +++ b/src/renderer/src/hooks/terminal-command-finished-event.ts @@ -0,0 +1,21 @@ +export const ORCA_TERMINAL_COMMAND_FINISHED_EVENT = 'orca:terminal-command-finished' + +export type TerminalCommandFinishedEventDetail = { + worktreeId: string +} + +// Why: the OSC 133;D handler lives in a per-pane closure; a window event lets +// decoupled consumers (e.g. git status refresh) react to shell commands +// finishing without reaching into terminal internals. +export function dispatchTerminalCommandFinishedEvent(worktreeId: string): void { + // Why: unit tests and non-DOM renderer shims may expose only the preload API. + if (typeof window.dispatchEvent !== 'function') { + return + } + + window.dispatchEvent( + new CustomEvent(ORCA_TERMINAL_COMMAND_FINISHED_EVENT, { + detail: { worktreeId } + }) + ) +} 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 1fcc595db94..00aa3fe4fea 100644 --- a/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts +++ b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts @@ -209,6 +209,8 @@ describe('useComposerState host-context boundaries', () => { 'const applyLinkedWorkItem = useCallback' ) expect(directLookup).toContain('sourceContext: selectedRepoGitHubSourceContext') + expect(directLookup).toContain('lookupGitHubWorkItemByOwnerRepoForSource') + expect(directLookup).toContain('type: normalizedLinkQuery.directLink.type') const submitLookup = sourceBetween( HOOK_SOURCE, @@ -236,7 +238,11 @@ describe('useComposerState host-context boundaries', () => { 'const intent = getSmartGitHubSubmitIntent(name)' ) expect(selectedPrSubmitLookup).toContain('smartGitHubPrStartPointSelectionRef.current') - expect(selectedPrSubmitLookup).toContain("linkedWorkItem.type === 'pr'") + expect(selectedPrSubmitLookup).toContain("linkedWorkItemIdentity?.type === 'pr'") + expect(selectedPrSubmitLookup).toContain("startPointIdentity?.type === 'pr'") + expect(selectedPrSubmitLookup).toContain( + 'startPointIdentity.number === linkedWorkItemIdentity.number' + ) expect(selectedPrSubmitLookup).toContain('resolveGitHubPrStartPointForRepo') expect(selectedPrSubmitLookup.indexOf('resolveGitHubPrStartPointForRepo')).toBeLessThan( selectedPrSubmitLookup.indexOf("return { kind: 'none' }") @@ -339,6 +345,19 @@ describe('useComposerState host-context boundaries', () => { expect(section).not.toContain('folderSourceRequiresConnection') }) + it('clears branch reuse state when manually editing the branch name', () => { + const section = sourceBetween( + HOOK_SOURCE, + 'const handleBranchNameOverrideChange = useCallback', + 'const addComposerAttachments = useCallback' + ) + + expect(section).toContain('resolveComposerManualBranchNameChange') + expect(section).toContain('setReuseEligibleBranch(null)') + expect(section).toContain('setReuseSelectedBranch(false)') + expect(section).toContain("branchAutoNameRef.current = ''") + }) + it('forces repo-scoped source reset when returning from folder target to a repo with the same id', () => { const handleRepoChange = sourceBetween( HOOK_SOURCE, @@ -408,8 +427,12 @@ describe('useComposerState host-context boundaries', () => { ) expect(projectGroupSmartHandlers).toContain('setLinkedGitLabIssue(null)') expect(projectGroupSmartHandlers).toContain('setLinkedGitLabMR(null)') - expect(projectGroupSmartHandlers).toContain("setLinkedIssue('')") - expect(projectGroupSmartHandlers).toContain('setLinkedPR(null)') + expect(projectGroupSmartHandlers).toContain( + "setLinkedIssue(identity.type === 'issue' ? String(identity.number) : '')" + ) + expect(projectGroupSmartHandlers).toContain( + "setLinkedPR(identity.type === 'pr' ? identity.number : null)" + ) }) it('disables repo-backed folder smart lookup when a folder target has no source repos', () => { @@ -489,8 +512,8 @@ describe('useComposerState host-context boundaries', () => { expect(section).toContain('resolveWorktreeCreateBaseBranch') expect(section).toContain('explicitBaseBranch: smartSubmitBaseBranch') - expect(section).toContain('repoWorktreeBaseRef: selectedRepo.worktreeBaseRef') - expect(section).toContain('getRuntimeRepoBaseRefDefault') + expect(section).not.toContain('repoWorktreeBaseRef: selectedRepo.worktreeBaseRef') + expect(section).not.toContain('getRuntimeRepoBaseRefDefault') }) it('plans new workspace agent startup from the selected repo runtime', () => { diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index df080dbef86..dc6e1aa1957 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -29,7 +29,6 @@ import { import { tuiAgentToAgentKind } from '@/lib/telemetry' import { isGitRepoKind } from '../../../shared/repo-kind' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' -import { getRuntimeRepoBaseRefDefault } from '@/runtime/runtime-repo-client' import { resolveWorktreeCreateBaseBranch } from '@/runtime/worktree-create-base' import { buildTaskSourceContextFromRepo, @@ -95,6 +94,10 @@ import { lookupGitHubWorkItemByOwnerRepoForSource, lookupGitHubWorkItemForSource } from '@/lib/github-work-item-source-lookup' +import { + resolveGitHubWorkItemIdentity, + type GitHubWorkItemIdentity +} from '@/lib/github-work-item-identity' import { resolveGitHubPrStartPointForRepo } from '@/lib/github-pr-start-point' import { isWorkItemLookupText } from '@/lib/work-item-lookup-text' import { @@ -141,6 +144,7 @@ import { queueNewWorkspaceTerminalFocus } from '@/lib/new-workspace-terminal-foc import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' import { getSuggestedCreatureName } from '@/components/sidebar/worktree-name-suggestions' import type { SmartWorkspaceNameSelection } from '@/components/new-workspace/SmartWorkspaceNameField' +import type { SmartNameMode } from '@/components/new-workspace/smart-workspace-source-results' import { getForkPushWarning } from './fork-push-warning' import { CONTEXTUAL_TOUR_ENABLE_AUTO_WORKSPACE_NAME_EVENT } from '@/components/contextual-tours/contextual-tour-composer-events' import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' @@ -163,6 +167,7 @@ import { resolveComposerBranchNameOverrideForCreate, resolveComposerBranchReuse, resolveComposerBranchSelection, + resolveComposerManualBranchNameChange, resolveComposerReuseOverride } from './composer-branch-selection' import { isCurrentComposerDropOwner } from './composer-drop-owner' @@ -255,9 +260,12 @@ export type ComposerCardProps = { smartNameRepoSwitchTarget?: 'project' | 'task-source' name: string onNameValueChange: (value: string) => void + branchNameOverride: string | undefined + onBranchNameOverrideChange: (value: string | undefined) => void onSmartGitHubItemSelect: (item: GitHubWorkItem) => void onSmartGitLabItemSelect: (item: GitLabWorkItem) => void onSmartBranchSelect: (refName: string, localBranchName: string) => void + onSmartNameModeChange?: (mode: SmartNameMode) => void onSmartLinearIssueSelect: (issue: LinearIssue) => void smartNameGitHubSourceContext?: TaskSourceContext | null /** GitLab parallel of onBaseBranchPrSelect. */ @@ -470,6 +478,38 @@ function getLinkedWorkItemSeedName(item: LinkedWorkItemSummary | null | undefine return getLinkedWorkItemWorkspaceName(item)?.seedName ?? getLinkedWorkItemSuggestedName(item) } +function getGitHubLinkedWorkItemIdentity( + item: LinkedWorkItemSummary | null | undefined +): GitHubWorkItemIdentity | null { + if ( + !item || + getLinkedWorkItemProvider(item) !== 'github' || + (item.type !== 'issue' && item.type !== 'pr') + ) { + return null + } + + return resolveGitHubWorkItemIdentity({ + type: item.type, + number: item.number, + url: item.url + }) +} + +function normalizeGitHubLinkedWorkItem( + item: LinkedWorkItemSummary | null | undefined +): LinkedWorkItemSummary | null { + if (!item) { + return null + } + const identity = getGitHubLinkedWorkItemIdentity(item) + if (!identity || (identity.type === item.type && identity.number === item.number)) { + return item + } + + return { ...item, type: identity.type, number: identity.number } +} + export function getInitialAutoManagedWorkspaceName({ draftName, draftLinkedWorkItem, @@ -785,9 +825,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS projects, projectHostSetups, eligibleRepos, - projectGroups + projectGroups, + hosts: hostOptions }), - [eligibleRepos, projectGroups, projectHostSetups, projects] + [eligibleRepos, hostOptions, projectGroups, projectHostSetups, projects] ) const selectedRepoSettings = useMemo(() => { if (!settings) { @@ -900,10 +941,16 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const [attachmentPaths, setAttachmentPaths] = useState( persistDraft ? (newWorkspaceDraft?.attachments ?? []) : [] ) + const initialLinkedWorkItemSeed = normalizeGitHubLinkedWorkItem(initialLinkedWorkItem) + const draftLinkedWorkItemSeed = persistDraft + ? normalizeGitHubLinkedWorkItem(newWorkspaceDraft?.linkedWorkItem) + : null + const linkedWorkItemSeed = persistDraft + ? (draftLinkedWorkItemSeed ?? initialLinkedWorkItemSeed) + : initialLinkedWorkItemSeed + const linkedWorkItemSeedIdentity = getGitHubLinkedWorkItemIdentity(linkedWorkItemSeed) const [linkedWorkItem, setLinkedWorkItem] = useState( - persistDraft - ? (newWorkspaceDraft?.linkedWorkItem ?? initialLinkedWorkItem) - : initialLinkedWorkItem + () => linkedWorkItemSeed ) const taskSourceContext = useMemo(() => { if ( @@ -977,6 +1024,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS }) }, [projects, selectedRepo, selectedRepoIsGit, selectedWorkspaceTarget, taskSourceContext]) const [linkedIssue, setLinkedIssue] = useState(() => { + if (linkedWorkItemSeedIdentity?.type === 'issue') { + return String(linkedWorkItemSeedIdentity.number) + } if (persistDraft && newWorkspaceDraft?.linkedIssue) { return newWorkspaceDraft.linkedIssue } @@ -989,6 +1039,12 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS return '' }) const [linkedPR, setLinkedPR] = useState(() => { + if (linkedWorkItemSeedIdentity?.type === 'pr') { + return linkedWorkItemSeedIdentity.number + } + if (linkedWorkItemSeedIdentity?.type === 'issue') { + return null + } if (persistDraft && newWorkspaceDraft?.linkedPR !== undefined) { return newWorkspaceDraft.linkedPR } @@ -1021,6 +1077,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const [branchNameOverride, setBranchNameOverride] = useState(undefined) const [branchNameOverridePreservesNameEdits, setBranchNameOverridePreservesNameEdits] = useState(false) + 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` // is the local branch name eligible for reuse (null = not a reusable local @@ -1128,9 +1185,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const lastAutoNameRef = useRef( getInitialAutoManagedWorkspaceName({ draftName: persistDraft ? newWorkspaceDraft?.name : null, - draftLinkedWorkItem: persistDraft ? newWorkspaceDraft?.linkedWorkItem : null, + draftLinkedWorkItem: persistDraft ? draftLinkedWorkItemSeed : null, initialName, - initialLinkedWorkItem + initialLinkedWorkItem: initialLinkedWorkItemSeed }) ) const nameRef = useRef(name) @@ -1916,16 +1973,28 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS let cancelled = false setLinkDirectLoading(true) // Why: Superset lets users paste a full GitHub URL or type a raw issue/PR - // number and still get a concrete selectable result. Orca mirrors that by - // resolving direct lookups against the selected repo instead of requiring a - // text match in the recent-items list. + // number and still get a concrete selectable result. Full URLs carry + // issue-vs-PR intent, so preserve the URL route instead of probing by + // number only. const lookupRepoId = selectedRepo.id - void lookupGitHubWorkItemForSource({ - repoPath: selectedRepo.path, - repoId: selectedRepo.id, - sourceContext: selectedRepoGitHubSourceContext, - number: normalizedLinkQuery.directNumber - }) + const lookup = + normalizedLinkQuery.directLink !== undefined + ? lookupGitHubWorkItemByOwnerRepoForSource({ + repoPath: selectedRepo.path, + repoId: selectedRepo.id, + sourceContext: selectedRepoGitHubSourceContext, + owner: normalizedLinkQuery.directLink.slug.owner, + repo: normalizedLinkQuery.directLink.slug.repo, + number: normalizedLinkQuery.directLink.number, + type: normalizedLinkQuery.directLink.type + }) + : lookupGitHubWorkItemForSource({ + repoPath: selectedRepo.path, + repoId: selectedRepo.id, + sourceContext: selectedRepoGitHubSourceContext, + number: normalizedLinkQuery.directNumber + }) + void lookup .then((item) => { if (!cancelled) { setLinkDirectItem( @@ -1948,6 +2017,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS cancelled = true } }, [ + normalizedLinkQuery.directLink, linkPopoverOpen, normalizedLinkQuery.directNumber, selectedRepo, @@ -1957,24 +2027,31 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const applyLinkedWorkItem = useCallback( (item: GitHubWorkItem, options: { preserveBranchNameOverride?: boolean } = {}): void => { - if (item.type === 'issue') { - setLinkedIssue(String(item.number)) + const identity = resolveGitHubWorkItemIdentity(item) + const normalizedItem: GitHubWorkItem = { + ...item, + type: identity.type, + number: identity.number + } + if (identity.type === 'issue') { + setLinkedIssue(String(identity.number)) setLinkedPR(null) } else { setLinkedIssue('') - setLinkedPR(item.number) + setLinkedPR(identity.number) } setLinkedGitLabIssue(null) setLinkedGitLabMR(null) setLinkedWorkItem({ - type: item.type, + type: identity.type, provider: 'github', - number: item.number, + number: identity.number, title: item.title, url: item.url }) const suggestedName = - getLinkedWorkItemWorkspaceName(item)?.seedName ?? getLinkedWorkItemSuggestedName(item) + getLinkedWorkItemWorkspaceName(normalizedItem)?.seedName ?? + getLinkedWorkItemSuggestedName(normalizedItem) // Why: a pasted URL/#123 in the field is the lookup query that found // this item, not a deliberate name — replace it with the title-derived // name or it silently becomes a slugified-URL workspace name. @@ -1996,20 +2073,25 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS useCallback(async (): Promise => { if (linkedWorkItem) { const startPointSelection = smartGitHubPrStartPointSelectionRef.current + const linkedWorkItemIdentity = getGitHubLinkedWorkItemIdentity(linkedWorkItem) + const startPointIdentity = startPointSelection + ? resolveGitHubWorkItemIdentity(startPointSelection.item) + : null if ( !isProjectGroupTarget && - linkedWorkItem.type === 'pr' && + linkedWorkItemIdentity?.type === 'pr' && + startPointIdentity?.type === 'pr' && getLinkedWorkItemProvider(linkedWorkItem) === 'github' && selectedRepo && selectedRepoIsGit && startPointSelection?.repoId === selectedRepo.id && - startPointSelection.item.number === linkedWorkItem.number + startPointIdentity.number === linkedWorkItemIdentity.number ) { const selectedPrStartPoint = startPointSelection.resolved ?? (await resolveGitHubPrStartPointForRepo({ repoId: selectedRepo.id, - prNumber: startPointSelection.item.number, + prNumber: startPointIdentity.number, settings: getSettingsForRepoRuntimeOwner( { repos: [selectedRepo], settings }, selectedRepo.id @@ -2096,11 +2178,12 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS throw new Error('Could not resolve the GitHub item before creating the workspace.') } + const itemIdentity = resolveGitHubWorkItemIdentity(item) const prStartPoint = - !isProjectGroupTarget && item.type === 'pr' && selectedRepo && selectedRepoIsGit + !isProjectGroupTarget && itemIdentity.type === 'pr' && selectedRepo && selectedRepoIsGit ? await resolveGitHubPrStartPointForRepo({ repoId: selectedRepo.id, - prNumber: item.number, + prNumber: itemIdentity.number, settings: getSettingsForRepoRuntimeOwner( { repos: [selectedRepo], settings }, selectedRepo.id @@ -2275,6 +2358,23 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS }, [branchNameOverride, branchNameOverridePreservesNameEdits, name] ) + const handleBranchNameOverrideChange = useCallback( + (value: string | undefined): void => { + const next = resolveComposerManualBranchNameChange({ + value, + pushTarget, + forkPushWarning + }) + setBranchNameOverride(next.branchNameOverride) + setBranchNameOverridePreservesNameEdits(Boolean(next.branchNameOverride)) + setPushTarget(next.pushTarget) + setForkPushWarning(next.forkPushWarning) + setReuseEligibleBranch(null) + setReuseSelectedBranch(false) + branchAutoNameRef.current = '' + }, + [forkPushWarning, pushTarget] + ) const addComposerAttachments = useCallback((paths: string[]): void => { if (paths.length === 0) { @@ -2734,8 +2834,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS // worktree's comment should surface (`orca worktree current`, sidebar). // Prefill the note if it's empty or still equal to a prior auto-fill, so // we don't overwrite anything the user has typed. - if (item.type === 'pr') { - const suggestedNote = `PR #${item.number} — ${item.title}` + const identity = resolveGitHubWorkItemIdentity(item) + if (identity.type === 'pr') { + const suggestedNote = `PR #${identity.number} — ${item.title}` const currentNote = noteRef.current if (!currentNote.trim() || currentNote === lastAutoNoteRef.current) { setNote(suggestedNote) @@ -2777,10 +2878,16 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const handleSmartGitHubItemSelect = useCallback( (item: GitHubWorkItem): void => { + const identity = resolveGitHubWorkItemIdentity(item) + const normalizedItem: GitHubWorkItem = { + ...item, + type: identity.type, + number: identity.number + } if (isProjectGroupTarget) { - const linkedItem = toGitHubLinkedWorkItem(item) - setLinkedIssue(String(item.number)) - setLinkedPR(item.type === 'pr' ? item.number : null) + const linkedItem = toGitHubLinkedWorkItem(normalizedItem) + setLinkedIssue(identity.type === 'issue' ? String(identity.number) : '') + setLinkedPR(identity.type === 'pr' ? identity.number : null) setLinkedGitLabIssue(null) setLinkedGitLabMR(null) setLinkedWorkItem(linkedItem) @@ -2803,8 +2910,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS // selected run host. Resolve git refs against the run repo; keep item // metadata/source context separate for provider identity. const runRepo = selectedRepo ?? eligibleRepos.find((repo) => repo.id === item.repoId) - applyLinkedWorkItem(item) - if (item.type !== 'pr' || !runRepo) { + applyLinkedWorkItem(normalizedItem) + if (identity.type !== 'pr' || !runRepo) { setBaseBranch(undefined) setCompareBaseRef(undefined) setPushTarget(undefined) @@ -2815,7 +2922,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS setPushTarget(undefined) const startPointSelection: SmartGitHubPrStartPointSelection = { repoId: runRepo.id, - item + item: normalizedItem } smartGitHubPrStartPointSelectionRef.current = startPointSelection const itemRepoSettings = getSettingsForRepoRuntimeOwner( @@ -2824,12 +2931,12 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS ) const resolvePrBase = resolveGitHubPrStartPointForRepo({ repoId: runRepo.id, - prNumber: item.number, + prNumber: identity.number, settings: itemRepoSettings, - ...(item.branchName ? { headRefName: item.branchName } : {}), - ...(item.baseRefName ? { baseRefName: item.baseRefName } : {}), - ...(item.isCrossRepository !== undefined - ? { isCrossRepository: item.isCrossRepository } + ...(normalizedItem.branchName ? { headRefName: normalizedItem.branchName } : {}), + ...(normalizedItem.baseRefName ? { baseRefName: normalizedItem.baseRefName } : {}), + ...(normalizedItem.isCrossRepository !== undefined + ? { isCrossRepository: normalizedItem.isCrossRepository } : {}) }) void resolvePrBase @@ -2840,7 +2947,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS startPointSelection.resolved = result handleBaseBranchPrSelect( result.baseBranch, - item, + normalizedItem, result.pushTarget, result.branchNameOverride, result.compareBaseRef @@ -3456,7 +3563,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS branchAutoName: branchAutoNameRef.current, workspaceName, preserveWorkspaceNameEdits: - smartGitHubResolution.kind === 'pr-start-point' || branchNameOverridePreservesNameEdits + smartGitHubResolution.kind === 'pr-start-point' || branchNameOverridePreservesNameEdits, + createBranchFromWorkspaceName: + smartGitHubResolution.kind === 'none' && smartNameMode === 'branches' }) const createDisplayName = smartGitHubResolution.kind === 'none' @@ -3658,6 +3767,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS settings?.agentDefaultArgs, settings?.agentDefaultEnv, settings?.autoRenameBranchFromWork, + smartNameMode, setSidebarOpen, setupDecision, sparseEnabled, @@ -3855,15 +3965,13 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS branchAutoName: branchAutoNameRef.current, workspaceName, preserveWorkspaceNameEdits: - smartGitHubResolution.kind === 'pr-start-point' || branchNameOverridePreservesNameEdits + smartGitHubResolution.kind === 'pr-start-point' || branchNameOverridePreservesNameEdits, + createBranchFromWorkspaceName: + smartGitHubResolution.kind === 'none' && smartNameMode === 'branches' }) const submitBaseBranch = selectedRepoIsGit ? await resolveWorktreeCreateBaseBranch({ - explicitBaseBranch: smartSubmitBaseBranch, - repoWorktreeBaseRef: selectedRepo.worktreeBaseRef, - loadDefaultBaseRef: async () => - (await getRuntimeRepoBaseRefDefault(selectedRepoSettings, repoId).catch(() => null)) - ?.defaultBaseRef + explicitBaseBranch: smartSubmitBaseBranch }) : undefined const createDisplayName = @@ -4108,6 +4216,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS settings?.agentDefaultArgs, settings?.agentDefaultEnv, settings?.autoRenameBranchFromWork, + smartNameMode, disabledTuiAgents, setupDecision, sparseEnabled, @@ -4166,9 +4275,12 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS smartNameRepoSwitchTarget: isProjectGroupTarget ? 'task-source' : 'project', name, onNameValueChange: handleNameValueChange, + branchNameOverride: isProjectGroupTarget ? undefined : branchNameOverride, + onBranchNameOverrideChange: isProjectGroupTarget ? () => {} : handleBranchNameOverrideChange, onSmartGitHubItemSelect: handleSmartGitHubItemSelect, onSmartGitLabItemSelect: handleSmartGitLabItemSelect, onSmartBranchSelect: isProjectGroupTarget ? () => {} : handleSmartBranchSelect, + onSmartNameModeChange: setSmartNameMode, onSmartLinearIssueSelect: handleSmartLinearIssueSelect, smartNameGitHubSourceContext: selectedRepoGitHubSourceContext, smartNameSelection, diff --git a/src/renderer/src/hooks/useEditorExternalWatch.ts b/src/renderer/src/hooks/useEditorExternalWatch.ts index de0f681998c..157b91df034 100644 --- a/src/renderer/src/hooks/useEditorExternalWatch.ts +++ b/src/renderer/src/hooks/useEditorExternalWatch.ts @@ -33,7 +33,7 @@ import { isGitRepoKind } from '../../../shared/repo-kind' // VSCode) land as a short burst of `update` events — or `delete + create` on // renamers — within a few milliseconds for the same path. Dispatching an // `ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT` per raw event fan-outs into N full -// `setContent` + `normalizeSoftBreaks` doc rebuilds per mounted EditorPanel, +// `setContent` + document-repair rebuilds per mounted EditorPanel, // which under split-pane + large markdown is enough to wedge the renderer // and black out the window (issue #826). Coalescing per (worktreeId + path) // on a short debounce collapses that burst into one reload notification. diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index d21df967484..b86a258bdc6 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -1738,6 +1738,7 @@ describe('useIpcEvents updater integration', () => { afterTabId?: string targetGroupId?: string command?: string + cwd?: string launchConfig?: SleepingAgentLaunchConfig launchAgent?: TuiAgent title?: string @@ -1883,6 +1884,7 @@ describe('useIpcEvents updater integration', () => { afterTabId?: string targetGroupId?: string command?: string + cwd?: string launchConfig?: SleepingAgentLaunchConfig launchAgent?: TuiAgent title?: string @@ -2144,6 +2146,7 @@ describe('useIpcEvents updater integration', () => { targetGroupId: 'group-left', title: 'Codex', command: 'codex', + cwd: '/repo/packages/app', launchConfig: { agentArgs: '--model gpt-5', agentEnv: { CODEX_PROFILE: 'request' } @@ -2156,7 +2159,8 @@ describe('useIpcEvents updater integration', () => { activate: false, recordInteraction: false, launchAgent: 'codex', - viewMode: 'chat' + viewMode: 'chat', + startupCwd: '/repo/packages/app' }) expect(setActiveView).not.toHaveBeenCalled() expect(setActiveWorktree).not.toHaveBeenCalled() diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 21742cf0e3c..8ae66a5fcda 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -1351,6 +1351,7 @@ export function useIpcEvents(): void { requestId, worktreeId, command, + cwd, env, launchConfig, launchToken, @@ -1428,6 +1429,7 @@ export function useIpcEvents(): void { }) } : {}), + ...(cwd ? { startupCwd: cwd } : {}), // Why: tabId hint comes from CLI-spawned PTYs whose env // already has the pane key baked in. Adopting the tab under // the same id keeps hook-event attribution working. @@ -1437,7 +1439,15 @@ export function useIpcEvents(): void { worktreeId, undefined, undefined, - shouldActivate ? undefined : { activate: false, recordInteraction: false } + shouldActivate + ? cwd + ? { startupCwd: cwd } + : undefined + : { + activate: false, + recordInteraction: false, + ...(cwd ? { startupCwd: cwd } : {}) + } )) // Why: when an existing tab already owns this ptyId, we reuse it instead of // minting a new one — but the PTY env already carries a paneKey from main. @@ -1606,11 +1616,18 @@ export function useIpcEvents(): void { launchAgent: data.launchAgent, ...initialAgentTabViewModeProps(store.settings, { agent: data.launchAgent - }) + }), + ...(data.cwd ? { startupCwd: data.cwd } : {}) } : shouldActivate - ? undefined - : { activate: false, recordInteraction: false } + ? data.cwd + ? { startupCwd: data.cwd } + : undefined + : { + activate: false, + recordInteraction: false, + ...(data.cwd ? { startupCwd: data.cwd } : {}) + } const tab = store.createTab(worktreeId, data.targetGroupId, undefined, tabOptions) if (data.afterTabId) { const createdUnifiedTab = useAppStore @@ -2148,6 +2165,7 @@ export function useIpcEvents(): void { title: data.url, targetGroupId: data.activate ? undefined : activeBrowserUnifiedTab?.groupId, sessionProfileId: data.sessionProfileId, + sessionPartition: data.sessionPartition, activate: data.activate === true }) // Why: registerGuest fires with the page ID (not workspace ID) as @@ -2210,7 +2228,7 @@ export function useIpcEvents(): void { } else { destroyPersistentWebview(data.browserPageId) } - store.switchBrowserTabProfile(owningWorkspace.id, data.profileId) + store.switchBrowserTabProfile(owningWorkspace.id, data.profileId, data.sessionPartition) window.api.ui.replyTabSetProfile({ requestId: data.requestId }) } catch (err) { window.api.ui.replyTabSetProfile({ diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.language-switch.test.tsx b/src/renderer/src/hooks/useSettingsNavigationMetadata.language-switch.test.tsx new file mode 100644 index 00000000000..65b024407c3 --- /dev/null +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.language-switch.test.tsx @@ -0,0 +1,66 @@ +// @vitest-environment happy-dom + +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, expect, it, vi } from 'vitest' + +import { i18n } from '../i18n/i18n' +import { useSettingsNavigationMetadata } from './useSettingsNavigationMetadata' +import type { SettingsNavSection } from '@/lib/settings-navigation-types' + +// Why: the Settings sidebar / Cmd+J nav labels are produced by translate() at +// build time and memoized. Before the fix the memo deps excluded the active +// locale, so a live language switch left the nav stuck in the old language +// until Settings was remounted. This pins that the labels retranslate live. + +const roots: Root[] = [] +let latest: SettingsNavSection[] | null = null + +function Probe(): null { + latest = useSettingsNavigationMetadata() + return null +} + +async function renderProbe(): Promise { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + roots.push(root) + await act(async () => { + root.render(createElement(Probe)) + }) +} + +function agentsTitle(): string | undefined { + return latest?.find((section) => section.id === 'agents')?.title +} + +afterEach(async () => { + for (const root of roots) { + await act(async () => { + root.unmount() + }) + } + roots.length = 0 + latest = null + await act(async () => { + await i18n.changeLanguage('en') + }) + vi.restoreAllMocks() +}) + +it('retranslates the settings nav labels when the UI language changes live', async () => { + await act(async () => { + await i18n.changeLanguage('en') + }) + await renderProbe() + expect(agentsTitle()).toBe('Agents') + + // Same path as the Settings → Appearance language switch. + await act(async () => { + await i18n.changeLanguage('es') + }) + + // Without the active-locale memo dep this stays 'Agents' (stale cache). + expect(agentsTitle()).toBe('Agentes') +}) diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts index dff0ea1824d..531933be42b 100644 --- a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts @@ -551,9 +551,12 @@ export function buildSettingsNavigationMetadata({ } export function useSettingsNavigationMetadata(): SettingsNavSection[] { - // Why: subscribe metadata consumers to language changes; translated memo - // contents refresh on rerender without depending on i18n.language directly. - useTranslation() + // Why: useTranslation subscribes to language changes, but the active locale + // must also be a memo dependency below — a rerender alone returns the cached + // previous-language sections, leaving the Settings sidebar and Cmd+J palette + // stuck in the old language until Settings is remounted. + const { i18n } = useTranslation() + const activeLocale = i18n.language const repos = useAppStore((state) => state.repos) const settings = useAppStore((state) => state.settings) const isMac = isMacUserAgent() @@ -584,6 +587,7 @@ export function useSettingsNavigationMetadata(): SettingsNavSection[] { isDev: import.meta.env.DEV, repos }), - [isMac, isWindows, isWindowsTerminalHost, isWebClient, repos] + // oxlint-disable-next-line react-hooks/exhaustive-deps -- activeLocale is read implicitly by the translate() calls inside buildSettingsNavigationMetadata; without it the memo keeps the previous language's sections. + [isMac, isWindows, isWindowsTerminalHost, isWebClient, repos, activeLocale] ) } diff --git a/src/renderer/src/i18n/I18nProvider.test.tsx b/src/renderer/src/i18n/I18nProvider.test.tsx new file mode 100644 index 00000000000..13ec0d144a5 --- /dev/null +++ b/src/renderer/src/i18n/I18nProvider.test.tsx @@ -0,0 +1,132 @@ +// @vitest-environment happy-dom + +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { getDefaultSettings } from '../../../shared/constants' +import { UI_LANGUAGE_ENGLISH, UI_LANGUAGE_SPANISH } from '../../../shared/ui-language' +import { useAppStore } from '@/store' +import { i18n } from './i18n' +import { I18nProvider } from './I18nProvider' + +// Why: settings arrive async over IPC after first render. The provider used to +// fall back to the 'system' language while settings were null, kicking off an +// OS-locale changeLanguage that raced with — and could permanently override — +// the persisted preference. These tests pin the fixed ordering. + +const initialAppState = useAppStore.getInitialState() +const roots: Root[] = [] + +// Why: the 'system' UI language resolves through navigator.language; stubbing it +// lets these tests simulate a non-English OS locale (the #7188 repro) without +// depending on the host machine's locale. +const ORIGINAL_SYSTEM_LOCALE = navigator.language +function stubSystemLocale(tag: string): void { + Object.defineProperty(navigator, 'language', { value: tag, configurable: true }) +} + +async function renderProvider(): Promise { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + roots.push(root) + await act(async () => { + root.render(createElement(I18nProvider, null, null)) + }) +} + +beforeEach(() => { + useAppStore.setState(initialAppState, true) +}) + +afterEach(async () => { + for (const root of roots) { + await act(async () => { + root.unmount() + }) + } + roots.length = 0 + useAppStore.setState(initialAppState, true) + stubSystemLocale(ORIGINAL_SYSTEM_LOCALE) + vi.restoreAllMocks() +}) + +describe('I18nProvider startup language', () => { + it('does not apply any language while settings are still loading', async () => { + const changeLanguage = vi.spyOn(i18n, 'changeLanguage') + + await renderProvider() + + expect(changeLanguage).not.toHaveBeenCalled() + }) + + it('applies the persisted language once settings load', async () => { + const changeLanguage = vi.spyOn(i18n, 'changeLanguage') + + await renderProvider() + await act(async () => { + useAppStore.setState({ + settings: { ...getDefaultSettings('/tmp'), uiLanguage: UI_LANGUAGE_SPANISH } + }) + }) + + expect(changeLanguage).toHaveBeenCalledWith('es') + }) + + it('applies persisted English even if i18n reports it as already active', async () => { + // Why: i18n.language stays stale while a lazy catalog load is in flight; a + // guard comparing against it skipped the correction back to the persisted + // language and let the in-flight OS locale win. + const changeLanguage = vi.spyOn(i18n, 'changeLanguage') + + await renderProvider() + await act(async () => { + useAppStore.setState({ + settings: { ...getDefaultSettings('/tmp'), uiLanguage: UI_LANGUAGE_ENGLISH } + }) + }) + + expect(changeLanguage).toHaveBeenCalledWith('en') + }) + + it('never applies the OS locale on a non-English system when English is persisted (#7188)', async () => { + // Why: on a Spanish OS the pre-fix provider kicked off changeLanguage('es') + // while settings were still null, and the in-flight OS switch then beat the + // persisted English preference. The fix applies nothing until settings load. + stubSystemLocale('es-ES') + const changeLanguage = vi.spyOn(i18n, 'changeLanguage') + + await renderProvider() + // No OS-locale switch is kicked off while settings are still loading. + expect(changeLanguage).not.toHaveBeenCalled() + + await act(async () => { + useAppStore.setState({ + settings: { ...getDefaultSettings('/tmp'), uiLanguage: UI_LANGUAGE_ENGLISH } + }) + }) + + // English wins and the Spanish OS locale is never requested. + expect(changeLanguage).toHaveBeenCalledWith('en') + expect(changeLanguage).not.toHaveBeenCalledWith('es') + }) + + it('switches language when the setting changes after startup', async () => { + const changeLanguage = vi.spyOn(i18n, 'changeLanguage') + + await renderProvider() + await act(async () => { + useAppStore.setState({ + settings: { ...getDefaultSettings('/tmp'), uiLanguage: UI_LANGUAGE_ENGLISH } + }) + }) + await act(async () => { + useAppStore.setState({ + settings: { ...getDefaultSettings('/tmp'), uiLanguage: UI_LANGUAGE_SPANISH } + }) + }) + + expect(changeLanguage).toHaveBeenLastCalledWith('es') + }) +}) diff --git a/src/renderer/src/i18n/I18nProvider.tsx b/src/renderer/src/i18n/I18nProvider.tsx index 89b83d02608..5cfd2c04206 100644 --- a/src/renderer/src/i18n/I18nProvider.tsx +++ b/src/renderer/src/i18n/I18nProvider.tsx @@ -1,22 +1,28 @@ -import { useEffect, type ReactNode } from 'react' +import { useEffect, useRef, type ReactNode } from 'react' import { I18nextProvider } from 'react-i18next' -import { UI_LANGUAGE_SYSTEM } from '../../../shared/ui-language' import { useAppStore } from '../store' import { i18n } from './i18n' import { resolveUiLocale } from './supported-languages' export function I18nProvider({ children }: { children: ReactNode }): React.JSX.Element { - const uiLanguage = useAppStore((state) => state.settings?.uiLanguage ?? UI_LANGUAGE_SYSTEM) - const locale = resolveUiLocale(uiLanguage) + // Why: settings arrive async over IPC; until they load we must not apply any + // language. Falling back to 'system' here used to kick off an OS-locale + // changeLanguage that raced with (and could permanently override) the + // persisted preference applied moments later. + const uiLanguage = useAppStore((state) => state.settings?.uiLanguage ?? null) + const locale = uiLanguage === null ? null : resolveUiLocale(uiLanguage) + const requestedLocale = useRef(null) useEffect(() => { - if (i18n.language !== locale) { - // Why: changeLanguage triggers the lazy locale backend, which fetches the - // non-English catalog before activating it — so the switch resolves real - // translations without bundling every locale at startup. - void i18n.changeLanguage(locale) + // Why: track the last *requested* locale instead of checking i18n.language — + // an in-flight lazy catalog load leaves i18n.language stale, which made the + // guard skip corrections back to the persisted language. + if (locale === null || requestedLocale.current === locale) { + return } + requestedLocale.current = locale + void i18n.changeLanguage(locale) }, [locale]) return {children} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 511c344f97d..927d992cfc7 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -25,6 +25,7 @@ "geminiToggleDescription": "Show Gemini token and cost usage for the active workspace.", "opencodeGoToggleDescription": "Show OpenCode Go token and cost usage for the active workspace.", "kimiToggleDescription": "Show Kimi subscription usage for the active workspace.", + "minimaxToggleDescription": "Show MiniMax subscription usage for the active workspace.", "sshToggleDescription": "Show configured SSH and remote Orca hosts when any are available.", "resourceUsageToggleDescription": "Show the Resource Manager. Click it for CPU, memory, sessions, daemon controls, and workspace disk scans.", "portsToggleDescription": "Show live workspace ports. Click it for workspace-scoped ports and external listeners." @@ -1170,7 +1171,9 @@ "ephemeralVm": "Per-Workspace Environment", "chooseRunTarget": "Choose target", "noRunTargets": "No run targets are ready for this project.", - "perWorkspaceEnvHint": "Provision an on-demand environment from a recipe" + "perWorkspaceEnvHint": "Provision an on-demand environment from a recipe", + "branchName": "Branch name", + "branchNamePlaceholder": "feature/my-branch" }, "NewWorkspaceComposerModal": { "createWorktree": "Create worktree", @@ -2586,7 +2589,9 @@ "openLocalWorkspace": "Open a local workspace before resuming a session.", "sessionQueued": "Session queued", "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", - "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session." + "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.", + "sessionHostMismatchUnsupported": "This session belongs to a different host. Drop it onto a workspace on the same host.", + "localSessionSshWorkspaceUnsupported": "This session's history is stored on this machine, so it can't resume in an SSH workspace. Drop it onto a local workspace instead." }, "TabGroupDropOverlay": { "paneColumnLabel": "New split" @@ -2691,7 +2696,7 @@ "39676a184c": "Open any file, URL, agent, ..." }, "TabBarQuickCommandsButton": { - "a2c7a33831": "Add Quick Command…", + "a2c7a33831": "Command", "20bbd75896": "No commands", "b82e237a4b": "More quick commands", "85482c57bc": "Run quick command", @@ -2841,10 +2846,7 @@ "41ae4fa725": "Killing…", "138b99bd80": "this session", "888dad8c55": "Loading…", - "56b6888304": "Local resource usage hidden for runtime servers.", - "14ff448686": "Unavailable for runtime servers", "6d9793d4bc": "Resource Manager - Terminals", - "6a822b06a7": "Resource Manager", "ca95d077db": "Daemon unreachable", "a82253b458": "Delete workspace.", "946724a70a": "The main workspace cannot be deleted.", @@ -2903,7 +2905,9 @@ "c35af53b73": "Sign in", "f19a63e7cd": "Sign in to see usage", "5c938d39ac": "% wk", + "54e8d6bb2d": "% Fable", "d79c3362c4": "% 5h", + "a79c64f87e": "Fable", "8295903d17": "Restart live Claude terminals before continuing old conversations after switching.", "c98ea88392": "No other accounts", "9332ba8684": "Switch to", @@ -2932,7 +2936,9 @@ "972a1ff497": "Reset Codex limits?", "6d1042aa6f": "This uses one Codex rate-limit reset credit for the active account and resets any eligible usage windows immediately.", "f077f586db": "Don't ask again", - "c0e972d726": "Cancel" + "c0e972d726": "Cancel", + "06741a2f3d": "Open MiniMax usage details", + "3bbf140864": "MiniMax Usage" }, "StatusBarUsageEmptyCta": { "828c764a79": "Connect an account", @@ -3067,6 +3073,7 @@ "6d6df77f41": "No data available", "7f7f208060": "Monthly", "252c096536": "Weekly", + "a79c64f87e": "Fable", "94038ad2fa": "Session", "2c35eca8d4": "Unable to fetch usage", "1292d4f2ee": "Unavailable", @@ -3519,7 +3526,9 @@ "8401a7a0d0": "1 repository", "d4f1df62ef": "{{value0}} repositories", "b4263a2ac4": "Found {{value0}} in {{value1}}.", - "24eda6c8b2": "Scanning... {{value0}}" + "24eda6c8b2": "Scanning... {{value0}}", + "6149d5203f": "No repositories are selected. Open the parent folder instead to use editor, terminal, and search without Git features.", + "e52454b7f6": "Open as Folder" }, "AddRepoRemoteStep": { "5b205b5281": "Stop scan", @@ -4115,7 +4124,8 @@ "groupDeleteFailed": "Failed to delete group", "groupDeleteFailedDesc": "Something went wrong while deleting the group. No projects were removed.", "failedNestWorkspace": "Failed to nest workspace", - "sidebarRowMissing": "Target no longer exists" + "sidebarRowMissing": "Target no longer exists", + "failedUnnestWorkspace": "Failed to unnest workspace" }, "WorktreeMetaDialog": { "3db0a2a593": "Cancel", @@ -4562,6 +4572,7 @@ "ae3b21eb6c": "opencode.ai/workspace/wrk_…/go", "51c9104e13": "Find this in the URL after logging into opencode.ai (e.g.", "b398b834c9": "Clear", + "316ca4e610": "Forget cookie", "a122332371": "wrk_… (leave blank for automatic lookup)", "dbdb0b0bd8": "Workspace ID override", "d70a5287a4": "Optional workspace ID override if the automatic lookup fails.", @@ -4627,7 +4638,47 @@ "75ca9b718e": "Codex reported that the active account needs a fresh sign-in. Re-authenticate it before starting new Codex sessions.", "b11078a9c2": "wsl", "350b2a1aa7": "Use your current", - "e05d0ff737": "Use your current {{value0}} Claude login." + "e05d0ff737": "Use your current {{value0}} Claude login.", + "2f24f244a4": "MiniMax cookie is required.", + "8e6f0cb1d8": "MiniMax cookie was not saved.", + "8d61637a77": "MiniMax cookie saved.", + "b43e761fe5": "MiniMax cookie update failed.", + "5d63bbfbec": "MiniMax", + "15e831350e": "Configure MiniMax usage tracking from platform.minimax.io.", + "21d6eb141e": "MiniMax Session Cookie", + "33bba5ad83": "Paste your MiniMax session cookie for local rate-limit fetching.", + "73ea15f24b": "Saved", + "23afe8f226": "Not saved", + "566d9a99ab": "_token=…; minimax_group_id_v2=…", + "f38b9cc4bd": "Replace", + "590a3130f9": "Save", + "79418c782a": "Open platform.minimax.io/console/usage in your browser, sign in, then copy the Cookie request header from DevTools (Network → any remains request → Cookie).", + "9dd50d3f75": "Advanced", + "174fb408f9": "Leave these defaults alone unless MiniMax usage refresh points at the wrong workspace or model.", + "bf160bb6c0": "Group ID override", + "b1e2743313": "Optional. Leave blank to use minimax_group_id_v2 from the cookie.", + "0747d6391a": "Use group ID from cookie", + "4ff2af7524": "Usage model names", + "5cf4b0f85f": "Optional comma-separated model names. Leave as general unless MiniMax returns a model-specific error.", + "3c92b0d31c": "general", + "0d8e77bc40": "Open console", + "0b8c1c7e02": "Stored locally", + "1fd1b1b6b4": "Cookie not set", + "5e08b0fe57": "Stored locally and sent only to platform.minimax.io for usage refreshes.", + "43d7a45b97": "How to copy", + "b8a4f21c3e": "Paste the Cookie header from DevTools", + "53f7b8c7a2": "Last refresh: {{value0}}", + "31d24a4e87": "Cookie expires when you sign out in the browser.", + "3a30aaf526": "just now", + "f5d8d2a6a1": "Open platform.minimax.io/console/usage in your browser and sign in.", + "24560fe830": "Open DevTools.", + "4cab0fa42d": "Go to the Network tab and enable Preserve log.", + "bee4e63e1c": "Reload the page.", + "87f814af6f": "Filter for remains and select the coding_plan/remains request.", + "435df0ee51": "Under Request Headers, copy the Cookie value.", + "7492fb3bba": "Paste it here and click Save.", + "9fec52de4b": "How to copy the cookie", + "4e32e030b2": "Stored locally. Orca sends it only to platform.minimax.io for usage refreshes." }, "AdvancedPane": { "40b29e0bf3": "Restart", @@ -4875,7 +4926,8 @@ "d420c43729": "Imported {{value0}} cookies from {{value1}}{{value2}} into {{value3}}.", "b4c167764d": "Imported {{value0}} cookies from file into {{value1}}.", "a3f8c2d1e0b4": "Imported {{value0}} cookies from {{value1}} ({{value2}}) into {{value3}}.", - "b4e9d3f2a1c5": "Imported {{value0}} cookies from {{value1}} into {{value2}}." + "b4e9d3f2a1c5": "Imported {{value0}} cookies from {{value1}} into {{value2}}.", + "c5a273a809": "From {{value0}}" }, "BrowserUseComputerUseNotice": { "15b5e680ba": "Open Computer Use", @@ -4899,7 +4951,7 @@ "be6df68384": "From File…", "e44c5d681e": "From", "67d9a53f47": "Manage profiles for separate logins", - "112f70adc4": "Last imported from", + "112f70adc4": "Last imported from {{value0}}", "72d4815523": "Bring your existing logins into Orca so agents can reach authenticated pages. Imports into the default profile.", "2eb906706c": "Import Browser Cookies", "af8c83ed61": "Import cookies from Chrome, Edge, or other browsers so agents can reuse your logins.", @@ -4921,7 +4973,8 @@ "ad8cb0ee22": "Fix PATH", "0289434ed6": "Enabled", "8b3054dac7": "Registering...", - "8f2675c2f3": "Imported {{value0}} cookies from file." + "8f2675c2f3": "Imported {{value0}} cookies from file.", + "5301857d88": "From {{value0}}" }, "BrowserUseSkillStep": { "0871b6998d": "Enables agents to navigate and verify pages in Orca's browser.", @@ -5159,7 +5212,9 @@ "8f1afdfbd8": "Diff Word Wrap", "4aa4d9fb73": "Wrap long lines in diff editors instead of requiring horizontal scrolling.", "bf16ef0af2": "Off", - "3f6892f307": "On" + "3f6892f307": "On", + "b82f86d7d2": "Rich Markdown Spellcheck", + "5195f0b9ef": "Show browser spelling underlines and suggestions while editing rich Markdown." }, "AdvancedNetworkSettingsSection": { "3e431564b5": "localhost, 127.0.0.1, *.internal", @@ -6400,7 +6455,11 @@ "9518545cb6": "Add Target", "a62b4cb39a": "Save Changes", "29af933cd5": "New SSH Target", - "f2331ce599": "Edit SSH Target" + "f2331ce599": "Edit SSH Target", + "4a342f44c1": "Advanced Connection", + "e9609ddca6": "Proxy, jump host, and connection reuse", + "8c922dffba": "Reuse SSH connection for faster setup", + "53e9aabfc0": "Uses OpenSSH multiplexing when available. Turn off for hosts with custom SSH restrictions." }, "TasksPane": { "3a72b9745e": "Choose which task providers appear in the Tasks page and sidebar shortcuts.", @@ -6787,7 +6846,9 @@ "bdbd1e668e": "windows", "593720c17f": "location", "b84a5b0c8a": "Choose whether provider accounts are inspected and added on this device or in WSL.", - "d09fb5ca92": "Account Location" + "d09fb5ca92": "Account Location", + "733f9e2a93": "MiniMax Usage", + "f8374c3151": "Paste your platform.minimax.io session cookie for local rate-limit fetching." } }, "advanced": { @@ -6959,6 +7020,11 @@ "40e5c3c285": "kimi", "c927a155d5": "Show Kimi subscription usage in the status bar.", "3a6c028ea8": "Kimi Usage", + "25e51b62ee": "rate limit", + "d9e7cef86f": "cookie", + "d16378a88f": "minimax", + "e46178eb1b": "Show MiniMax subscription usage in the status bar.", + "0f08f6b483": "MiniMax Usage", "edbf0f63a0": "cost", "afbb6a3767": "tokens", "d77537b580": "opencode-go", @@ -7445,7 +7511,9 @@ "161a86a9da": "Confirm before closing pinned tabs", "8e593f04fc": "Show a confirmation dialog before a pinned tab is closed.", "defaultProjectRuntime": "Default Project Runtime", - "defaultProjectRuntimeDescription": "Choose the runtime inherited by local Windows projects." + "defaultProjectRuntimeDescription": "Choose the runtime inherited by local Windows projects.", + "d2d2d929c0": "Rich Markdown Spellcheck", + "4497e2e2bb": "Show browser spelling underlines and suggestions while editing rich Markdown." } }, "git": { @@ -8694,6 +8762,18 @@ "ephemeralVmsExperimentalSetting": { "description": "Shows setup controls and workspace run targets for repo-owned, on-demand environments.", "toggleLabel": "Toggle per-workspace environments" + }, + "SourceControlActionRepoOverrideNote": { + "agent": "Agent", + "agentArgs": "CLI arguments", + "commandTemplate": "Command template", + "more": "+{{count}} more", + "plural": "Global saves won't change {{count}} repositories with their own recipes.", + "recipe": "Recipe", + "review": "Review", + "reviewFirst": "Review first", + "singular": "Global saves won't change 1 repository with its own recipe.", + "tooltipTitle": "Repository overrides" } }, "right": { @@ -8812,7 +8892,8 @@ "98a79948b3": "Copy", "b234ab25b4": "Could not copy the file to the clipboard", "f9d7ca753d": "Copy Paths", - "3161c4e425": "folder" + "3161c4e425": "folder", + "e887fa4b2e": "Open in Terminal" }, "FileExplorerToolbar": { "d238264654": "Show Git Ignored Files", @@ -9159,7 +9240,8 @@ "b715ef615b": "{{value0}} commits ahead of {{value1}}", "c1a8f3e204": "1 commit behind {{value0}}", "d2b9g4f315": "{{value0}} commits behind {{value1}}", - "e9c2a5d416": "Even with {{value0}}" + "e9c2a5d416": "Even with {{value0}}", + "createPrIntentCommitBlockedSummary": "Commit blocked: {{value0}} Fix the issue, then retry Create PR." }, "SourceControlAgentActionDialog": { "8e856842d1": "Could not start the selected agent.", @@ -9190,7 +9272,8 @@ "c7ff8cef11": "Detecting agents...", "b0da3a4d3e": "Launch recipe already saved", "bff4795a6d": "Change the agent, arguments, or prompt template to update the saved recipe.", - "5c75b24735": "Customize what the agent receives before Orca starts it." + "5c75b24735": "Customize what the agent receives before Orca starts it.", + "repoAgentOverrideNote": "This repository overrides your global default ({{global}}) and currently runs {{effective}}. Save to this repository to change what runs here." }, "SourceControlTextGenerationDialog": { "c5b7fa7cb6": "Save as global default", @@ -9559,7 +9642,9 @@ "worktreeUnavailable": "Worktree is no longer available.", "runtimeBrowseLocalHistory": "Runtime-hosted workspaces can browse local history. Resume actions are available in local and SSH workspaces.", "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", - "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session." + "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.", + "sessionHostMismatchUnsupported": "This session belongs to a different host. Open a workspace on the same host to resume it.", + "localSessionSshWorkspaceUnsupported": "This session's history is stored on this machine, so it can't resume in an SSH workspace. Open a local workspace instead." }, "AiVaultPanelControls": { "scanningSessions": "Scanning sessions", @@ -9588,7 +9673,9 @@ "globalScope": "Global", "projectScope": "Project", "currentProjectLower": "current project", - "project": "Project" + "project": "Project", + "hostScopeAriaLabel": "Session History host: {{value0}}", + "host": "Host" }, "AiVaultSessionDetails": { "originalAsk": "Original ask", @@ -9776,10 +9863,6 @@ "a84d5677ff": "OpenCode", "2528586aa7": "Claudino" } - }, - "PetOverlay": { - "de932b0e8f": "@keyframes pet-bob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-4px); } }", - "4712d196c6": "@keyframes pet-{{value0}} { from { background-position: {{value1}}px {{value2}}px; } to { background-position: {{value3}}px {{value4}}px; } }" } }, "onboarding": { @@ -10201,8 +10284,8 @@ "title": "Custom network address", "description": "Advertise an address your phone can reach when it is not on the same Wi-Fi — for example a Tailscale hostname or a static IP.", "label": "Address", - "placeholder": "my-mac.ts.net or 192.168.1.50", - "hint": "Enter an IP address or a Tailscale hostname (ends in .ts.net).", + "placeholder": "my-mac.ts.net, home.example.com, or 192.168.1.50", + "hint": "Enter an IP address or a hostname — a Tailscale MagicDNS name, a DDNS domain, or a LAN hostname — optionally with :port.", "cancel": "Cancel", "use": "Use address" }, @@ -11695,7 +11778,8 @@ "4bb7424d6b": "Canceled", "6e776f9ef9": "Download failed", "756bfc25c9": "Open", - "09a9489aa5": "Show" + "09a9489aa5": "Show", + "2a4c4b8e1f": "Copy" }, "BrowserToolbarMenu": { "429ef481f9": "Cancel", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 7276f765bec..f2a749b8020 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -4,7 +4,7 @@ "rootTitle": "Orca tuvo un error de renderizado.", "rootDescription": "El shell de la aplicación no pudo terminar de renderizarse. Vuelva a intentar volver a montarlo o reinicie Orca si el error persiste.", "webTitle": "Orca web tuvo un error de renderizado.", - "webDescription": "Vuelva a intentar el cliente web o vuelva a conectarse al tiempo de ejecución emparejado." + "webDescription": "Vuelva a cargar el cliente web o vuelva a conectarse al servidor Orca vinculado." } }, "settings": { @@ -20,14 +20,15 @@ "spanish": "Español" }, "statusBar": { - "claudeToggleDescription": "Muestra el token de Claude y el uso de costos para el espacio de trabajo activo.", - "codexToggleDescription": "Muestra el token Codex y el uso de costos para el espacio de trabajo activo.", - "geminiToggleDescription": "Muestra el token de Gemini y el uso de costos para el espacio de trabajo activo.", - "opencodeGoToggleDescription": "Muestra el token de OpenCode Go y el uso de costos para el espacio de trabajo activo.", + "claudeToggleDescription": "Muestra el consumo de tokens y el costo de Claude para el espacio de trabajo activo.", + "codexToggleDescription": "Muestra el consumo de tokens y el costo de Codex para el espacio de trabajo activo.", + "geminiToggleDescription": "Muestra el consumo de tokens y el costo de Gemini para el espacio de trabajo activo.", + "opencodeGoToggleDescription": "Muestra el consumo de tokens y el costo de OpenCode Go para el espacio de trabajo activo.", "kimiToggleDescription": "Muestra el uso de la suscripción de Kimi para el espacio de trabajo activo.", + "minimaxToggleDescription": "Muestra el uso de la suscripción de MiniMax para el espacio de trabajo activo.", "sshToggleDescription": "Muestra hosts SSH configurados y hosts remotos de Orca cuando haya alguno disponible.", - "resourceUsageToggleDescription": "Muestra el Administrador de recursos. Haga clic en él para ver la CPU, la memoria, las sesiones, los controles del demonio y los análisis del disco del espacio de trabajo.", - "portsToggleDescription": "Mostrar puertos del espacio de trabajo en vivo. Haga clic en él para puertos con ámbito de espacio de trabajo y oyentes externos." + "resourceUsageToggleDescription": "Muestra el Administrador de recursos. Haz clic para ver CPU, memoria, sesiones, controles del servicio en segundo plano y análisis de disco de los espacios de trabajo.", + "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." } } }, @@ -35,10 +36,10 @@ "checkForUpdates": "Buscar actualizaciones...", "settings": "Ajustes", "exploreOrca": "Explorar Orca", - "gettingStarted": "Empezando con Orca", + "gettingStarted": "Primeros pasos con Orca", "reportCrash": "Informar fallo...", "file": "Archivo", - "exit": "Salida", + "exit": "Salir", "edit": "Editar", "appearance": "Apariencia", "toggleLeftSidebar": "Alternar barra lateral izquierda", @@ -52,12 +53,12 @@ "reload": "Recargar", "forceReload": "Forzar recarga", "resetSize": "Restablecer tamaño", - "zoomIn": "Dar un golpe de zoom", + "zoomIn": "Acercar", "zoomOut": "Alejar", - "openWorktreePalette": "Abrir paleta de árbol de trabajo", + "openWorktreePalette": "Abrir paleta de worktrees", "window": "Ventana", "help": "Ayuda", - "paste": "Paste" + "paste": "Pegar" }, "tray": { "openOrca": "Abrir Orca", @@ -69,42 +70,42 @@ "worktreeJumpPalette": { "matchLabel": { "comment": "Comentario", - "issue": "Asunto", + "issue": "Issue", "port": "Puerto", "pr": "PR" } }, "auto": { "App": { - "221a95ba38": "Vuelva a intentar la incorporación o ciérrela y continúe en la aplicación.", - "f02d37278a": "La incorporación produjo un error.", - "acd66311dc": "Utilice el menú Ayuda después de volver a intentarlo si aún necesita diagnóstico.", - "722d03aa62": "El cuadro de diálogo del informe de fallos generó un error.", - "8a023cea1f": "Vuelva a intentar la barra de estado para volver a montar sus controles.", - "2e8ff36f94": "La barra de estado encontró un error.", - "7cbfbf622f": "Vuelva a intentar el espacio de trabajo flotante o ciérrelo y vuelva a abrirlo.", - "1b3024bcd6": "Se produjo un error en el espacio de trabajo flotante.", - "8d1e160ed1": "Vuelva a intentar la barra lateral o cambie de pestaña para recargar esta superficie.", - "ed6b168d00": "La barra lateral derecha encontró un error.", - "03a14f6b5b": "Vuelva a intentar la página o navegue a otra superficie de Orca.", + "221a95ba38": "Vuelve a intentar la configuración inicial o ciérrala y continúa en la aplicación.", + "f02d37278a": "La configuración inicial tuvo un error.", + "acd66311dc": "Si todavía necesitas diagnóstico después de reintentarlo, usa el menú Ayuda.", + "722d03aa62": "La ventana de informe de fallos tuvo un error.", + "8a023cea1f": "Vuelve a intentarlo para recargar los controles de la barra de estado.", + "2e8ff36f94": "La barra de estado tuvo un error.", + "7cbfbf622f": "Vuelve a intentarlo, o cierra y vuelve a abrir el espacio de trabajo flotante.", + "1b3024bcd6": "El espacio de trabajo flotante tuvo un error.", + "8d1e160ed1": "Vuelve a intentarlo o cambia de pestaña para recargar la barra lateral.", + "ed6b168d00": "La barra lateral derecha tuvo un error.", + "03a14f6b5b": "Vuelve a intentarlo con la página o navega a otra sección de Orca.", "b7a714db1e": "Esta página tuvo un error.", - "98d4ea2823": "Error al renderizar el terminal, el navegador o el editor en este espacio de trabajo. Vuelva a intentar montarlo.", - "5a9519aef0": "Se produjo un error en el banco de trabajo del espacio de trabajo.", - "cba0fafda5": "La página activa permanece abierta. Vuelva a intentar la lista o cambie de vista.", - "1468601e7b": "Se produjo un error en la lista de espacios de trabajo.", - "bdc71dddc9": "El espacio de trabajo activo permanece abierto. Vuelva a intentar la lista o cambie de vista.", + "98d4ea2823": "No se pudo mostrar el terminal, el navegador o el editor en este espacio de trabajo. Vuelve a intentarlo para recargarlo.", + "5a9519aef0": "El panel principal del espacio de trabajo tuvo un error.", + "cba0fafda5": "La página activa sigue abierta. Vuelve a intentarlo con la lista o cambia de vista.", + "1468601e7b": "La lista de espacios de trabajo tuvo un error.", + "bdc71dddc9": "El espacio de trabajo activo sigue abierto. Vuelve a intentarlo con la lista o cambia de vista.", "c1cf0b0e4a": "Contraer panel", - "8504ddf267": "La aplicación todavía se está ejecutando. Vuelva a intentar el shell o utilice el menú para informar los detalles del fallo.", - "df1d56bf87": "El shell del espacio de trabajo encontró un error.", + "8504ddf267": "La aplicación sigue ejecutándose. Vuelve a intentar cargar el espacio de trabajo o usa el menú para informar los detalles del fallo.", + "df1d56bf87": "La vista del espacio de trabajo tuvo un error.", "9e0b441a91": "Alternar barra lateral derecha", "e81217c1b7": "Ocultar nombre de la aplicación", "5096cbbc86": "Orca", "8b0b8eb54f": "Menú de aplicaciones", "caea5b51b9": "Reiniciar ahora", - "0a9e810705": "Los cambios no se guardarán hasta que se reinicie. Tus pestañas anteriores están seguras en el disco.", + "0a9e810705": "Hasta que reinicies, los cambios no se guardarán. Tus pestañas anteriores siguen guardadas en disco.", "12e77cf12b": "Error al restaurar la sesión", "332dbfa497": "Espacio de trabajo subido", - "e960d18540": "Cerca", + "e960d18540": "Cerrar", "c9d6f98459": "Maximizar", "66f0a552e5": "Restaurar", "bbb7f90669": "Minimizar", @@ -117,7 +118,7 @@ "ca6c6eece7": "habilidades", "1b9d9d065f": "ajustes", "c184e056de": "Alternar barra lateral derecha ({{value0}})", - "f7aa73e785": "Seguir adelante ({{value0}})", + "f7aa73e785": "Avanzar ({{value0}})", "cf9099fe98": "Avanzar", "fe21e8f6f5": "Volver ({{value0}})", "064bd07810": "Volver", @@ -127,34 +128,34 @@ "web": { "WebConnect": { "b411ec0069": "Conectar", - "2cf9e5a294": "Borrar servidor guardado", - "4a4c017be1": "Punto final:", + "2cf9e5a294": "Eliminar servidor guardado", + "4a4c017be1": "Dirección de conexión:", "27393856e4": "orca://pair?code=...", "7a566540de": "URL o código de emparejamiento", "cb4d287238": "Nombre del servidor", - "3affe7de3a": "Pegue una URL de emparejamiento de un servidor Orca al que este navegador pueda acceder.", - "e3bcd082ac": "Conéctate a Orca", - "mobileScopeRejected": "Este código QR concede acceso limitado (móvil). Para usar la app web completa, abre el enlace del navegador desde Ajustes → Servidores remotos de Orca → Comparte este servidor Orca → Nuevo enlace." + "3affe7de3a": "Pega una URL de emparejamiento de un servidor Orca accesible desde este navegador.", + "e3bcd082ac": "Conectar con Orca", + "mobileScopeRejected": "Este código QR solo da acceso limitado para móvil. Para usar la app web completa, abre el enlace para navegador desde Ajustes → Servidores remotos de Orca → Comparte este servidor Orca → Nuevo enlace." }, "web": { "preload": { "api": { - "31bfe8ae1a": "No disponible en el cliente web.", + "31bfe8ae1a": "No está disponible en el cliente web.", "67ec964791": "La importación de cookies no está disponible en el cliente web.", - "275a776357": "La extracción al pasar el mouse no está disponible en el cliente web.", - "8dfcb7a351": "Las capturas de pantalla de selección no están disponibles en el cliente web.", - "31bea294d5": "El modo Grab no está disponible en el cliente web.", - "b8a1618172": "La generación de detalles de solicitudes de extracción no está disponible en el cliente web.", - "e57c82d276": "La Commit del descubrimiento del modelo de mensaje no está disponible en el cliente web.", - "9fc90740b6": "La generación de mensajes de Commit no está disponible en el cliente web.", + "275a776357": "La extracción al pasar el cursor no está disponible en el cliente web.", + "8dfcb7a351": "Las capturas de pantalla de una selección no están disponibles en el cliente web.", + "31bea294d5": "El modo de captura no está disponible en el cliente web.", + "b8a1618172": "La generación de detalles de pull requests no está disponible en el cliente web.", + "e57c82d276": "La detección del modelo para mensajes de commit no está disponible en el cliente web.", + "9fc90740b6": "La generación de mensajes de commit no está disponible en el cliente web.", "52bee9d8a0": "Se ignoraron los atajos personalizados en conflicto: {{value0}}.", "32f15bdb0f": "Se ignoró la plataforma desconocida \"{{value0}}\".", - "0a69fcd8bc": "plataformas debe ser un objeto con secciones darwin, linux o win32.", - "10898045f3": "Se ignoró el acceso directo para \"{{value0}}\": utilice una matriz de cadenas.", - "36761d9604": "Se ignoró la acción de combinación de teclas desconocida \"{{value0}}\".", + "0a69fcd8bc": "platforms debe ser un objeto con secciones darwin, linux o win32.", + "10898045f3": "Se ignoró el atajo para \"{{value0}}\": usa un array de strings.", + "36761d9604": "Se ignoró la acción de atajo desconocida \"{{value0}}\".", "d2e43e426a": "{{value0}} debe ser un objeto.", - "fb290366b2": "No disponible en la web.", - "76122208ca": "Se ignoró el acceso directo para \"{{value0}}\": {{value1}}" + "fb290366b2": "No está disponible en la web.", + "76122208ca": "Se ignoró el atajo para \"{{value0}}\": {{value1}}" } }, "runtime": { @@ -171,30 +172,30 @@ "08fc23631d": "Navegador" }, "editor": { - "dcb521ed29": "Este archivo se encuentra en estado de conflicto, pero no hay ningún archivo de árbol de trabajo disponible para editar.", - "51f15c37d3": "No se puede abrir el directorio: {{value0}}", - "f2e00db373": "Archivo no encontrado: {{value0}}", - "checkRunDetailsUnavailable": "No hay detalles disponibles para esta comprobación.", - "checkRunDetailsLoadFailed": "No se pudieron cargar los detalles de la comprobación." + "dcb521ed29": "Este archivo está en conflicto, pero no hay una copia editable en el worktree.", + "51f15c37d3": "No se puede abrir la carpeta: {{value0}}", + "f2e00db373": "No se encontró el archivo: {{value0}}", + "checkRunDetailsUnavailable": "No hay detalles disponibles para este check.", + "checkRunDetailsLoadFailed": "No se pudieron cargar los detalles del check." }, "github": { - "f129c42773": "GitHub no devolvió el nuevo comentario.", - "683a21264b": "La fila no tiene propietario/repo/número.", - "83f9b126ad": "El tipo de problema solo se puede configurar en problemas.", - "f963485d37": "Fila no encontrada", - "a967f23983": "Vista del proyecto no cargada", - "87020f6605": "La fila no tiene propietario/repo/número: no se puede parchear el elemento subyacente", - "d49ef4b944": "No se pudo guardar la preferencia de fuente del problema" + "f129c42773": "GitHub no devolvió el comentario nuevo.", + "683a21264b": "La fila no tiene owner/repo/number.", + "83f9b126ad": "El campo Issue Type solo se puede configurar en issues.", + "f963485d37": "No se encontró la fila", + "a967f23983": "La vista del proyecto no está cargada", + "87020f6605": "La fila no tiene owner/repo/number; no se puede actualizar el elemento asociado", + "d49ef4b944": "No se pudo guardar la preferencia de origen de issues" }, "sparse": { "presets": { - "ef13e994e6": "Los ajustes preestablecidos deben cargarse antes de guardarlos.", - "6ed7d6010a": "No se pudo eliminar el ajuste preestablecido", - "ee434d7941": "Preestablecido eliminado", - "c96b770172": "No se pudo guardar el ajuste preestablecido", - "811be06b57": "No se pudo actualizar el ajuste preestablecido", - "0696d13e56": "Preajuste guardado", - "e10f097822": "Preestablecido actualizado" + "ef13e994e6": "Los presets deben cargarse antes de guardarlos.", + "6ed7d6010a": "No se pudo eliminar el preset", + "ee434d7941": "Preset eliminado", + "c96b770172": "No se pudo guardar el preset", + "811be06b57": "No se pudo actualizar el preset", + "0696d13e56": "Preset guardado", + "e10f097822": "Preset actualizado" } }, "store": { @@ -211,39 +212,39 @@ }, "worktrees": { "5a58e03a26": "Se eliminó \"{{value0}}\".", - "d1d78a7baa": "Git no pudo eliminar de forma segura la rama \"{{value0}}\"{{value1}}, por lo que Orca la mantuvo para evitar perder commits locales.", - "4e6496f3d2": "{{value0}} eliminado, rama mantenida", + "d1d78a7baa": "Git no pudo eliminar de forma segura la rama \"{{value0}}\"{{value1}}, así que Orca la mantuvo para evitar perder commits locales.", + "4e6496f3d2": "{{value0}} eliminado, rama conservada", "e50495aae6": "Forzar eliminación de rama", - "889487d8bb": "Despedir", - "f4503ca505": "Abra Configuración > Git e inténtelo de nuevo.", - "34a03a6565": "Mantenga {{value0}} actualizado", - "fa9299a66f": "Su nuevo árbol de trabajo está actualizado, pero el {{value0}} local está {{value1}} {{value2}} atrasado. Las diferencias de IA pueden pasar por alto las commits recientes.", - "14bc053a47": "El local {{value0}} no se actualizó", - "4a18052018": "El local {{value0}} está detrás de {{value1}}", - "903b51c2ed": "Espacio de trabajo creado a partir de {{value0}}, pero Orca no pudo adelantar el {{value1}} local porque {{value2}}", + "889487d8bb": "Descartar", + "f4503ca505": "Abre Ajustes > Git e inténtalo de nuevo.", + "34a03a6565": "Mantener {{value0}} actualizado", + "fa9299a66f": "Tu nuevo worktree está actualizado, pero el {{value0}} local está {{value1}} {{value2}} por detrás. Los diffs de IA pueden omitir commits recientes.", + "14bc053a47": "No se actualizó el {{value0}} local", + "4a18052018": "El {{value0}} local está {{value1}} por detrás", + "903b51c2ed": "Espacio de trabajo creado desde {{value0}}, pero Orca no pudo hacer fast-forward del {{value1}} local porque {{value2}}", "0216895fb5": "No se pudo eliminar la rama", - "19db0085fb": "Sucursal local eliminada", + "19db0085fb": "Rama local eliminada", "2b0afc7f14": "No se pudo mantener actualizado el {{value0}} local", - "670864ab52": "Mantener actualizado el {{value0}} local", + "670864ab52": "Manteniendo actualizado el {{value0}} local", "5366d13eec": "Espacio de trabajo eliminado, rama conservada", - "2e17f825d4": "Árbol de trabajo eliminado, rama conservada", - "78e08cd877": "Git no pudo eliminar de forma segura la rama \"{{value0}}\", por lo que Orca la conservó para evitar perder commits locales.", - "3b57982bf6": "Git no pudo eliminar de forma segura la rama \"{{value0}}\" después de eliminar el espacio de trabajo \"{{value1}}\", por lo que Orca la conservó para evitar perder commits locales.", - "81f13f48d2": "Git no pudo eliminar de forma segura la rama \"{{value0}}\" después de eliminar el árbol de trabajo \"{{value1}}\", por lo que Orca la conservó para evitar perder commits locales.", + "2e17f825d4": "Worktree eliminado, rama conservada", + "78e08cd877": "Git no pudo eliminar de forma segura la rama \"{{value0}}\", así que Orca la conservó para evitar perder commits locales.", + "3b57982bf6": "Git no pudo eliminar de forma segura la rama \"{{value0}}\" después de eliminar el espacio de trabajo \"{{value1}}\", así que Orca la conservó para evitar perder commits locales.", + "81f13f48d2": "Git no pudo eliminar de forma segura la rama \"{{value0}}\" después de eliminar el worktree \"{{value1}}\", así que Orca la conservó para evitar perder commits locales.", "runtimeScopeForbiddenTitle": "Esta conexión tiene acceso limitado (móvil)", "runtimeScopeForbiddenDescription": "Los espacios de trabajo no están disponibles con un emparejamiento de acceso móvil. Vuelve a conectarte con el enlace del navegador desde Ajustes → Servidores remotos de Orca → Comparte este servidor Orca." }, "repos": { - "2975400634": "Actualice el servidor Orca para abrir carpetas que no sean Git en este tiempo de ejecución.", + "2975400634": "Actualiza el servidor Orca para abrir carpetas que no sean Git en este host.", "b7e14472ae": "No se pudo agregar la carpeta", - "e649269645": "Utilice una ruta de servidor para agregar proyectos desde un tiempo de ejecución remoto.", + "e649269645": "Usa Agregar proyecto para ingresar una ruta en el host seleccionado.", "c6e022ddfc": "No se pudo agregar el proyecto", "90d129b48b": "Carpeta agregada", "8bb3ad7935": "Proyecto agregado", "a8e4b3af5b": "Proyecto ya agregado", "6d3318e813": "No se pudieron importar repositorios", - "3be0f7df04": "No se puede abrir la carpeta en el tiempo de ejecución seleccionado", - "15cf5319ec": "{{path}} fue verificado en {{hostName}}, pero ese host no informó una carpeta utilizable." + "3be0f7df04": "No se puede abrir la carpeta en el host seleccionado", + "15cf5319ec": "{{path}} se verificó en {{hostName}}, pero ese host no reportó ninguna carpeta utilizable." }, "settings": { "e12dab333b": "No se pudo cambiar de servidor", @@ -302,12 +303,12 @@ "skill": { "cli": { "prerequisite": { - "79371593b0": "Orca CLI aún no es visible en PATH", + "79371593b0": "Orca CLI aún no está visible en PATH", "e99d7dc36f": "El registro de Orca CLI necesita atención", "2db0bd7515": "El registro de Orca CLI no está disponible", "8d6eedf97e": "No se pudo registrar Orca CLI en PATH.", - "0f116999f1": "Reinicie su shell o agregue el directorio CLI de Orca a PATH antes de la instalación.", - "15cbedc3e3": "Instale la CLI de Orca antes de ejecutar la configuración de habilidades del agente." + "0f116999f1": "Reinicia tu shell o agrega el directorio de Orca CLI a PATH antes de la configuración.", + "15cbedc3e3": "Instala la CLI de Orca antes de configurar skills para agentes." } } } @@ -323,14 +324,14 @@ "checks": { "agent": { "launch": { - "027228a06b": "No se puede encontrar un espacio de trabajo para estas comprobaciones.", + "027228a06b": "No se encontró un espacio de trabajo para estos checks.", "fb6c294e85": "No se pudo generar el comando de inicio del agente.", - "03c1d61f83": "No se puede abrir el espacio de trabajo adjunto a estos controles.", + "03c1d61f83": "No se puede abrir el espacio de trabajo adjunto a estos checks.", "822bf52295": "No se puede resolver la plataforma de inicio del espacio de trabajo.", - "dfb4dd7c00": "No se puede encontrar el espacio de trabajo adjunto a estos controles.", - "9f00d7df0c": "El mensaje de corrección de comprobaciones está vacío. Actualice la configuración de AI de control de fuente.", + "dfb4dd7c00": "No se encontró el espacio de trabajo adjunto a estos checks.", + "9f00d7df0c": "El prompt de corrección de checks está vacío. Actualiza los ajustes de Source Control AI.", "2ebf794906": "No se detectó ningún agente de IA habilitado en este host de espacio de trabajo.", - "4c7f783a7a": "El agente de cheques guardados no está disponible en este host de espacio de trabajo." + "4c7f783a7a": "El agente guardado para checks no está disponible en este host de espacio de trabajo." } } } @@ -350,13 +351,13 @@ "new": { "tab": { "11cce5cc77": "No se pudo iniciar {{value0}} en una nueva terminal.", - "a5a1f7033f": "Su {{value0}} no fue enviado; péguelo una vez que el agente esté listo." + "a5a1f7033f": "No se envió tu {{value0}}; pégalo cuando el agente esté listo." } } }, "background": { "session": { - "4ca0651d56": "Su mensaje de automatización no fue enviado: abra el espacio de trabajo y péguelo." + "4ca0651d56": "No se envió tu prompt de automatización: abre el espacio de trabajo y pégalo." } } }, @@ -371,11 +372,11 @@ "item": { "direct": { "3de6371df3": "No se pudo generar el comando de inicio del agente.", - "67e103dd60": "Espacio de trabajo creado pero no se pudo activar.", + "67e103dd60": "Se creó el espacio de trabajo, pero no se pudo activar.", "19c7683acf": "El agente seleccionado no está disponible en el espacio de trabajo creado.", - "8bc45efdbc": "No se pudo resolver el jefe de PR.", + "8bc45efdbc": "No se pudo resolver el head del PR.", "agent": { - "ceeeb509b5": "El Agent tardó demasiado en comenzar. El espacio de trabajo está listo: pegue {{value0}} cuando el Agent esté inactivo." + "ceeeb509b5": "El agente tardó demasiado en iniciar. El espacio de trabajo está listo: pega {{value0}} cuando el agente esté inactivo." } } } @@ -385,7 +386,7 @@ "path": { "open": { "guard": { - "edc1908653": "No está disponible abrir rutas remotas en el sistema operativo local." + "edc1908653": "No se pueden abrir rutas remotas en el sistema operativo local." } } } @@ -403,7 +404,7 @@ "mobile": { "emulator": { "tab": { - "bf4f2a8a72": "No se pudo iniciar el emulador. Verifique la configuración del emulador de iOS o Android y pruebe con otro dispositivo." + "bf4f2a8a72": "No se pudo iniciar el emulador. Revisa la configuración del emulador de iOS o Android y prueba con otro dispositivo." } } } @@ -411,10 +412,10 @@ "orchestration": { "usage": { "examples": { - "f91fe27f2a": "Dividir un cambio grande en RP más pequeños", - "9e37a5b1b3": "Ejecutar trabajo independiente en paralelo.", - "bddc4c09b8": "Ejecute un flujo de trabajo por fases", - "ab0e9803b7": "Entregar a otro árbol de trabajo", + "f91fe27f2a": "Dividir un cambio grande en PRs más pequeños", + "9e37a5b1b3": "Ejecutar trabajo independiente en paralelo", + "bddc4c09b8": "Ejecutar un flujo de trabajo por fases", + "ab0e9803b7": "Entregar a otro worktree", "5e0d489fe1": "Entregar una tarea activa" } } @@ -422,9 +423,9 @@ "pr": { "comment": { "audience": { - "64deee36a9": "robots", + "64deee36a9": "Bots", "a7150a17bc": "Humanos", - "27ce73211c": "Todo", + "27ce73211c": "Todos", "empty": { "bot": "Sin comentarios de robots.", "human": "Sin comentarios humanos.", @@ -449,9 +450,9 @@ "plan": { "3f0ea9aa0d": "No se pudo generar el comando de inicio del agente.", "46f1a2c9bd": "La entrada del comando está vacía.", - "8eb541cc83": "El agente seleccionado no fue detectado en este host de espacio de trabajo.", - "b96e091fc9": "El agente seleccionado está deshabilitado en Configuración.", - "a7ac8717c7": "Elija un agente antes de comenzar." + "8eb541cc83": "No se detectó el agente seleccionado en este host de espacio de trabajo.", + "b96e091fc9": "El agente seleccionado está deshabilitado en Ajustes.", + "a7ac8717c7": "Elige un agente antes de comenzar." } } }, @@ -465,8 +466,8 @@ "sparse": { "preset": { "draft": { - "5915a0a1f6": "Utilice directorios relativos al repo, no raíz, rutas absolutas ni segmentos principales.", - "efc05d1820": "Agregue al menos un directorio." + "5915a0a1f6": "Usa directorios relativos al repositorio, no la raíz, rutas absolutas ni segmentos padre.", + "efc05d1820": "Agrega al menos un directorio." } } }, @@ -475,8 +476,8 @@ "capture": { "notification": { "b0536028c9": "Abrir atajos", - "141ad6c004": "Acceso directo a terminal manejado", - "0ab0cd001a": "tamaño-4 texto-silenciado-primer plano" + "141ad6c004": "Atajo de terminal gestionado", + "0ab0cd001a": "size-4 text-muted-foreground" } } } @@ -486,7 +487,7 @@ "error": { "format": { "37cf0bc991": "Orca no pudo resolver una referencia base utilizable para este espacio de trabajo.", - "64555d0014": "No se encontró ninguna sucursal base" + "64555d0014": "No se encontró ninguna rama base" } } } @@ -494,7 +495,7 @@ "worktree": { "palette": { "search": { - "9ccec2316b": "Asunto", + "9ccec2316b": "Issue", "ca40ffcbec": "PR", "0b01ff98d2": "Puerto", "7d732521ec": "Comentario" @@ -506,41 +507,41 @@ "missing": "Carpeta no encontrada", "notDirectory": "La ruta no es una carpeta", "ambiguousConnection": "No se puede determinar la conexión", - "unavailable": "No se puede comprobar la carpeta" + "unavailable": "No se puede verificar la carpeta" }, "description": { "missing": "Orca no puede encontrar {{path}}. Elimina y vuelve a importar este espacio de trabajo de carpeta.", "notDirectory": "{{path}} existe, pero no es una carpeta.", - "ambiguousConnection": "Orca no puede determinar qué conexión SSH pertenece a este ámbito de carpeta.", - "unavailable": "Orca no puede verificar esta carpeta ahora mismo. Revisa el runtime o la conexión SSH e inténtalo de nuevo." + "ambiguousConnection": "Orca no puede determinar a qué conexión SSH pertenece este alcance de carpeta.", + "unavailable": "Orca no puede verificar esta carpeta ahora mismo. Revisa el host o la conexión SSH e inténtalo de nuevo." }, "createError": { "title": { "missing": "Carpeta no encontrada", "notDirectory": "La ruta no es una carpeta", "ambiguousConnection": "No se puede determinar la conexión", - "unavailable": "No se puede comprobar la carpeta", + "unavailable": "No se puede verificar la carpeta", "generic": "No se pudo crear el espacio de trabajo de carpeta" }, "description": { "missing": "Orca no puede encontrar {{path}}. Elimina y vuelve a importar la carpeta.", "notDirectory": "{{path}} existe, pero no es una carpeta.", - "ambiguousConnection": "Orca no puede determinar qué conexión SSH pertenece a este ámbito de carpeta.", - "unavailable": "Orca no puede verificar esta carpeta ahora mismo. Revisa el runtime o la conexión SSH e inténtalo de nuevo." + "ambiguousConnection": "Orca no puede determinar a qué conexión SSH pertenece este alcance de carpeta.", + "unavailable": "Orca no puede verificar esta carpeta ahora mismo. Revisa el host o la conexión SSH e inténtalo de nuevo." } } }, "projectSkillRuntime": { - "wslUnavailable": "El runtime del proyecto necesita WSL antes de instalar esta habilidad.", - "distroRequired": "Selecciona una distribución WSL para este proyecto antes de instalar esta habilidad.", - "distroMissing": "La distribución WSL seleccionada no está disponible. Elige una disponible o cambia este proyecto a Windows.", - "wslDefault": "Predeterminado de WSL" + "wslUnavailable": "El entorno del proyecto necesita WSL antes de instalar esta skill.", + "distroRequired": "Selecciona una distro WSL para este proyecto antes de instalar esta skill.", + "distroMissing": "La distro WSL seleccionada no está disponible. Elige una disponible o cambia este proyecto a Windows.", + "wslDefault": "WSL predeterminado" }, "sidebarWorktreeActivation": { - "wakeEphemeralVmFailed": "Failed to wake ephemeral VM workspace" + "wakeEphemeralVmFailed": "No se pudo reactivar el espacio de trabajo de la VM efímera" }, "ephemeralVmWorkspaceTarget": { - "projectRootRegistrationFailed": "Failed to register the recipe-created project root on the runtime." + "projectRootRegistrationFailed": "No se pudo registrar en el host la raíz del proyecto creada por la receta." } }, "hooks": { @@ -551,23 +552,23 @@ "3ad7d77f57": "El espacio de trabajo de destino está en un host distinto al destino de ejecución de esta automatización." }, "useComposerState": { - "7eb3f44ff7": "El agente seleccionado está deshabilitado. Elija un agente habilitado antes de crear.", - "b2ead86962": "No se pudo resolver la base de PR.", + "7eb3f44ff7": "El agente seleccionado está deshabilitado. Elige un agente habilitado antes de crear.", + "b2ead86962": "No se pudo resolver la base del PR.", "a9ff236145": "Algunos archivos adjuntos no se pudieron cargar.", "3db83fc58a": "No hay ninguna ruta de proyecto remoto disponible para los archivos adjuntos.", "ba6cb77082": "No se pudo conectar al proyecto.", "chooseOrAddProjectBeforeWorkspace": "Elige o agrega un proyecto antes de crear un espacio de trabajo.", "folderWorkspaceCreateFailedTitle": "No se pudo crear el espacio de trabajo de carpeta", "folderWorkspaceCreateFailedMessage": "No se pudo crear el espacio de trabajo de carpeta. Revisa los detalles del error de arriba e inténtalo de nuevo.", - "setupAgentStartupPolicySaveFailed": "Failed to save setup startup behavior.", - "5f3d2c8a1b": "Failed to resolve MR base." + "setupAgentStartupPolicySaveFailed": "No se pudo guardar el comportamiento de inicio del setup.", + "5f3d2c8a1b": "No se pudo resolver la base del MR." }, "useGlobalFileDrop": { - "38c9f034ff": "No se pudieron cargar los archivos eliminados.", - "d720e2f855": "Algunos archivos caídos no se pudieron cargar.", - "245faa95b9": "No hay ninguna ruta de espacio de trabajo remoto disponible para los archivos descartados.", + "38c9f034ff": "No se pudieron cargar los archivos soltados.", + "d720e2f855": "Algunos archivos soltados no se pudieron cargar.", + "245faa95b9": "No hay ninguna ruta de espacio de trabajo remoto disponible para los archivos soltados.", "nativeDropTooManyPathsDescription": "Suelta {{value0}} archivos o menos a la vez.", - "nativeDropTooManyPaths": "La suelta contiene demasiados archivos.", + "nativeDropTooManyPaths": "Has soltado demasiados archivos.", "nativeDropPathsTooLargeDescription": "Suelta menos archivos o usa una lista de rutas más corta.", "nativeDropPathsTooLarge": "La lista de rutas soltadas es demasiado grande." }, @@ -575,32 +576,32 @@ "0e3cf53060": "Pestaña del navegador {{value0}} no encontrada", "2f6637fe6c": "La pestaña del navegador {{value0}} está fijada", "a8d2bf8e9e": "No hay ninguna pestaña activa del navegador para cerrar", - "291c8ed902": "Las pestañas del navegador no están disponibles mientras un tiempo de ejecución remoto está activo", - "f45fa2b03c": "Los perfiles del navegador no están disponibles mientras un tiempo de ejecución remoto está activo", - "f000b2ff76": "Ningún árbol de trabajo activo", - "56d3ec4203": "No se pudo crear el archivo de markdowns sin título.", + "291c8ed902": "Las pestañas del navegador no están disponibles mientras haya un host remoto activo", + "f45fa2b03c": "Los perfiles del navegador no están disponibles mientras haya un host remoto activo", + "f000b2ff76": "No hay ningún worktree activo", + "56d3ec4203": "No se pudo crear el archivo Markdown sin título.", "f6300deb8b": "Nueva pestaña del navegador", - "7a64b31991": "La creación de terminal local no está disponible mientras un tiempo de ejecución remoto está activo", - "60428567b4": "La revelación de terminal local no está disponible mientras un tiempo de ejecución remoto está activo", + "7a64b31991": "La creación de terminales locales no está disponible mientras haya un host remoto activo", + "60428567b4": "Mostrar terminales locales no está disponible mientras haya un host remoto activo", "f8aaf2bde3": "Espacio de trabajo subido", "2fe88c2e06": "La sincronización del espacio de trabajo remoto no está disponible", "2ec42e1c52": "Aún no hay espacio de trabajo remoto", - "88214a785b": "La sincronización del espacio de trabajo esperó a que se hidratara la sesión local y se agotó el tiempo de espera", + "88214a785b": "La sincronización del espacio de trabajo esperó a que la sesión local terminara de cargarse y agotó el tiempo de espera", "4f78ba5885": "Espacio de trabajo sincronizado" }, "useSettingsNavigationMetadata": { - "4a728cd56b": "Nuevas características que aún están tomando forma. Pruébalos.", + "4a728cd56b": "Nuevas funciones que aún están tomando forma. Pruébalas.", "225071c560": "Experimental", - "e338c507c1": "Configuraciones de compatibilidad de bajo nivel para solucionar problemas.", + "e338c507c1": "Ajustes de compatibilidad de bajo nivel para solucionar problemas.", "580a04cd81": "Avanzado", "8400cfe1c1": "Datos de uso anónimos y controles de telemetría.", "3618579df6": "Privacidad y telemetría", - "65ec7d1968": "Acceso a la privacidad de macOS para herramientas de desarrollo lanzadas en terminales.", + "65ec7d1968": "Permisos de privacidad de macOS para herramientas de desarrollo lanzadas desde terminales.", "d91ae31fbd": "Permisos de macOS", - "95a1886d94": "Controla terminales y agents desde tu teléfono.", + "95a1886d94": "Controla terminales y agentes desde tu teléfono.", "1cd25673df": "Móvil", "31e57d1c70": "Usa máquinas existentes por SSH para archivos, terminales, Git y espacios de trabajo.", - "94a5afe910": "Anfitriones SSH", + "94a5afe910": "Hosts SSH", "40d80bad8a": "Beta", "de0c2907a1": "Servidores remotos de Orca", "b351014180": "Estadísticas de Orca más análisis de uso de Claude, Codex y OpenCode.", @@ -613,9 +614,9 @@ "0c6ee88a5f": "Entrada y edición", "b11a5a48a2": "Tema, zoom, apariencia de la aplicación y del terminal, barras laterales y barra de estado.", "93d88d20bf": "Apariencia", - "2d0659f6f0": "Terminal global, navegador y pestañas de markdowns.", + "2d0659f6f0": "Terminal global, navegador y pestañas Markdown.", "65b19f5bde": "Espacio de trabajo flotante", - "3d65d3f1b9": "Configure la compatibilidad con el emulador móvil para Orca y agents de codificación.", + "3d65d3f1b9": "Configura la compatibilidad del emulador móvil para Orca y coding agents.", "1e761cff2b": "Emulador móvil", "e815fd01bd": "Página de inicio, enrutamiento de enlaces y cookies de sesión.", "8c197f74a1": "Navegador", @@ -623,36 +624,36 @@ "3fc3db144f": "Comandos rápidos", "c33bfd664c": "Shells, renderizador, sesiones y comportamiento del terminal.", "a9fb10afca": "Terminal", - "5235c215ca": "Elija qué proveedores de tareas aparecen en la página Tareas y en la barra lateral.", + "5235c215ca": "Elige qué proveedores de tareas aparecen en la página Tareas y en la barra lateral.", "85f4fd7710": "Fuentes de tareas", - "ab4b21b58e": "Nomenclatura de sucursales, referencias base, atribución y autor de Git AI.", - "09607cb0fe": "Git y control de código fuente", - "33a5e1d597": "Conecte GitHub, GitLab, Linear y servicios de alojamiento de origen.", + "ab4b21b58e": "Nombres de ramas, refs base, atribución y Git AI Author.", + "09607cb0fe": "Git y Source Control", + "33a5e1d597": "Conecta GitHub, GitLab, Linear y servicios de hosting de código.", "2b043783ef": "Integraciones", - "2cd4ea75da": "Valores predeterminados del espacio de trabajo, configuración y mantenimiento de aplicaciones.", + "2cd4ea75da": "Ajustes predeterminados del espacio de trabajo, configuración de la app y mantenimiento.", "13241992bd": "General", - "724c440e72": "empezando", + "724c440e72": "primeros pasos", "0505d0df29": "empezar con Orca", "ea0b1bc7b8": "guía de configuración", - "17005c73d4": "Abra la lista de verificación de incorporación para conocer los pasos de configuración y hitos.", - "ded9e9032f": "Lista de verificación de incorporación", - "5f32ac08f3": "Complete la lista de verificación de incorporación para los flujos de trabajo principales de Orca.", + "17005c73d4": "Abre la lista de configuración inicial para ver pasos de setup e hitos.", + "ded9e9032f": "Lista de configuración inicial", + "5f32ac08f3": "Completa la lista de configuración inicial para los flujos de trabajo principales de Orca.", "8ac3de82f5": "Dictado local de voz a texto con modelos en el dispositivo.", "6a50cdcd7c": "Voz", - "0059bd17f3": "Permita que los agents controlen cualquier aplicación en su computadora.", - "b35e92364b": "Uso de la computadora", - "cd50cec5d7": "Coordine múltiples agents de codificación a través de Orca.", + "0059bd17f3": "Permite que los agentes controlen cualquier app en tu computadora.", + "b35e92364b": "Computer Use", + "cd50cec5d7": "Coordina múltiples coding agents a través de Orca.", "58a868e8e4": "Orquestación", "7c79d3b7bf": "Opcional", "b1c2f8b0ac": "Cambio de cuenta opcional para Claude, Codex, Gemini y OpenCode Go.", "f70ac54d38": "Cuentas de proveedores de IA", - "4121f7a0a2": "Administre agents de IA, establezca un valor predeterminado y personalice comandos.", + "4121f7a0a2": "Administra agentes de IA, define uno predeterminado y personaliza comandos.", "b49abbd2f7": "Agentes", "dev": "Dev Tools", - "devDescription": "Dev-only tools for exercising UI states.", + "devDescription": "Herramientas solo para dev para probar estados de UI.", "devBadge": "Dev", - "devSearchNotificationPlayground": "Notification playground", - "devSearchNotificationPlaygroundDescription": "Trigger representative toast and notification UI states.", + "devSearchNotificationPlayground": "Panel de prueba de notificaciones", + "devSearchNotificationPlaygroundDescription": "Activa estados representativos de notificaciones y toasts en la interfaz.", "devSearchKeywordDev": "dev", "devSearchKeywordToast": "toast", "devSearchKeywordSonner": "sonner", @@ -668,10 +669,10 @@ }, "components": { "CodexRestartChip": { - "9132779820": "Despedir", - "c72a5fb234": "Reanudar", + "9132779820": "Descartar", + "c72a5fb234": "Reiniciar", "9263e75f49": "Codex está usando la cuenta anterior.", - "a4c8e1b2f7": "Codex aún inició sesión como {{value0}}", + "a4c8e1b2f7": "Codex sigue conectado como {{value0}}", "d3e8a1f4b2": "Cuenta cambiada", "e9b2c7d1a5": "Reinicia esta sesión para usar {{value0}}. Contrae para seguir trabajando con la cuenta actual.", "f1a6c8e3b4": "Contraer" @@ -681,15 +682,15 @@ "94cc673726": "Entiendo", "fc5cc29955": "Optar por no participar", "d1deebb050": "Política de privacidad", - "958d2cc31b": "Los recuentos anónimos de las funciones que utiliza nos ayudan a priorizar qué crear. Sin contenidos de archivos, mensajes, salida de terminal ni nada que lo identifique. Cambie en cualquier momento en Configuración -> Privacidad y telemetría.", - "9784b4d7bc": "Ayúdanos a decidir qué construir a continuación", + "958d2cc31b": "Los recuentos anónimos de las funciones que usas nos ayudan a priorizar qué crear. Sin contenido de archivos, prompts, salida de terminal ni nada que te identifique. Puedes cambiarlo en cualquier momento en Ajustes -> Privacidad y telemetría.", + "9784b4d7bc": "Ayúdanos a decidir qué crear a continuación", "fcbee32f08": "Aviso de telemetría" }, "GitHubItemDialog": { - "3ab6ac0fc8": "Obtenga una vista previa y edite el problema de GitHub seleccionado o la solicitud de extracción.", + "3ab6ac0fc8": "Previsualiza y edita el issue o pull request de GitHub seleccionado.", "3cd5ae5b7b": "No se cambiaron archivos.", "999b5ad7d9": "Archivos", - "4bd1f5b055": "cheques", + "4bd1f5b055": "Checks", "e30a5470c9": "Conversación", "474c59b4b3": "Cerrar · Esc", "45af57999b": "Cerrar vista previa", @@ -698,22 +699,22 @@ "0caac1a18f": "Iniciar espacio de trabajo desde PR", "8223320f8d": "actualizado", "10ef1afb8e": "· actualizado", - "55962099bc": "abrió este problema", - "0ab4664a8b": "Iniciar espacio de trabajo desde el problema", + "55962099bc": "abrió este issue", + "0ab4664a8b": "Iniciar espacio de trabajo desde issue", "36182aa57f": "Iniciar nuevo espacio de trabajo", - "fe6ff12dc2": "Más acciones en el espacio de trabajo de problemas", - "726db41722": "Espacio de trabajo abierto", - "84855fedd0": "Abrir espacio de trabajo adjunto al problema", + "fe6ff12dc2": "Más acciones del espacio de trabajo de issue", + "726db41722": "Abrir espacio de trabajo", + "84855fedd0": "Abrir espacio de trabajo adjunto al issue", "b7bf31b8de": "No se pudo sincronizar el estado visto con GitHub.", - "c0253318d6": "No se puede sincronizar el estado visto para esta solicitud de extracción.", + "c0253318d6": "No se puede sincronizar el estado visto de este pull request.", "5fea151559": "No se pudo copiar el enlace de GitHub", "2e77dc2053": "Enlace de GitHub copiado", - "2ef631437e": "No se puede abrir el espacio de trabajo adjunto a este problema.", + "2ef631437e": "No se puede abrir el espacio de trabajo adjunto a este issue.", "bf43425540": "Comentario", "0a73f59e85": "Enviar comentario", "c5c117270e": "Añade un comentario…", "082515176a": "No se pudo agregar el comentario", - "c6f37a563d": "+ Cesionario", + "c6f37a563d": "+ Asignado", "f41ec96c13": "+ Etiqueta", "ab050dffec": "Cerrado", "dc1ca081a8": "Abierto", @@ -721,9 +722,9 @@ "886a64b081": "Ninguno todavía", "4ba0132f37": "Editar etiquetas", "217e55d87c": "Etiquetas", - "c67de9e2fe": "nadie asignado", + "c67de9e2fe": "Nadie asignado", "76adcf5fe2": "Editar asignados", - "83ac703dda": "Cesionarios", + "83ac703dda": "Asignados", "00ccdf9b5a": "Estado", "2aa9acdf34": "Editar etiquetas en GitHub", "d23bbb6416": "{{value0}} omitido", @@ -731,54 +732,54 @@ "b1ac991806": "{{value0}} fallando", "checksNeedActionChip": "{{value0}} requiere acción", "311d0cee55": "{{value0}} pasando", - "e52bed9264": "Aún no se han reportado controles", - "90020cc1f3": "Esta solicitud de extracción aún no tiene comprobaciones reportadas.", - "ecffebc251": "No se encontraron cheques", + "e52bed9264": "Aún no se reportaron checks", + "90020cc1f3": "Este pull request aún no tiene checks reportados.", + "ecffebc251": "No se encontraron checks", "5dddefdf58": "Abrir en GitHub", - "744197c84d": "No hay salida en línea disponible para esta verificación.", - "08d072664d": "Empleos", + "744197c84d": "No hay output inline disponible para este check.", + "08d072664d": "Jobs", "96d8f36798": "Anotaciones", - "485609c4f2": "controlar #", - "0f478f5efa": "Terminado", - "4812814bc8": "Comenzó", + "485609c4f2": "check #", + "0f478f5efa": "Completado", + "4812814bc8": "Iniciado", "9c3ba11a05": "Estado:", - "934d87ab96": "Cargando detalles del cheque...", - "71c11aff84": "Vuelva a ejecutar todas las comprobaciones", - "e31651a224": "Volver a ejecutar comprobaciones fallidas", - "1b56e28faa": "Repetición", - "f4b1292569": "Inicie el agente de IA predeterminado en estas comprobaciones", - "9a1004fc76": "Actualizar cheques", - "03e542fcfe": "No se pudo iniciar un agente de IA para los cheques rotos: {{value0}}", - "28986b3747": "Inició un agente de inteligencia artificial para los cheques rotos.", - "1690fd7f4a": "No hay cheques rotos que arreglar.", - "9e7c221b8d": "No se pudieron volver a ejecutar las comprobaciones", - "e463ec935f": "Verificar repeticiones solicitadas", - "ddafe851e1": "Se solicita volver a ejecutar el cheque", - "0bbdc673c1": "No se pudieron actualizar las comprobaciones", - "e7007aa1d8": "No se pueden actualizar las comprobaciones sin una ruta al repositorio.", + "934d87ab96": "Cargando detalles del check...", + "71c11aff84": "Volver a ejecutar todos los checks", + "e31651a224": "Volver a ejecutar checks fallidos", + "1b56e28faa": "Volver a ejecutar", + "f4b1292569": "Iniciar el agente de IA predeterminado en estos checks", + "9a1004fc76": "Actualizar checks", + "03e542fcfe": "No se pudo iniciar un agente de IA para los checks fallidos: {{value0}}", + "28986b3747": "Se inició un agente de IA para los checks fallidos.", + "1690fd7f4a": "No hay checks fallidos que arreglar.", + "9e7c221b8d": "No se pudieron volver a ejecutar los checks", + "e463ec935f": "Se solicitó volver a ejecutar los checks", + "ddafe851e1": "Se solicitó volver a ejecutar el check", + "0bbdc673c1": "No se pudieron actualizar los checks", + "e7007aa1d8": "No se pueden actualizar los checks sin una ruta al repositorio.", "675bc0d638": "Cancelar", "a18f669c7a": "{{value0}} {{value1}} reacción{{value2}}", - "53fe19aefc": "Abrir el cuadro de combinación de GitHub", - "a2495e4784": "Solicitud de extracción", - "ce360fc318": "No se pudo deshabilitar la combinación automática", - "825a8fb8cd": "No se pudo habilitar la combinación automática", - "4b390bd50d": "Fusión automática deshabilitada", - "a35ea5a0f6": "Combinación automática habilitada", - "aba792c8b3": "No se pudo fusionar la solicitud de extracción", - "dbe5e2448e": "Solicitud de extracción fusionada", - "a27ee5ca1a": "Esto actualizará la solicitud de extracción en GitHub.", + "53fe19aefc": "Abrir el cuadro de merge de GitHub", + "a2495e4784": "Pull request", + "ce360fc318": "No se pudo deshabilitar auto-merge", + "825a8fb8cd": "No se pudo habilitar auto-merge", + "4b390bd50d": "Auto-merge deshabilitado", + "a35ea5a0f6": "Auto-merge habilitado", + "aba792c8b3": "No se pudo hacer merge del pull request", + "dbe5e2448e": "Pull request mergeado", + "a27ee5ca1a": "Esto actualizará el pull request en GitHub.", "03d7216d62": "{{value0}} PR #{{value1}}?", "e9b7cb7d17": "No se pudo {{value0}} PR", - "bd3b4492a0": "Solicitud de extracción reabierta", - "9f88657c4e": "Solicitud de extracción cerrada", - "b6f1b7adbd": "Esto reabrirá la solicitud de extracción en GitHub.", - "de45fedf7b": "Esto cerrará la solicitud de extracción en GitHub.", + "bd3b4492a0": "Pull request reabierto", + "9f88657c4e": "Pull request cerrado", + "b6f1b7adbd": "Esto reabrirá el pull request en GitHub.", + "de45fedf7b": "Esto cerrará el pull request en GitHub.", "5a94f3d0e9": "Aún no hay comentarios.", "1506916c09": "Comentarios", - "9b9cb55994": "No se proporciona descripción.", + "9b9cb55994": "No se proporcionó descripción.", "52b20b56f7": "Descripción", "4d555d3796": "Editar descripción", - "9df4e74bdf": "Ahorrar", + "9df4e74bdf": "Guardar", "0ae387d8ca": "por", "228e2f59d3": "Resuelto", "a154ec5224": "Abrir comentario en GitHub", @@ -788,62 +789,62 @@ "745c9089ec": "No se puede responder sin una ruta al repositorio.", "58c73cb0d8": "No se pudo actualizar la descripción.", "5221548274": "Descripción actualizada.", - "06c06e58ba": "Mostrar más líneas a continuación", + "06c06e58ba": "Mostrar más líneas abajo", "307c98e8e3": "Mostrar {{value0}} más líneas arriba", "5664681624": "Mostrar más líneas arriba", - "b1574e8ac2": "Restablecer el contexto del código", + "b1574e8ac2": "Restablecer contexto de código", "d43736d09c": "Controles de contexto de código", - "bd7be7b1fd": "comentario l", + "bd7be7b1fd": "comentario L", "db61d76cd5": "Cargando contexto de código...", "f2d02cdf8c": "archivos vistos", "1257d1435d": "Mostrar árbol de archivos", "a341343303": "Se agregó el comentario de revisión.", - "d1fa2cf888": "No se puede comentar sin el jefe de PR SHA.", - "829674460a": "La diferencia no está disponible porque faltan los SHA de commit de PR.", + "d1fa2cf888": "No se puede comentar sin el SHA del head del PR.", + "829674460a": "El diff no está disponible porque faltan los SHA de commits del PR.", "af924014f8": "Visto", - "2d89a38d9d": "{{value0}} {{value1}} tal como se ve", + "2d89a38d9d": "Cambiar estado de visto de {{value1}} ({{value0}})", "70e84e3d0b": "No hay revisores que coincidan.", "1ffce94a8b": "Todos los demás", "c2b21818e1": "Sugerencias", "a98433e73d": "Cargando...", - "b0b7344684": "Solicite hasta 15 revisores", - "934add88b6": "Crítico", + "b0b7344684": "Solicita hasta 15 revisores", + "934add88b6": "Revisor", "bb42774171": "Escribe o elige un usuario", "36f9ac4a47": "No se solicitaron revisores.", "8b15a5e91c": "Eliminar revisor {{value0}}", "6a45771d47": "Cargando revisores", "dc8a092c57": "Revisores", - "e3243d9376": "Editados recientemente estos archivos", + "e3243d9376": "Editó estos archivos recientemente", "8c45901789": "Solicitar revisor {{value0}}", - "fedc09eeb9": "Revisor no solicitado {{value0}}", + "fedc09eeb9": "Cancelar solicitud de revisor {{value0}}", "73487fb975": "No se pudo eliminar al revisor", "2e69540652": "Revisores eliminados", "69515bff81": "Revisor eliminado", - "b4af16bf43": "No hay contexto de repo disponible para esta solicitud de extracción.", + "b4af16bf43": "No hay contexto de repositorio disponible para este pull request.", "c42d942b75": "No se pudo solicitar el revisor", "c016e4bac3": "Revisores solicitados", "ea985e657f": "Revisor solicitado", "12e761610e": "Puedes solicitar hasta 15 revisores", - "94ab23a9f9": "Introduzca un revisor", + "94ab23a9f9": "Ingresa un revisor", "3853476a97": "elemento de GitHub", - "68796dafa0": "pr", + "68796dafa0": "PR", "b1157c78ff": "hoja", "038b3d39b1": "copiado", - "04539beb48": "asunto", + "04539beb48": "issue", "773ff70035": "desconocido", - "3e544d966d": "Asunto", + "3e544d966d": "Issue", "88fd82474d": "página", "e517b4d641": "cerrado", "d0a05e73f5": "compacto", "7d42606f66": "Anotación", - "2511f44bb7": "Arreglar cheques rotos", - "9157d48ddb": "Arreglar cheques", - "06482d6190": "No se puede crear un espacio de trabajo de reparación automáticamente.", + "2511f44bb7": "Arreglar checks fallidos", + "9157d48ddb": "Arreglar checks", + "06482d6190": "No se pudo crear automáticamente un espacio de trabajo de reparación.", "f64dd90102": "Responder", - "5752c25aff": "Destino…", + "5752c25aff": "Publicando…", "ec5c4b3ab2": "Reabrir PR", - "21860b58d0": "Cerrar solicitud de extracción", - "5932578f51": "La fusión requiere un repo local registrado", + "21860b58d0": "Cerrar pull request", + "5932578f51": "El merge requiere un repositorio local registrado", "ce8a85d209": "por defecto", "924c2fe05e": "destructivo", "e2bf3e41a9": "comentario", @@ -853,56 +854,56 @@ "136542c9ba": ":L{{value0}}", "283699bc82": "No se pudo publicar la respuesta.", "d1c0dad471": "-L{{value0}}", - "31770bef03": "Juntos", + "31770bef03": "Lado a lado", "6e43a16435": "En línea", "d00a0a7f8f": "Contraer todo", "3c19ec3069": "Expandir todo", "b0b09778c8": "No se pudo agregar el comentario de revisión.", "16c1abe76c": "Marcar visto", "ba8e329d92": "Desmarcar visto", - "3f79ffc8b7": "Abra los detalles de PR para ver los revisores actuales.", + "3f79ffc8b7": "Abre los detalles del PR para ver los revisores actuales.", "5c1c973855": "Eliminar revisor", "commentTooLarge": "El comentario es demasiado grande para enviarlo de forma segura.", "timeline": { - "completed": "as completed", - "notPlanned": "as not planned", - "someone": "someone", - "assigned": "assigned", - "unassigned": "unassigned", - "mentioned": "mentioned this", - "in": "in", - "closed": "closed this", - "reopened": "reopened this", - "moved": "moved this", - "from": "from", - "to": "to", - "activity": "Activity", - "noActivity": "No activity yet." + "completed": "como completado", + "notPlanned": "como no planificado", + "someone": "alguien", + "assigned": "asignó a", + "unassigned": "quitó a", + "mentioned": "mencionó esto", + "in": "en", + "closed": "cerró esto", + "reopened": "reabrió esto", + "moved": "movió esto", + "from": "de", + "to": "a", + "activity": "Actividad", + "noActivity": "Aún no hay actividad." }, - "checkActionRequiredHint": "This check needs a manual action on GitHub (for example, approving the workflow run) before merging is unblocked." + "checkActionRequiredHint": "Este check requiere una acción manual en GitHub (por ejemplo, aprobar la ejecución del workflow) antes de desbloquear el merge." }, "GitLabItemDialog": { "65e784c1f1": "Reabrir", - "a199eb364b": "Cerca", - "16b3412570": "Unir", + "a199eb364b": "Cerrar", + "16b3412570": "Merge", "131865e231": "Crear espacio de trabajo", "f2e64d1c20": "Abrir en GitLab", - "84012fa8fb": "Comentario", + "84012fa8fb": "Comentar", "c08e1d5a57": "Comentar en {{value0}}{{value1}}…", - "f11e3e7675": "No hay tuberías para este MR.", + "f11e3e7675": "No hay ejecuciones de pipeline para este MR.", "808b1ca1ba": "No hay archivos modificados.", - "007423f585": "Contenido diferencial no disponible.", - "a7eb4f4916": "de", - "21f8dde18a": "comentario en línea", + "007423f585": "El contenido del diff no está disponible.", + "a7eb4f4916": "desde", + "21f8dde18a": "Comentario inline", "7a7204417f": "Línea", "ceb08a733d": "Archivo", "85a8170279": "Aún no hay comentarios.", "14423484db": "Sin descripción.", "da4174b00f": "Editar", - "93f79a3fc1": "Ahorrar", + "93f79a3fc1": "Guardar", "f72fad3b16": "Cancelar", "717b706849": "Cargando etiquetas", - "3c0b6ccca7": "error, backend", + "3c0b6ccca7": "bug, backend", "dde24ade55": "Etiquetas", "908d8d2a73": "Descripción", "89f3f19368": "Título", @@ -910,75 +911,75 @@ "05939e977d": "Agregar revisor", "474b50d988": "Sin revisores.", "1b19cdc510": "Eliminar revisor {{value0}}", - "cb55b0390f": "Administrar", + "cb55b0390f": "Gestionar", "4f9313984d": "Revisores", - "02cbe2de44": "Tubería", + "02cbe2de44": "Pipeline", "be3d291837": "Archivos", "c996e2962c": "Conversación", - "b3c156dd51": "Refrescar", + "b3c156dd51": "Actualizar", "9bfb4a24d7": "por", - "30c97083c2": "Detalle del elemento de trabajo de GitLab", - "e089f62594": "¡MR fusionado! {{value0}}", - "865ea2703e": "Reabierto MR!{{value0}}", - "9b11cd233f": "Cerrado SEÑOR !{{value0}}", - "60c13320c4": "Se agregó comentario en línea", - "ffdd9a78e1": "Las referencias de diferencias de MR no están disponibles para comentarios en línea.", + "30c97083c2": "Detalle del work item de GitLab", + "e089f62594": "MR !{{value0}} mergeado", + "865ea2703e": "MR !{{value0}} reabierto", + "9b11cd233f": "MR !{{value0}} cerrado", + "60c13320c4": "Comentario inline agregado", + "ffdd9a78e1": "Las diff refs del MR no están disponibles para comentarios inline.", "00d0d25825": "Se requieren archivo, línea y comentario.", - "ceaf7c30c7": "La identificación del revisor no está disponible para este usuario de GitLab.", - "f7cb495a12": "Reintentado {{value0}}", - "98718490e4": "Se requiere título de MR.", - "d600c2619a": "Cargando registro", - "028bde664e": "Esconder", - "2f9b27f838": "Registro de trabajo", - "032ae1312b": "Trabajo abierto en GitLab", - "fa3e042203": "Rever", + "ceaf7c30c7": "El ID del revisor no está disponible para este usuario de GitLab.", + "f7cb495a12": "Se reintentó {{value0}}", + "98718490e4": "El título del MR es obligatorio.", + "d600c2619a": "Cargando log", + "028bde664e": "Ocultar", + "2f9b27f838": "Log del job", + "032ae1312b": "Abrir job en GitLab", + "fa3e042203": "Reintentar", "f23ea85341": "resuelto", "4186685c78": "reabrir", - "cae2712a23": "cerca", - "881e522e04": "unir", - "6de8ce0cc6": "{{value0}} requerido", + "cae2712a23": "cerrar", + "881e522e04": "merge", + "6de8ce0cc6": "{{value0}} obligatorias", "22511537d2": "Aprobado", - "00f3bab87b": "de {{value0}} requerido", + "00f3bab87b": " de {{value0}} obligatorias", "11384f99aa": "número", "40c56b95e2": "{{value0}} aprobación{{value1}} restante", - "3a051b8ade": "Elemento de trabajo", - "32f8bef818": "Sin salida de registro.", + "3a051b8ade": "Work item", + "32f8bef818": "Sin output de log.", "4168eb2c51": "Resolver", "commentTooLarge": "El comentario es demasiado grande para enviarlo de forma segura." }, "JiraIssueWorkspace": { - "b0b92666c9": "Comentario", - "a585fd204e": "Añade un comentario de Jira...", + "b0b92666c9": "Comentar", + "a585fd204e": "Agregar un comentario de Jira...", "2441be6f9f": "Iniciar espacio de trabajo", "9178090e26": "Aún no hay comentarios.", - "5cd09beaf9": "Rever", + "5cd09beaf9": "Reintentar", "9a980b06b9": "Comentarios", - "c4889a47e4": "No se proporciona descripción.", - "0f3c07a901": "backend, error", + "c4889a47e4": "No se proporcionó descripción.", + "0f3c07a901": "backend, bug", "aee97b6913": "Etiquetas", "444865b4a8": "Título", - "0b6b5646ed": "No asignado", + "0b6b5646ed": "Sin asignar", "51bed73f88": "Sin prioridad", - "7a96985ca0": "Cerca", - "76513c7898": "Cerrar vista previa del problema de Jira", - "857bd2f88f": "Obtenga una vista previa, edite y comience a trabajar desde el número seleccionado.", - "0cc62bd690": "Copiar mensaje", - "80efa101c5": "Copiar el nombre de la sucursal sugerida", + "7a96985ca0": "Cerrar", + "76513c7898": "Cerrar vista previa del issue de Jira", + "857bd2f88f": "Previsualiza, edita y empieza a trabajar desde el issue seleccionado.", + "0cc62bd690": "Copiar prompt", + "80efa101c5": "Copiar nombre de rama sugerido", "38839801e8": "Copiar clave", "779bb91ee0": "Copiar URL", - "69da9a208c": "Abierto en Jira", + "69da9a208c": "Abrir en Jira", "fa132c8aed": "No se pudo agregar el comentario.", - "ea21952aa3": "No se pudo actualizar el problema de Jira.", + "ea21952aa3": "No se pudo actualizar el issue de Jira.", "6c41a9bcea": "No se pudo copiar {{value0}}", "2ff69a3545": "{{value0}} copiado", "666cfdd835": "Desconocido", "9ebee71962": "etiquetas", "def3d0e824": "título", - "b8e2079d96": "cesionario", - "54649eaeab": "+ Cesionario", + "b8e2079d96": "asignado", + "54649eaeab": "+ Asignado", "2a829a2f00": "prioridad", "693be070d0": "transición", - "ef21405c6d": "Problema con Jira", + "ef21405c6d": "Issue de Jira", "commentTooLarge": "El comentario es demasiado grande para enviarlo de forma segura." }, "Landing": { @@ -988,15 +989,15 @@ "6ca6ff404e": "ORCA", "520304a067": "logotipo de Orca", "ce44fad849": "Dependencias faltantes", - "c1cf168479": "Esconder", - "00cee697c1": "Ejecute \"gh auth login\" en una terminal para conectar su cuenta de GitHub.", + "c1cf168479": "Ocultar", + "00cee697c1": "Ejecuta \"gh auth login\" en una terminal para conectar tu cuenta de GitHub.", "9f96d018b7": "La CLI de GitHub no está autenticada", - "73e1ad4282": "Orca utiliza la CLI de GitHub (gh) para mostrar solicitudes de extracción, problemas y comprobaciones.", + "73e1ad4282": "Orca usa GitHub CLI (gh) para mostrar pull requests, issues y checks.", "5beaef5f9e": "La CLI de GitHub no está instalada", "b673e7cf1b": "Se requiere Git para proyectos Git, control de código fuente y gestión del espacio de trabajo.", - "e5b7296d9d": "git no está instalado", - "cd21242762": "Agregue un proyecto para comenzar.", - "9c00bd4adf": "Seleccione un espacio de trabajo de la barra lateral para comenzar.", + "e5b7296d9d": "Git no está instalado", + "cd21242762": "Agrega un proyecto para comenzar.", + "9c00bd4adf": "Selecciona un espacio de trabajo en la barra lateral para comenzar.", "16e9e3df89": "con estrella", "0d0ace8861": "Dar estrella en GitHub", "ec43b38ba7": "Con estrella en GitHub", @@ -1005,49 +1006,49 @@ }, "LinearIssueMarkdownDescriptionEditor": { "d9c47069ef": "Markdown", - "a7301a11f3": "ahorrar", + "a7301a11f3": "guardar", "632096eb1c": "Enlace", "340160f4e8": "Quitar enlace", "9eaf02ac01": "Cita", "e2a0267c8c": "Lista de verificación", - "d6b2f3d35b": "lista numerada", - "c82917e06e": "lista de viñetas", - "ad1869bd54": "código en línea", - "28fd951b83": "Huelga", - "5666b4493d": "Itálico", - "caa88f50d0": "Atrevido", - "dddaa7a0a6": "Título 2", - "e3f741d258": "Título 1", + "d6b2f3d35b": "Lista numerada", + "c82917e06e": "Lista con viñetas", + "ad1869bd54": "Código inline", + "28fd951b83": "Tachado", + "5666b4493d": "Cursiva", + "caa88f50d0": "Negrita", + "dddaa7a0a6": "Encabezado 2", + "e3f741d258": "Encabezado 1", "68a41d5665": "Texto del cuerpo", - "7c52151156": "Formato de descripción del problema", + "7c52151156": "Formato de descripción del issue", "5c16ec8f14": "URL del enlace", - "4f2fddc2b7": "No se proporciona descripción." + "4f2fddc2b7": "No se proporcionó descripción." }, "LinearIssueTextEditor": { "947ba2d6f4": "guardar", - "04d73b72dc": "Título del problema", + "04d73b72dc": "Título del issue", "e8ff595db3": "No se pudo actualizar {{value0}}", - "1e08a1ec80": "Se requiere título", + "1e08a1ec80": "El título es obligatorio", "00fa439dc7": "título", "75294b07d1": "descripción" }, "LinearIssueWorkspace": { - "ad5dec37b7": "Obtenga una vista previa, edite y comience a trabajar desde el número seleccionado.", - "c23e79e5c0": "Comportamiento", - "b0eac92d85": "Rever", - "fabbd3f974": "actualizó el problema ·", + "ad5dec37b7": "Previsualiza, edita y empieza a trabajar desde el issue seleccionado.", + "c23e79e5c0": "Acciones", + "b0eac92d85": "Reintentar", + "fabbd3f974": "actualizó el issue ·", "543970c87a": "Actividad", - "df4c86ed12": "Cerca", - "7a4997d8bb": "Cerrar vista previa del problema Linear", + "df4c86ed12": "Cerrar", + "7a4997d8bb": "Cerrar vista previa del issue de Linear", "e1e0a9bca9": "Iniciar espacio de trabajo", - "30a7f56c0a": "Iniciar espacio de trabajo desde el problema", + "30a7f56c0a": "Iniciar espacio de trabajo desde el issue", "30c1242f3a": "Copiar identificador", - "9e3c49beb8": "Copiar identificador de problema", + "9e3c49beb8": "Copiar identificador del issue", "9a9a884236": "Copiar URL", - "97c19a84f1": "Copiar URL Linear", - "f63ef94ea8": "Asuntos", - "f6c6381593": "Copiar mensaje", - "5d670ec8dc": "Copiar el nombre de la sucursal sugerida", + "97c19a84f1": "Copiar URL de Linear", + "f63ef94ea8": "Issues", + "f6c6381593": "Copiar prompt", + "5d670ec8dc": "Copiar nombre de rama sugerido", "937ba6ad9a": "Cargando proyectos", "db3f269d98": "Buscar proyectos", "b51276c8d6": "Proyecto", @@ -1055,30 +1056,30 @@ "f9d4ef9807": "Proyecto actualizado", "38b80780c2": "No se pudieron cargar los proyectos", "42589845bc": "Crear", - "c182e02de5": "Título del subtema", - "8c55d6696a": "Agregar subtemas", - "b25e453c9d": "No se pudo crear el subproblema", - "aeed19d003": "Creado {{value0}}", - "9a1317cdd3": "No se pudo cargar el subproblema", + "c182e02de5": "Título del sub-issue", + "8c55d6696a": "Agregar sub-issues", + "b25e453c9d": "No se pudo crear el sub-issue", + "aeed19d003": "Se creó {{value0}}", + "9a1317cdd3": "No se pudo cargar el sub-issue", "9bcbaa2737": "No se pudo copiar {{value0}}", "7835483c43": "{{value0}} copiado", - "61f424f8ca": "Cuestión Linear", + "61f424f8ca": "Issue de Linear", "ca8778c124": "Desconocido", "8a33c85e9c": "Alguien", "f5a6b38a14": "hoja", "65239a714b": "Linear", "af6e02c44a": "página", - "76ffd3c937": "Busque un proyecto para agregar.", + "76ffd3c937": "Busca un proyecto para agregar.", "c11b4e3cc2": "No se encontraron proyectos.", - "519c3587f3": "Añadir al proyecto" + "519c3587f3": "Agregar al proyecto" }, "LinearItemDrawer": { - "04008e6c46": "Iniciar espacio de trabajo desde el problema", + "04008e6c46": "Iniciar espacio de trabajo desde el issue", "a4fcc57522": "Aún no hay comentarios.", "fde849b2b6": "Comentarios", "9dc54172db": "Cerrar · Esc", - "0190b760c1": "Abierto en Linear", - "04a442f796": "Obtenga una vista previa y edite la edición Linear seleccionada.", + "0190b760c1": "Abrir en Linear", + "04a442f796": "Previsualiza y edita el issue de Linear seleccionado.", "d369841269": "Enviar comentario", "2fcff829a8": "Añade un comentario…", "2820f0f0f0": "Deja un comentario...", @@ -1088,31 +1089,31 @@ "23886c7eec": "Agregar etiqueta", "7f7b89b631": "Etiquetas: {{value0}}", "b2376d0179": "Cargando miembros", - "866316f22c": "No asignado", - "b5675b0694": "Ahorrar", - "ceeb8c6153": "Claro", - "fbb90300e2": "Presupuesto personalizado", + "866316f22c": "Sin asignar", + "b5675b0694": "Guardar", + "ceeb8c6153": "Limpiar", + "fbb90300e2": "Estimación personalizada", "780ea6ed89": "No se encontraron estados", - "59b6cd3706": "Estados de carga", + "59b6cd3706": "Cargando estados", "64bfffc4dd": "Etiquetas", "dd304de85a": "Propiedades", "0be31fef8e": "La estimación debe ser un número entero no negativo", "48e17e8cbd": "Desconocido", "858d0630da": "Cerrar vista previa", - "39883467f4": "Cuestión Linear", + "39883467f4": "Issue de Linear", "fda549766e": "{{value0}} para comentar", - "d71cd3003e": "+ Cesionario", + "d71cd3003e": "+ Asignado", "commentTooLarge": "El comentario es demasiado grande para enviarlo de forma segura." }, "NewWorkspaceComposerCard": { "cbb47ee0dc": "Solo disponible para proyectos Git locales.", - "d861de981b": "Pago escaso", - "090cfedeb4": "escribe una nota", + "d861de981b": "Checkout sparse", + "090cfedeb4": "Escribe una nota", "f8728aa4f9": "Nota", "0ee17638fe": "Nombre del espacio de trabajo", "2688050e4b": "Nombre", "f0470c7383": "Avanzado", - "ba64270bdb": "Configurar agents", + "ba64270bdb": "Configurar agentes", "ab63f25397": "Abrir configuración del agente", "01d1e8f601": "Agente", "0c5d6a479c": "[Opcional]", @@ -1122,9 +1123,9 @@ "969a8bff66": "Proyecto", "23bb365554": "orca.yaml", "a239038146": "Error de conexión SSH", - "9a70e4859e": "Elija si desea ejecutar la instalación antes de crear este espacio de trabajo.", - "803b7fe72f": "Comprobando la configuración de instalación...", - "92e34f0311": "configuraciones locales", + "9a70e4859e": "Elige si quieres ejecutar el setup antes de crear este espacio de trabajo.", + "803b7fe72f": "Verificando configuración de setup...", + "92e34f0311": "ajustes locales", "326a578923": "orca.yaml + local", "2132b670da": "ambos", "0e587e31fb": "yaml", @@ -1132,7 +1133,7 @@ "f660aa1454": "Conectando", "7711ad5122": "Comando de configuración local", "e5db1b0419": "Comando de configuración combinado", - "addProjectBeforeWorkspace": "Agregue un proyecto antes de crear un espacio de trabajo.", + "addProjectBeforeWorkspace": "Agrega un proyecto antes de crear un espacio de trabajo.", "connectProjectFirst": "Conecta este proyecto primero", "connectSourceLookup": "Conectar", "reconnectSourceLookup": "Volver a conectar", @@ -1160,20 +1161,22 @@ "importHostSetup": "Importar", "notePasteTooLarge": "El contenido pegado es demasiado grande para el campo de nota.", "reuseExistingBranch": "Reutilizar rama", - "reuseExistingBranchHint": "Hacer checkout de la rama existente en lugar de crear una nueva a partir de ella.", + "reuseExistingBranchHint": "Haz checkout de la rama existente en lugar de crear una nueva a partir de ella.", "createMultiple": "Crear más", - "waitForSetupBeforeAgent": "Espere a que se complete la configuración antes de iniciar el agent", - "waitForSetupBeforeAgentHelp": "Active esto cuando el programa de instalación instale dependencias, servidores MCP o archivos de configuración que el agent necesite durante el inicio.", - "destroyDisabled": "destruir discapacitados", - "destroyConfigured": "destruir configurado", - "noDestroyConfigured": "no destruir", + "waitForSetupBeforeAgent": "Esperar a que termine el setup antes de iniciar el agente", + "waitForSetupBeforeAgentHelp": "Activa esto cuando el setup instale dependencias, servidores MCP o archivos de configuración que el agente necesita al iniciar.", + "destroyDisabled": "destroy deshabilitado", + "destroyConfigured": "destroy configurado", + "noDestroyConfigured": "sin destroy", "ephemeralVm": "Entorno por espacio de trabajo", - "chooseRunTarget": "Elige objetivo", - "noRunTargets": "No hay objetivos de ejecución listos para este proyecto.", - "perWorkspaceEnvHint": "Proporcione un entorno bajo demanda a partir de una receta" + "chooseRunTarget": "Elegir destino", + "noRunTargets": "No hay destinos de ejecución listos para este proyecto.", + "perWorkspaceEnvHint": "Provisiona un entorno bajo demanda a partir de una receta", + "branchName": "Nombre de rama", + "branchNamePlaceholder": "feature/my-branch" }, "NewWorkspaceComposerModal": { - "createWorktree": "Crear árbol de trabajo", + "createWorktree": "Crear worktree", "createWorkspace": "Crear espacio de trabajo", "fa90f739a5": "Elija el proyecto, el nombre del espacio de trabajo y el agente antes de crear el espacio de trabajo." }, @@ -1182,13 +1185,13 @@ "3450247584": "Comentario", "6ad2c1ab9c": "No se cambiaron archivos.", "4d18310d55": "Archivos cambiados", - "94d95cf1f7": "cheques", + "94d95cf1f7": "Checks", "9e8d45700e": "Conversación", "e6996f4024": "· actualizado", - "00b7b82329": "rama principal", + "00b7b82329": "rama head", "e1f3641bfd": "de", "c44b70352b": "rama base", - "b0e80f083d": "quiere fusionarse en", + "b0e80f083d": "quiere hacer merge en", "8ecda455a0": "Abrir en GitHub", "1a2570e18e": "Iniciar nuevo espacio de trabajo", "57c13a5aa4": "Más acciones en el espacio de trabajo de PR", @@ -1196,15 +1199,15 @@ "a459866967": "Reanudar espacio de trabajo adjunto a PR", "347034903a": "Copiar enlace de GitHub", "5a01ca7253": "No se pudo sincronizar el estado visto con GitHub.", - "996a1897d2": "No se puede sincronizar el estado visto para esta solicitud de extracción.", + "996a1897d2": "No se puede sincronizar el estado visto de este pull request.", "e0b15c793f": "No se pudo copiar el enlace de GitHub", "992e799227": "Enlace de GitHub copiado", - "61bfc81ada": "No se puede abrir el espacio de trabajo adjunto a esta solicitud de extracción.", + "61bfc81ada": "No se puede abrir el espacio de trabajo adjunto a este pull request.", "161d91ef02": "Enviar comentario", "d2030fc8cd": "Añade un comentario…", "1208347ac0": "No se pudo agregar el comentario", - "61452f2143": "Iniciar espacio de trabajo desde el problema", - "14c9fc70ed": "+ Cesionario", + "61452f2143": "Iniciar espacio de trabajo desde el issue", + "14c9fc70ed": "+ Asignado", "bc215fea4d": "+ Etiqueta", "b936cc51a4": "Cerrado", "7b8f6bf6d8": "Abierto", @@ -1213,56 +1216,56 @@ "ae2a34c7b8": "{{value0}} fallando", "checksNeedActionChip": "{{value0}} requiere acción", "7c5035931a": "{{value0}} pasando", - "a18d01cda3": "Aún no se han reportado controles", - "3912daf310": "Esta solicitud de extracción aún no tiene comprobaciones reportadas.", - "45877f5089": "No se encontraron cheques", - "85e62c5266": "Inició un agente de inteligencia artificial para los cheques rotos.", - "ddfd42f460": "Revise el mensaje antes de iniciar un agente.", - "a053bdd082": "Arreglar cheques rotos con IA", + "a18d01cda3": "Aún no se reportaron checks", + "3912daf310": "Este pull request aún no tiene checks reportados.", + "45877f5089": "No se encontraron checks", + "85e62c5266": "Se inició un agente de IA para los checks fallidos.", + "ddfd42f460": "Revisa el prompt antes de iniciar un agente.", + "a053bdd082": "Arreglar checks fallidos con IA", "1b14d0a69c": "Abrir en GitHub", - "1550675e5f": "No hay salida en línea disponible para esta verificación.", - "7720c9c3f5": "Empleos", + "1550675e5f": "No hay output inline disponible para este check.", + "7720c9c3f5": "Jobs", "8432d17901": "Anotaciones", - "f01bf79a79": "controlar #", - "000f90afcf": "Terminado", - "76551b1161": "Comenzó", + "f01bf79a79": "check #", + "000f90afcf": "Completado", + "76551b1161": "Iniciado", "662bc2998d": "Estado:", - "d8e82b7f15": "Cargando detalles del cheque...", - "54cddd1858": "Vuelva a ejecutar todas las comprobaciones", - "68605516dd": "Volver a ejecutar comprobaciones fallidas", - "522d9353e1": "Repetición", - "0fa8b8faec": "Inicie el agente de IA predeterminado en estas comprobaciones", - "5d0f42766d": "Actualizar cheques", - "98583589c6": "No se pudo iniciar un agente de IA para los cheques rotos: {{value0}}", - "51c65c0265": "No hay cheques rotos que arreglar.", - "788a782bb0": "No se pudieron volver a ejecutar las comprobaciones", - "18f2af42ac": "Verificar repeticiones solicitadas", - "5963a6a852": "Se solicita volver a ejecutar el cheque", - "246b2c6456": "No se pudieron actualizar las comprobaciones", - "c057f2fcb0": "No se pueden actualizar las comprobaciones sin una ruta al repositorio.", + "d8e82b7f15": "Cargando detalles del check...", + "54cddd1858": "Volver a ejecutar todos los checks", + "68605516dd": "Volver a ejecutar checks fallidos", + "522d9353e1": "Volver a ejecutar", + "0fa8b8faec": "Iniciar el agente de IA predeterminado en estos checks", + "5d0f42766d": "Actualizar checks", + "98583589c6": "No se pudo iniciar un agente de IA para los checks fallidos: {{value0}}", + "51c65c0265": "No hay checks fallidos que arreglar.", + "788a782bb0": "No se pudieron volver a ejecutar los checks", + "18f2af42ac": "Se solicitó volver a ejecutar los checks", + "5963a6a852": "Se solicitó volver a ejecutar el check", + "246b2c6456": "No se pudieron actualizar los checks", + "c057f2fcb0": "No se pueden actualizar los checks sin una ruta al repositorio.", "6591b1fa82": "Cancelar", "42c36d9166": "{{value0}} {{value1}} reacción{{value2}}", - "7df8d5fc60": "Abrir el cuadro de combinación de GitHub", - "1939d0f663": "Solicitud de extracción", - "973ef2fac9": "No se pudo deshabilitar la combinación automática", - "d31f4b508c": "No se pudo habilitar la combinación automática", - "0f5821b035": "Fusión automática deshabilitada", - "5edbe7eefa": "Combinación automática habilitada", - "aae645d36d": "No se pudo fusionar la solicitud de extracción", - "c57873d721": "Solicitud de extracción fusionada", - "a63b3c159c": "Esto actualizará la solicitud de extracción en GitHub.", + "7df8d5fc60": "Abrir el cuadro de merge de GitHub", + "1939d0f663": "Pull request", + "973ef2fac9": "No se pudo deshabilitar auto-merge", + "d31f4b508c": "No se pudo habilitar auto-merge", + "0f5821b035": "Auto-merge deshabilitado", + "5edbe7eefa": "Auto-merge habilitado", + "aae645d36d": "No se pudo hacer merge del pull request", + "c57873d721": "Pull request mergeado", + "a63b3c159c": "Esto actualizará el pull request en GitHub.", "eec3706a6a": "{{value0}} PR #{{value1}}?", "b8c6cbb8c4": "No se pudo {{value0}} PR", - "710e47aa06": "Solicitud de extracción reabierta", - "7aa3b5f706": "Solicitud de extracción cerrada", - "3d77438c92": "Esto reabrirá la solicitud de extracción en GitHub.", - "5a65651096": "Esto cerrará la solicitud de extracción en GitHub.", + "710e47aa06": "Pull request reabierto", + "7aa3b5f706": "Pull request cerrado", + "3d77438c92": "Esto reabrirá el pull request en GitHub.", + "5a65651096": "Esto cerrará el pull request en GitHub.", "d2d589556c": "Aún no hay comentarios.", "3463d10a63": "Comentarios", - "c8ea6c7c4c": "No se proporciona descripción.", + "c8ea6c7c4c": "No se proporcionó descripción.", "778683ec84": "Descripción", "da9aaa8bcf": "Editar descripción", - "4a337ac05f": "Ahorrar", + "4a337ac05f": "Guardar", "169a93b29a": "actualizado", "3c891789f6": "por", "f4fe47c2bb": "Resuelto", @@ -1273,60 +1276,60 @@ "6885c619e7": "No se puede responder sin una ruta al repositorio.", "d94810f652": "No se pudo actualizar la descripción.", "9b4190dc98": "Descripción actualizada.", - "51ed0cf38b": "Mostrar más líneas a continuación", + "51ed0cf38b": "Mostrar más líneas abajo", "e295a78c11": "Mostrar {{value0}} más líneas arriba", "c9de94b07a": "Mostrar más líneas arriba", - "5f3e293517": "Restablecer el contexto del código", + "5f3e293517": "Restablecer contexto de código", "85d119be40": "Controles de contexto de código", - "791ddede19": "comentario l", + "791ddede19": "comentario L", "4b960e5978": "Cargando contexto de código...", "89e80af1c7": "archivos vistos", "319cf2d54b": "Mostrar árbol de archivos", "eff839f438": "Se agregó el comentario de revisión.", - "d8c3ba91c4": "No se puede comentar sin el jefe de PR SHA.", - "74660bd80b": "La diferencia no está disponible porque faltan los SHA de commit de PR.", + "d8c3ba91c4": "No se puede comentar sin el SHA del head del PR.", + "74660bd80b": "El diff no está disponible porque faltan los SHA de commits del PR.", "2e528e1c2d": "Visto", - "ff84e1f54c": "{{value0}} {{value1}} tal como se ve", + "ff84e1f54c": "Cambiar estado de visto de {{value1}} ({{value0}})", "5ad00c7a0e": "No hay revisores que coincidan.", "2760fa29a4": "Todos los demás", "828f045847": "Sugerencias", "57750f4a8c": "Cargando...", - "805cb72cd4": "Solicite hasta 15 revisores", - "a04c137bb7": "Crítico", + "805cb72cd4": "Solicita hasta 15 revisores", + "a04c137bb7": "Revisor", "3bde131f49": "Escribe o elige un usuario", "d10b6d5209": "No se solicitaron revisores.", "ae9a38fd4a": "Eliminar revisor {{value0}}", "acbd110867": "Cargando revisores", "00d3be6bcd": "Revisores", - "f4a4b3fd9f": "Editados recientemente estos archivos", + "f4a4b3fd9f": "Editó estos archivos recientemente", "41d275d3ec": "Solicitar revisor {{value0}}", - "36b514a457": "Revisor no solicitado {{value0}}", + "36b514a457": "Cancelar solicitud de revisor {{value0}}", "c798fa0ec7": "No se pudo eliminar al revisor", "1e6d089420": "Revisores eliminados", "2c1d93da43": "Revisor eliminado", - "1ae11c905c": "No hay contexto de repo disponible para esta solicitud de extracción.", + "1ae11c905c": "No hay contexto de repositorio disponible para este pull request.", "102d3d177f": "Revisores solicitados", "03282ff3b9": "Revisor solicitado", "8f369a6b6b": "Puedes solicitar hasta 15 revisores", - "dace0d1a9f": "Introduzca un revisor", + "dace0d1a9f": "Ingresa un revisor", "77d9388fb0": "desconocido", "c9e7094a7b": "Reanudar espacio de trabajo", "3b6886b2ee": "copiado", "b6bda618cf": "compacto", "35a0573f41": "Anotación", "61a8c69a33": "página", - "a4541fd3db": "Arreglar cheques rotos", - "c808db1dd1": "Arreglar cheques", - "c4c02ea23e": "No se puede crear un espacio de trabajo de reparación automáticamente.", + "a4541fd3db": "Arreglar checks fallidos", + "c808db1dd1": "Arreglar checks", + "c4c02ea23e": "No se pudo crear automáticamente un espacio de trabajo de reparación.", "f119e5f5ef": "Responder", - "894cfd884b": "Destino…", + "894cfd884b": "Publicando…", "9d5425918e": "Reabrir PR", - "96d013ed28": "Cerrar solicitud de extracción", + "96d013ed28": "Cerrar pull request", "d65f70786e": "cerrado", - "eca289e593": "La fusión requiere un repo local registrado", + "eca289e593": "El merge requiere un repositorio local registrado", "6568ae8ece": "por defecto", "19f19560d5": "destructivo", - "aae99c6c04": "pr", + "aae99c6c04": "PR", "e01e34f5fa": "comentario", "345b68254c": "hilo", "31a7b202f2": "Responder a @{{value0}}", @@ -1334,42 +1337,42 @@ "34b9f7c264": ":L{{value0}}", "5821aab360": "No se pudo publicar la respuesta.", "84fc40769a": "-L{{value0}}", - "1378d79e83": "Juntos", + "1378d79e83": "Lado a lado", "e5f4a24f78": "En línea", "dd94111c18": "Contraer todo", "eb722a5a8c": "Expandir todo", "19628e058d": "No se pudo agregar el comentario de revisión.", "50b8fb290f": "Marcar visto", "2b4fdb880c": "Desmarcar visto", - "56ec6eafb7": "Abra los detalles de PR para ver los revisores actuales.", + "56ec6eafb7": "Abre los detalles del PR para ver los revisores actuales.", "7f964a365a": "Eliminar revisor", "commentTooLarge": "El comentario es demasiado grande para enviarlo de forma segura.", - "8ff5ae8866": "Responsables", - "82c87eceb9": "Editar responsables", + "8ff5ae8866": "Asignados", + "82c87eceb9": "Editar asignados", "1ff5d979df": "Nadie asignado", - "checkActionRequiredHint": "Esta comprobación requiere una acción manual en GitHub (por ejemplo, aprobar la ejecución del flujo de trabajo) antes de poder desbloquear la fusión.", + "checkActionRequiredHint": "Este check requiere una acción manual en GitHub (por ejemplo, aprobar la ejecución del workflow) antes de desbloquear el merge.", "dd5d9a4f17": "actualizado {{value0}}", "71a3c0f9d2": "Iniciar espacio de trabajo" }, "QuickOpen": { "1dbd3f59ff": "Mover", - "73b2c581f1": "Cerca", - "95fccbae88": "ESC", - "61b1c871a6": "Abierto", - "250e5b2dfb": "Ingresar", + "73b2c581f1": "Cerrar", + "95fccbae88": "Esc", + "61b1c871a6": "Abrir", + "250e5b2dfb": "Enter", "74e2e1b3e4": "No hay archivos coincidentes.", "722a21e1a8": "Cargando archivos...", "1cb6ef47b7": "Ir al archivo...", "9e97f08d0f": "Buscar un archivo para abrir", - "ec31e058f7": "ir al archivo", + "ec31e058f7": "Ir al archivo", "73b44e7bde": "Copiar comando de instalación", - "1cf8561ab4": "en el control remoto para habilitar un listado rápido y compatible con gitignore:", + "1cf8561ab4": "en el remoto para habilitar un listado rápido y compatible con gitignore:", "5d80dc39bb": "ripgrep", "2ca749c15d": "Instalar", "4725b0e931": "El escaneo de apertura rápida es demasiado grande (", "b227d88520": "{{value0}} archivos encontrados", "995be8ea22": "Copiar", - "cf144856dc": "copiado" + "cf144856dc": "Copiado" }, "SelectedTextCopyMenu": { "9b40d7b018": "Copiar" @@ -1377,9 +1380,9 @@ "StarNagCard": { "92b0f9d921": "está autenticado y vuelve a intentarlo.", "cd8c34aac1": "gh", - "cf82170065": "No se pudo destacar el repo. Cerciorarse", + "cf82170065": "No se pudo dar estrella al repositorio. Asegúrate de que", "30c36231c1": "Orca es de código abierto. Si te ayudó hoy, una estrella en GitHub ayuda a otros desarrolladores a encontrarlo.", - "b5e685e4d9": "Despedir", + "b5e685e4d9": "Descartar", "5f6df21046": "¿Disfrutando de Orca?", "2d67b6c849": "Dar estrella en GitHub", "af3c9bbb37": "Dando estrella...", @@ -1393,30 +1396,30 @@ "TaskPage": { "513cddfa7a": "Verificando…", "ff69a30681": "Cancelar", - "2abe22ef76": "Su token se cifra a través del llavero del sistema operativo y se almacena localmente.", + "2abe22ef76": "Tu token se cifra con el llavero del sistema operativo y se almacena localmente.", "246c2b3dd3": "Configuración de la cuenta de Atlassian", "59c14d34a2": "Crear un token en", "b95623e93f": "Token API de Atlassian", "68df347677": "tu@ejemplo.com", "163df31e0e": "https://example.atlassian.net", - "33fc2bcb30": "Utilice la URL de un sitio de Jira Cloud, un correo electrónico de Atlassian y un token de API para explorar problemas.", + "33fc2bcb30": "Usa una URL de Jira Cloud, un correo de Atlassian y un token de API para explorar issues.", "60f806ce99": "Conectar el sitio de Jira", "8ff6fdc368": "Creando…", - "fc0d8a1fa4": "para presentar.", - "919a20dd5b": "Ingrese {{value0}}", + "fc0d8a1fa4": "para enviar.", + "919a20dd5b": "Ingresa {{value0}}", "56cdb413a2": "Valores separados por comas", - "1f0fce91e3": "Seleccione {{value0}}", + "1f0fce91e3": "Selecciona {{value0}}", "cbcdcbe244": "Cargando campos obligatorios de Jira...", "34d97ca682": "¿Qué está sucediendo?", "f161bf9ede": "Descripción (opcional)", "578f730c16": "Breve resumen", "16cba35bee": "Título", - "ae592fee62": "Tipo de problema", + "ae592fee62": "Tipo de issue", "7d63e2626e": "Cargando...", "93c57f15e5": "No se encontraron proyectos.", "cfb56a7868": "Buscar proyectos...", "00022ec0ba": "Proyecto", - "0c11ca0b6d": "Nuevo problema de Jira", + "0c11ca0b6d": "Nuevo issue de Jira", "d0ca4aa1d0": "Etiquetas", "1742eafc14": "Sin proyecto", "69591944e7": "Bajo", @@ -1426,166 +1429,166 @@ "713179dfdc": "Sin prioridad", "c8d5bec5f7": "Prioridad", "42a9160321": "No asignado", - "d2a876ca53": "Cesionario", + "d2a876ca53": "Asignado", "154b0fa623": "Estado", "9bc8aea407": "Añadir descripción...", - "d9151fd4e9": "Título del problema", + "d9151fd4e9": "Título del issue", "4f3cb99f41": "Cambiar de equipo", - "c11105dac5": "Nuevo número", + "c11105dac5": "Nuevo Issue", "1b59a07674": "Creando...", "cf72580c04": "Escribe una descripción, un resumen del proyecto o recopila ideas...", - "2ea1c701b6": "fecha objetivo", + "2ea1c701b6": "Fecha objetivo", "7da41c9225": "Objetivo", "09623359b9": "Fecha de inicio", "7d08e8be0f": "Comenzar", "af9e877f30": "Sin etiquetas", "d6cda23ef1": "Miembros", - "cfaadb6b22": "Sin pista", - "34da8ac06c": "Dirigir", + "cfaadb6b22": "Sin lead", + "34da8ac06c": "Líder", "579f98afcd": "Añade un breve resumen...", "ecbcc83140": "Nombre del proyecto", - "b6795e65fd": "Cerca", + "b6795e65fd": "Cerrar", "a98cbe7664": "Equipo", "02f67c0d09": "Nuevo proyecto", "bdebffcbfe": "Crea un proyecto Linear para el equipo seleccionado.", "1361275ec3": "Nuevo proyecto Linear", "7f3f7b4c18": "Descripción (opcional, markdown)", - "9f2b4c03a6": "Presentando", - "d3d0998b7d": "Nuevo problema de GitHub", - "d1e243795c": "asuntos", - "be8cf68d9f": "ver problemas", - "67662ade50": "problemas del proyecto", + "9f2b4c03a6": "Creando en", + "d3d0998b7d": "Nuevo issue de GitHub", + "d1e243795c": "issues", + "be8cf68d9f": "ver issues", + "67662ade50": "issues del proyecto", "6244a02f46": "Abrir en Linear", "606a85c774": "Abierto", "5e8061b088": "Iniciar espacio de trabajo desde {{value0}} {{value1}}", - "592a55611b": "Intente seleccionar más equipos o actualizar; Los filtros de equipo se aplican al conjunto de incidencias recuperadas actualmente.", - "618107fab3": "No hay problemas recuperados que coincidan con los equipos seleccionados.", - "903c7af49f": "No se encontraron problemas Lineares", - "5ed38a49e5": "Revise el error del espacio de trabajo a continuación y luego actualice.", - "cc8795e07c": "No se pueden cargar problemas Lineares", + "592a55611b": "Prueba a seleccionar más equipos o actualizar; los filtros de equipo se aplican al conjunto de issues obtenido actualmente.", + "618107fab3": "No hay issues obtenidos que coincidan con los equipos seleccionados.", + "903c7af49f": "No se encontraron issues de Linear", + "5ed38a49e5": "Revisa el error del espacio de trabajo abajo y luego actualiza.", + "cc8795e07c": "No se pueden cargar issues de Linear", "f362667d55": "Actualizado", - "b1eaa18ace": "Asunto", - "37e7ee311e": "Llave", + "b1eaa18ace": "Issue", + "37e7ee311e": "Clave", "b7bae28b6a": "mostrado", "a26a48252e": "Propiedades de visualización", - "5d2d835467": "Realizar pedidos", - "5659da12fc": "Agrupamiento", + "5d2d835467": "Orden", + "5659da12fc": "Agrupación", "9c57663908": "Vista", - "af377b13b1": "{{value0}} ver", - "d47248df4d": "Modo de vista Linear", + "af377b13b1": "Vista {{value0}}", + "d47248df4d": "Modo de vista de Linear", "f397d513e3": "Atrás", "b39fe6511d": "proyectos", "8675cd6188": "Linear", "733b8f2421": "Linear / Vistas", "bc06ed0fb0": "Volver a vistas", "3cb855080f": "vistas", - "b4e10f096e": "Dueño", + "b4e10f096e": "Propietario", "a04fe7ba73": "Visibilidad", "0aa8525950": "Modelo", - "dfc0c79bd8": "Asuntos", + "dfc0c79bd8": "Issues", "8a07f21e76": "Salud", "851017590d": "Agregar acceso Linear", - "228b25028f": "Explore y comience a trabajar en los problemas Lineares asignados directamente desde aquí.", + "228b25028f": "Explora y empieza a trabajar en tus issues asignados de Linear directamente desde aquí.", "6d56559467": "Conecta tu cuenta Linear", - "eee68073b2": "Abierto en Jira", + "eee68073b2": "Abrir en Jira", "9497f2787c": "Iniciar espacio de trabajo", - "eba87f2edb": "No se encontraron problemas con Jira", - "63b2abd3aa": "Problemas con Jira", + "eba87f2edb": "No se encontraron issues de Jira", + "63b2abd3aa": "Issues de Jira", "e7115334aa": "Ocultar Jira", "83bce6be5c": "Conecta Jira", - "b518ae6307": "Explora, edita, crea y comienza a trabajar desde incidencias de Jira directamente desde aquí.", - "a150c59da7": "Conecte su sitio Jira", + "b518ae6307": "Explora, edita, crea y empieza a trabajar desde issues de Jira directamente aquí.", + "a150c59da7": "Conecta tu sitio de Jira", "bcdc1330b2": "Abrir en GitLab", "00b7ffb952": "Tipo / Estado", - "eb10c32872": "IDENTIFICACIÓN", - "e9b6955dcd": "Número {{value0}}", - "a0544fb653": "¡SEÑOR! {{value0}}", + "eb10c32872": "ID", + "e9b6955dcd": "Issue #{{value0}}", + "a0544fb653": "MR !{{value0}}", "8396825a14": "Acción", "c1d1600362": "Abrir en el navegador", "b6329379ca": "Iniciar nuevo espacio de trabajo", "054bf695cc": "Borrador", - "285bc21dc5": "Cambie la consulta o borrela.", + "285bc21dc5": "Cambia la consulta o bórrala.", "d0e3c8f933": "No hay trabajo de GitHub coincidente", "5b6b2af943": "Reintentando…", - "0c0de0fc0e": "No se pudieron cargar los problemas de", + "0c0de0fc0e": "No se pudieron cargar los issues de", "d1766fd62d": "los proyectos no pudieron cargarse", "7762f4b03a": "de", - "443f7dd928": "Unir", - "a7396b05c6": "cheques", + "443f7dd928": "Merge", + "a7396b05c6": "Checks", "f6fa3c97d0": "Revisores", - "8aba10579d": "Cesionarios", + "8aba10579d": "Asignados", "5eccb3c841": "Título / Contexto", "d4c2830063": "Actualizar elementos de trabajo de GitLab", - "c679af7ad9": "Actualizar mis todos", + "c679af7ad9": "Actualizar My Todos", "dfd72673e7": "No se pudo guardar la selección del proyecto.", "b797bdd7c3": "Borrar búsqueda", - "99c2755218": "Jira JQL, p.ej. proyecto = ABC AND statusCategory! = Listo", - "2ff9fd71fd": "Actualizar problemas de Jira", - "0b65d3fb2c": "Buscar proyectos Lineares...", - "eec0c5c079": "Buscar temas Lineares...", + "99c2755218": "Jira JQL, p. ej. project = ABC AND statusCategory != Done", + "2ff9fd71fd": "Actualizar issues de Jira", + "0b65d3fb2c": "Buscar proyectos de Linear...", + "eec0c5c079": "Buscar issues de Linear...", "8964184a8b": "Actualizar Linear", - "3feb524d42": "Nueva edición Linear", + "3feb524d42": "Nuevo issue de Linear", "0cbf7e5cf3": "Modo de tarea Linear", - "ff53631e6f": "Actualizar el trabajo de GitHub", - "6ffa6be99f": "Actualización del trabajo de GitHub", - "b15ceb409d": "Buscar problemas de GitHub...", - "eee4df4c66": "Buscar PR de GitHub...", + "ff53631e6f": "Actualizar trabajo de GitHub", + "6ffa6be99f": "Actualizando trabajo de GitHub", + "b15ceb409d": "Buscar issues de GitHub...", + "eee4df4c66": "Buscar PRs de GitHub...", "e592d99051": "Todos los sitios de Jira", "d09b7631b7": "No se pudo cambiar el sitio de Jira.", - "8029e2bd4d": "Seleccione un equipo lineal para abrir en Linear", + "8029e2bd4d": "Selecciona un equipo de Linear para abrirlo en Linear", "609532fae7": "No se pudo guardar la fuente de tarea predeterminada.", "4826fd1ad8": "Cerrar · Esc", "1a06219d5c": "Cerrar tareas", "3f594861a5": "No se pudo guardar la selección del equipo.", "d0d570b306": "No se pudo cambiar el espacio de trabajo Linear.", - "cb98f0350c": "Creado {{value0}}", - "1e1b2ad8f2": "Seleccione un equipo del espacio de trabajo del proyecto antes de presentar este problema.", + "cb98f0350c": "Se creó {{value0}}", + "1e1b2ad8f2": "Selecciona un equipo del espacio de trabajo del proyecto antes de crear este issue.", "3ca9b424a3": "No se pudo crear el proyecto.", - "3f9604efc7": "Problema abierto #{{value0}}", - "585dba2989": "No se puede abrir el espacio de trabajo adjunto a este problema.", - "534a9c6017": "No se puede abrir el espacio de trabajo adjunto a esta solicitud de extracción.", + "3f9604efc7": "Issue #{{value0}} abierto", + "585dba2989": "No se puede abrir el espacio de trabajo adjunto a este issue.", + "534a9c6017": "No se puede abrir el espacio de trabajo adjunto a este pull request.", "fe380f306c": "No se pudo guardar la vista de tareas predeterminada.", - "af2a8371de": "No se pudieron cargar los tipos de problemas de Jira.", + "af2a8371de": "No se pudieron cargar los tipos de issues de Jira.", "6775c05483": "No se pudo actualizar el estado Linear", "745ae567d4": "\"{{value0}}\" no está disponible para {{value1}}", "669e419d65": "A la vista Linear le falta el contexto del espacio de trabajo.", "cba2a2b7fb": "Al proyecto Linear le falta el contexto del espacio de trabajo.", - "f4374519ae": "Su fuente de problemas preferida (ascendente) ya no está configurada para {{value0}}. Usando origen.", + "f4374519ae": "Tu fuente de issues preferida (upstream) ya no está configurada para {{value0}}. Se usará origin.", "e9139db03f": "No se pudo ocultar {{value0}}.", - "b73717af92": "Próximo", + "b73717af92": "Siguiente", "0c8df28045": "Página siguiente", "ae859c816b": "Página {{value0}}", "cd171f3391": "...", "297a805b64": "Anterior", - "6cd6b3ae6a": "Pagina anterior", + "6cd6b3ae6a": "Página anterior", "e65757a338": "Paginación", - "37d60046e3": "Abrir el cuadro de combinación de GitHub", - "1a9ea003dc": "No se pudo deshabilitar la combinación automática", - "a3318684bc": "No se pudo habilitar la combinación automática", - "a5bf86defe": "Fusión automática deshabilitada", - "fed317634c": "Combinación automática habilitada", - "88f478cdef": "No se pudo fusionar la solicitud de extracción", - "a161925adc": "Solicitud de extracción fusionada", - "0506a78337": "Esto actualizará la solicitud de extracción en GitHub.", + "37d60046e3": "Abrir el cuadro de merge de GitHub", + "1a9ea003dc": "No se pudo deshabilitar auto-merge", + "a3318684bc": "No se pudo habilitar auto-merge", + "a5bf86defe": "Auto-merge deshabilitado", + "fed317634c": "Auto-merge habilitado", + "88f478cdef": "No se pudo hacer merge del pull request", + "a161925adc": "Pull request mergeado", + "0506a78337": "Esto actualizará el pull request en GitHub.", "844dc193c7": "{{value0}} PR #{{value1}}?", - "995dd6af9b": "Abrir cheques de PR", + "995dd6af9b": "Abrir checks del PR", "8a22eb3f7b": "No hay revisores que coincidan.", "67755a83a1": "Todos los demás", "3ace2e6bcf": "Sugerencias", "0eacf48491": "Cargando…", "0b9b04f4b5": "Escribe o elige un usuario", - "62c7bd789f": "Solicite hasta 15 revisores", - "5d4fd69a6a": "Activo recientemente en esta solicitud de extracción", + "62c7bd789f": "Solicita hasta 15 revisores", + "5d4fd69a6a": "Activo recientemente en este pull request", "ed1daeb49a": "No se pudo eliminar al revisor", "837bb901ec": "Revisores eliminados", "f9191d1714": "Revisor eliminado", "dc67f69962": "No se pudo solicitar el revisor", "8f06dbb9e5": "Revisor solicitado", "969e26577c": "Puedes solicitar hasta 15 revisores", - "d00571d9b1": "Introduzca un revisor", + "d00571d9b1": "Ingresa un revisor", "edf4bc4135": "No hay usuarios asignables.", - "53e002d895": "La emisión no tiene slug de repo.", - "7f94eb6395": "Asignar problema", + "53e002d895": "El issue no tiene slug de repositorio.", + "7f94eb6395": "Asignar issue", "bb63046423": "Asignado a {{value0}}", "ca63694b4c": "No se pudieron actualizar los asignados.", "b36f4bf9de": "Sin etiquetas.", @@ -1593,15 +1596,15 @@ "d09bf34db7": "Cerrado", "1c893195ac": "No se pudo actualizar el estado", "afc68824ff": "No se encontraron estados", - "cc13109b5d": "Estados de carga", + "cc13109b5d": "Cargando estados", "d45a910c4a": "Cambiar estado Linear de {{value0}}", "d8a517ad89": "Identificador", "50387522d7": "Sin agrupación", - "d747aed72f": "Junta", + "d747aed72f": "Tablero", "a6f7e93d7f": "Lista", "e78ec261ed": "Vistas", "727069bee5": "Proyectos", - "137e2a8a01": "PR", + "137e2a8a01": "PRs", "18451e99df": "Hecho", "4b6e40e42c": "Todo abierto", "bd9965df51": "Reportado", @@ -1611,79 +1614,79 @@ "acef77f7ca": "GitHub", "524f095d55": "Necesita revisión", "7698af5263": "Mío", - "94f0339621": "Asignado a mi", + "94f0339621": "Asignado a mí", "c2268a9982": "Todo", - "37a82eaaf8": "Fusionado", + "37a82eaaf8": "Mergeado", "887efe9140": "Conectar", "a70153f583": "conectando", "6459faa8b3": "error", - "e15ba2d2eb": "Crear problema", - "3b11c8e8fc": "formación", - "e178c0a953": "Elija un proyecto de Jira antes de crear el problema.", - "0f7b0d964a": "Crea un nuevo problema en {{value0}}.", + "e15ba2d2eb": "Crear issue", + "3b11c8e8fc": "array", + "e178c0a953": "Elige un proyecto de Jira antes de crear el issue.", + "0f7b0d964a": "Crea un nuevo issue en {{value0}}.", "eff9800d4b": "{{value0}} etiqueta{{value1}}", "d7f16d0e32": "Seleccionar equipo", "5301ca0f20": "Crear proyecto", "7719d8daa9": "{{value0}} miembro{{value1}}", "5af6f0ae5b": "Seleccionar equipo", - "cfb730b73e": "asunto", - "3659f9792a": "junta", - "d079be2dc8": "No hay problemas asignados. Intenta buscar algo.", - "2bdefbcac3": "Pruebe con una consulta de búsqueda diferente.", - "25ff84769a": "Ningún problema coincide con este contexto Linear.", + "cfb730b73e": "issue", + "3659f9792a": "board", + "d079be2dc8": "No hay issues asignados. Intenta buscar algo.", + "2bdefbcac3": "Prueba con otra consulta de búsqueda.", + "25ff84769a": "Ningún issue coincide con este contexto de Linear.", "cbce2bc9cd": "ninguno", "51411113df": "lista", - "60f68a2ef4": "Cuestiones Lineares", + "60f68a2ef4": "Issues de Linear", "b2007ba885": "proyecto", "6edf402e11": "descripción general", "9ae151b26b": "lineal", - "94d900518d": "Ningún problema coincide con el ajuste preestablecido seleccionado.", - "f51e254d35": "Pruebe con una consulta JQL diferente.", + "94d900518d": "Ningún issue coincide con el preset seleccionado.", + "f51e254d35": "Prueba con otra consulta JQL.", "4645a7814f": "jira", - "e224d76876": "SEÑOR", - "bbec4717ee": "señor", - "d6d08c1650": "Seleccione un proyecto para ver los elementos de trabajo de GitLab.", - "f294c500ef": "Ningún trabajo de GitLab coincide con este filtro.", + "e224d76876": "MR", + "bbec4717ee": "mr", + "d6d08c1650": "Selecciona un proyecto para ver los work items de GitLab.", + "f294c500ef": "Ningún work item de GitLab coincide con este filtro.", "cd7dc432a3": "Ningún GitLab MR coincide con este filtro.", - "171b7739d8": "señora", - "a9f256ecea": "Ningún problema de GitLab coincide con este filtro.", + "171b7739d8": "mrs", + "a9f256ecea": "Ningún issue de GitLab coincide con este filtro.", "2007e14d95": "gitlab", - "456b8512da": "Solicitud de fusión", - "03da966159": "Seleccione un proyecto para que podamos autenticarnos en GitLab.", - "d591aac6ae": "No hay todos pendientes. ¡Estáis todos atrapados!", + "456b8512da": "MergeRequest", + "03da966159": "Selecciona un proyecto para que podamos autenticarnos en GitLab.", + "d591aac6ae": "No hay todos pendientes. ¡Estás al día!", "33a4bd7f5c": "pendientes", "66ae7330f6": "Más acciones", "93d5f21fc1": "pr", - "e104fa3d3d": "Iniciar espacio de trabajo desde el problema", - "2193a99ec1": "Abrir espacio de trabajo adjunto al problema", + "e104fa3d3d": "Iniciar espacio de trabajo desde el issue", + "2193a99ec1": "Abrir espacio de trabajo adjunto al issue", "7deb9e59a5": "Más acciones de PR", "7753652524": "Reanudar", "e4b29c5bcf": "Iniciar espacio de trabajo desde PR", "67d881244c": "Reanudar espacio de trabajo adjunto a PR", "6430594b18": "autor desconocido", "7799ad9ab6": "borrador", - "0bfbf62f75": "Rever", + "0bfbf62f75": "Reintentar", "38139edb52": "github", - "31f81cc334": "Actualizando el trabajo de GitHub...", + "31f81cc334": "Actualizando trabajo de GitHub...", "3d93316bb0": "PRs", "937b29fa35": "elementos", - "bc46d8204e": "Seleccione un proyecto para abrir en GitHub", - "d1132848f8": "Seleccione un proyecto de GitHub para abrir en GitHub", + "bc46d8204e": "Selecciona un proyecto para abrirlo en GitHub", + "d1132848f8": "Selecciona un proyecto de GitHub para abrirlo en GitHub", "2af3ab5c58": "Selecciona un equipo para abrir en Linear", - "aec5feeb69": "No se pudo crear el problema de Jira.", - "7437e340b4": "No se pudo crear el problema.", - "9e03c17847": "Abra los detalles de PR para ver los revisores actuales.", + "aec5feeb69": "No se pudo crear el issue de Jira.", + "7437e340b4": "No se pudo crear el issue.", + "9e03c17847": "Abre los detalles del PR para ver los revisores actuales.", "3b7f34282f": "cerrado", "246bd64aed": "Abrir {{value0}} en Linear", "ff90d0abc7": "Iniciar espacio de trabajo desde {{value0}}", "fe28c9821f": "vista", - "8d1e17a3ef": "Abra {{value0}} en GitHub", + "8d1e17a3ef": "Abrir {{value0}} en GitHub", "4ac8ff2275": "Abrir {{value0}} en Jira", "40eaf2c27c": "Detalles", "closeAsCompleted": "Cerrar como completado", "closeAsCompletedDescription": "Hecho, cerrado, corregido, resuelto", "closeAsNotPlanned": "Cerrar como no planificado", - "closeAsNotPlannedDescription": "No se corregirá, no se puede reproducir, obsoleto", + "closeAsNotPlannedDescription": "No se arreglará, no se puede reproducir, obsoleto", "closeAsDuplicate": "Cerrar como duplicado", "closeAsDuplicateDescription": "Duplicado de otro issue en este repositorio", "duplicateIssueNumberPlaceholder": "Número de issue", @@ -1700,101 +1703,101 @@ "editReviewersWithCurrent": "Editar revisores: {{value0}}" }, "Terminal": { - "73768427cf": "Cerca", + "73768427cf": "Cerrar", "f82e9f02df": "Cancelar", "7958465754": "Hay terminales locales con procesos en ejecución. ¿Cerrar la ventana de todos modos?", "2fa9c69ff3": "¿Cerrar ventana?", - "cd51e28d8b": "Ahorrar", + "cd51e28d8b": "Guardar", "0037b21794": "No guardar", "21295c6b8c": "Cambios no guardados", "5c1d2a32bb": "Cargando editor...", - "f0600556b3": "No se pudo crear el archivo de markdowns sin título.", + "f0600556b3": "No se pudo crear el archivo Markdown sin título.", "37da0d736f": "Nueva pestaña del navegador", - "a2a279b32a": "Se agotó el tiempo de guardado o falló. Corregir errores antes de cerrar.", + "a2a279b32a": "El guardado agotó el tiempo de espera o falló. Corrige los errores antes de cerrar.", "46e08bc5c8": "Este archivo tiene cambios no guardados.", "61ed600d29": "\"{{value0}}\" tiene cambios no guardados. ¿Quieres guardar antes de cerrar?", "cdc9ac4b2d": "editor", - "e57db40c11": "No se pudo construir el comando de inicio para {{value0}}.", - "5b2c1a9e44": "No se detectó ninguna CLI de agente; instala una o elige un agente predeterminado en Configuración." + "e57db40c11": "No se pudo generar el comando de inicio para {{value0}}.", + "5b2c1a9e44": "No se detectó ninguna CLI de agente; instala una o elige un agente predeterminado en Ajustes." }, "TerminalSearch": { - "db234b7519": "Cerca", - "7cb40c04eb": "Próximo partido", - "0f3066256e": "Partido anterior", - "42e466b9f1": "expresión regular", + "db234b7519": "Cerrar", + "7cb40c04eb": "Siguiente coincidencia", + "0f3066256e": "Coincidencia anterior", + "42e466b9f1": "Expresión regular", "90c61387d9": "Distingue mayúsculas y minúsculas", "e07012f26e": "Buscar..." }, "UpdateCard": { "68b235d264": "Reiniciar para actualizar", - "6714206e5a": "Se descarga Orca v{{value0}}. Reinicie cuando esté listo.", + "6714206e5a": "Orca v{{value0}} se descargó. Reinicia cuando estés listo.", "93794ea932": "Orca v{{value0}} se está descargando.", "8acbdd3961": "Minimizar a la barra de estado", "17412483da": "Listo para instalar", "47126bcf57": "Descargar manualmente", - "3553a8672f": "último error", - "90559b14e3": "Esto activa un conmutador de red Electron de todo el proceso después del reinicio. Úselo para VPN corporativas o servidores proxy que rechacen las descargas de actualizaciones HTTP/2.", + "3553a8672f": "Último error", + "90559b14e3": "Esto activa un switch de red de Electron para todo el proceso después de reiniciar. Úsalo para VPNs corporativas o proxies que rechazan descargas de actualizaciones por HTTP/2.", "6e45bfa2e0": "Descargando...", "558842597d": "Descargando actualización", "f58b5c57a6": "Nuevo:", "ec8fe71cfc": "Actualizar", "44324ef542": "Notas de la versión", - "fdd4a364fa": "Las sesiones no serán interrumpidas.", - "05ad78a6d1": "La Orca v{{value0}} está lista.", + "fdd4a364fa": "Las sesiones no se interrumpirán.", + "05ad78a6d1": "Orca v{{value0}} está lista.", "318d3b4bc7": "Descartar actualización", "9abc59f814": "Actualización disponible", - "aad383aecc": "Lea las notas de la versión completas", + "aad383aecc": "Lee las notas de la versión completas", "ccd8b0a793": "más desde tu última actualización", - "b1d867f4fb": "Las sesiones de su terminal no se interrumpirán durante la actualización.", + "b1d867f4fb": "Tus sesiones de terminal no se interrumpirán durante la actualización.", "09a55c39b5": "Instalando...", "ea2a41adbe": "Estás en la última versión.", "ba5ffc949c": "Buscando actualizaciones...", - "2c2d3e03ca": "Intentar otra vez", + "2c2d3e03ca": "Intentar de nuevo", "4cf109845a": "Error de actualización", - "6b0085010d": "Vuelva a comprobar", + "6b0085010d": "Volver a comprobar", "48565a32bc": "Reintentar descargar", "933c6fdf5b": "Habilitar y reiniciar", "1339b82cee": "Descarga HTTP/2 bloqueada", "7274ef6e59": "Descartar consejo", - "a726967bd3": "Despedir", + "a726967bd3": "Descartar", "d5253b54af": "error", - "7ffc08506e": "controlar", - "522df222b9": "hilandero" + "7ffc08506e": "check", + "522df222b9": "spinner" }, "WorktreeJumpPalette": { "ac037cfac2": "Mover", - "75499e01d9": "Cerca", - "66b5a67bee": "ESC", - "45def60329": "Abierto", - "f65d992a11": "Ingresar", - "c5081f2814": "Árbol de trabajo actual", + "75499e01d9": "Cerrar", + "66b5a67bee": "Esc", + "45def60329": "Abrir", + "f65d992a11": "Enter", + "c5081f2814": "Worktree actual", "52404f8096": "Pestaña actual", "739bda980c": "primario", "556e7232ca": "Actual", - "684e8d7bc2": "Recopilando sus árboles de trabajo recientes y pestañas abiertas.", + "684e8d7bc2": "Recopilando tus worktrees recientes y pestañas abiertas.", "ff908adfe9": "Cargando objetivos de salto", - "1ebe225fee": "Busque árboles de trabajo, configuraciones, pestañas y acciones...", - "4e4ff044d5": "Buscar árboles de trabajo, configuraciones, pestañas y acciones", + "1ebe225fee": "Busca worktrees, ajustes, pestañas y acciones...", + "4e4ff044d5": "Buscar worktrees, ajustes, pestañas y acciones", "4ee378034d": "Saltar a...", - "f7fda8d562": "Cree un árbol de trabajo o abra una pestaña en Orca para comenzar.", - "1628fd7dfa": "No hay árboles de trabajo, configuraciones, acciones ni pestañas abiertas activas", - "b781ae05e3": "Escriba para buscar árboles de trabajo, configuraciones, pestañas y acciones.", - "f60f8730be": "No hay otros árboles de trabajo a los que cambiar", - "c4afa68159": "Pruebe con un árbol de trabajo, ajuste, acción, título de pestaña, prompt de agente, URL, PR o puerto.", + "f7fda8d562": "Crea un worktree o abre una pestaña en Orca para comenzar.", + "1628fd7dfa": "No hay worktrees activos, ajustes, acciones ni pestañas abiertas", + "b781ae05e3": "Escribe para buscar worktrees, ajustes, pestañas y acciones.", + "f60f8730be": "No hay otros worktrees a los que cambiar", + "c4afa68159": "Prueba con un worktree, ajuste, acción, título de pestaña, prompt de agente, URL, PR o puerto.", "dbd9d87eec": "Ningún resultado coincide con tu búsqueda", "2c38630a01": "El espacio de trabajo ya no existe", "7726ce9970": "La pestaña del emulador móvil ya no existe", "d7d496a451": "La página del navegador ya no existe", "50a1d11d5b": "Pestañas abiertas", - "088d66d980": "Acciones y configuraciones", - "dabd819ca1": "Escriba para ver todos los árboles de trabajo {{value0}}", - "20af998bff": "{{value0}} artículos disponibles{{value1}}", + "088d66d980": "Acciones y ajustes", + "dabd819ca1": "Escribe para ver los {{value0}} worktrees", + "20af998bff": "{{value0}} elementos disponibles{{value1}}", "bb72c08e63": "{{value0}} resultados encontrados{{value1}}", "34c8fbb46e": "SSH remoto", "63c2be1914": "SSH desconectado", - "95be6587d3": "Crear árbol de trabajo \"{{value0}}\"", - "worktreesHeader": "árboles de trabajo", - "recentWorktreesHeader": "Árboles de trabajo recientes", + "95be6587d3": "Crear worktree \"{{value0}}\"", + "worktreesHeader": "Worktrees", + "recentWorktreesHeader": "Worktrees recientes", "settingsBadge": "Ajustes", "actionBadge": "Acción", "paletteHostBadge": "Host: {{value0}}", @@ -1807,39 +1810,39 @@ "pr": { "merge": { "state": { - "a80132573b": "GitHub todavía está calculando el estado de fusión de esta solicitud de extracción", - "f958920f3a": "De cheques", - "9bd983ce8f": "GitHub dice que este PR puede fusionarse, pero aún se están realizando comprobaciones", - "4e2507176b": "Cheques pendientes", - "1432ecff30": "GitHub dice que este PR puede fusionarse, pero algunas comprobaciones fallaron", - "87fa36ac83": "Los controles fallaron", - "1766eb46ba": "GitHub informa que esta solicitud de extracción está bloqueada", - "bf5e4c6c92": "Obstruido", - "c614e2660a": "Actualizar la rama antes de fusionar", - "039c072f94": "Detrás", - "b37d45bca9": "GitHub informa conflictos de fusión", + "a80132573b": "GitHub todavía está calculando el estado de merge de este pull request", + "f958920f3a": "Verificando", + "9bd983ce8f": "GitHub dice que este PR se puede mergear, pero los checks siguen ejecutándose", + "4e2507176b": "Checks pendientes", + "1432ecff30": "GitHub dice que este PR se puede mergear, pero algunos checks fallaron", + "87fa36ac83": "Checks fallidos", + "1766eb46ba": "GitHub informa que este pull request está bloqueado", + "bf5e4c6c92": "Bloqueado", + "c614e2660a": "Actualiza la rama antes de hacer merge", + "039c072f94": "Por detrás", + "b37d45bca9": "GitHub informa conflictos de merge", "7e8bbe3cd7": "Conflictos", - "09896aad26": "El estado de fusión no está disponible para este PR", - "bd4f27b50e": "Unir", - "35ec24bc43": "Esta rama base usa la cola de combinación de GitHub", - "b289646bcd": "Los informes de GitHub solicitaron cambios en esta solicitud de extracción", + "09896aad26": "El estado de merge no está disponible para este PR", + "bd4f27b50e": "Merge", + "35ec24bc43": "Esta rama base usa la merge queue de GitHub", + "b289646bcd": "GitHub informa cambios solicitados en este pull request", "c606463dc2": "Cambios solicitados", - "a20db875ed": "GitHub requiere aprobación de revisión antes de que esta solicitud de extracción pueda fusionarse", + "a20db875ed": "GitHub requiere aprobación de revisión antes de que este pull request pueda mergearse", "1f8eb81c0e": "Se requiere aprobación", - "f03028e055": "Esta solicitud de extracción aún es un borrador.", + "f03028e055": "Este pull request sigue en borrador", "ec8e2cebaa": "Borrador", - "820fd21663": "Esta solicitud de extracción está cerrada.", + "820fd21663": "Este pull request está cerrado", "4f976d3450": "Cerrado", - "62eb8d39da": "Esta solicitud de extracción ya está fusionada", - "83ecdbb4a6": "Fusionado", - "331ebe1170": "Agregue esta solicitud de extracción a la cola de combinación de GitHub", - "b169f943e1": "Fusionar cuando esté listo", - "62703b1dc4": "La fusión automática de GitHub está habilitada para esta solicitud de extracción", - "48d75ae118": "Deshabilitar la fusión automática", - "a5b66afb58": "Comprobaciones aprobadas", - "fbd4f57f0a": "Comprobaciones aprobadas. La elegibilidad de fusión se volverá a comprobar antes de fusionar.", - "4ab19a62ef": "Habilitar la fusión automática", - "8f6cb3772f": "Fusionar esta solicitud de extracción automáticamente cuando se cumplan los requisitos" + "62eb8d39da": "Este pull request ya está mergeado", + "83ecdbb4a6": "Mergeado", + "331ebe1170": "Agrega este pull request a la merge queue de GitHub", + "b169f943e1": "Mergear cuando esté listo", + "62703b1dc4": "Auto-merge de GitHub está habilitado para este pull request", + "48d75ae118": "Deshabilitar auto-merge", + "a5b66afb58": "Checks aprobados", + "fbd4f57f0a": "Checks aprobados. La elegibilidad de merge se volverá a verificar antes de hacer merge.", + "4ab19a62ef": "Habilitar auto-merge", + "8f6cb3772f": "Mergear este pull request automáticamente cuando se cumplan los requisitos" } } }, @@ -1852,30 +1855,30 @@ "baa006f9af": "Documentos", "3fefeebde4": "Copiar comando de actualización", "9c2da6353b": "Copiar comando de inicio de sesión", - "b436c586d1": "comando copiar", + "b436c586d1": "Copiar comando", "8a7f6bf5dc": "No se pudo copiar", "224c9d0ae8": "Copiado al portapapeles", - "891a7d4616": "Desarmado para este shell", - "fd17b3019f": "Desarmado (PowerShell, persistente)", - "ae43542893": "Encuentra dónde está configurado", - "df636f5886": "Compruebe si está configurado (PowerShell)" + "891a7d4616": "Unset para este shell", + "fd17b3019f": "Unset (PowerShell, persistente)", + "ae43542893": "Encontrar dónde está configurado", + "df636f5886": "Comprobar si está configurado (PowerShell)" }, "ProjectCell": { - "4b5b871da8": "No hay etiquetas en este repo.", + "4b5b871da8": "No hay etiquetas en este repositorio.", "2219e945ef": "Cargando…", - "54cac64427": "Row no tiene slug de repo.", + "54cac64427": "La fila no tiene slug de repositorio.", "8ae56a88a6": "Etiquetas", - "f7cdb78efb": "Cesionarios", - "ebde486e3c": "Claro", - "191905e20e": "Actual y próximo", - "e17bb96881": "Terminado", - "943b3dadc9": "Este repo no tiene tipos de problemas.", - "c7b059cf07": "Tipo de problema", - "c5f949e489": "Asunto", + "f7cdb78efb": "Asignados", + "ebde486e3c": "Limpiar", + "191905e20e": "Actual y próximos", + "e17bb96881": "Completado", + "943b3dadc9": "Este repositorio no tiene tipos de issue.", + "c7b059cf07": "Tipo de issue", + "c5f949e489": "Issue", "8d669084f6": "Restringido", "6efdc0d920": "Borrador", "d0d0e13a5a": "PR", - "af5d8c912a": "Artículo restringido", + "af5d8c912a": "Elemento restringido", "bb7ebc11e3": "Añadir número", "9cb1a0c984": "Agregar texto", "2e26a06c70": "Agregar etiqueta", @@ -1889,38 +1892,38 @@ }, "ProjectItemSlugDialog": { "e55a5c4e68": "Vista previa de la fila del proyecto.", - "4450efea9c": "elemento de GitHub" + "4450efea9c": "Elemento de GitHub" }, "ProjectPicker": { - "96739284c3": "Pegue la URL de un proyecto a continuación para llegar a los que faltan.", + "96739284c3": "Pega una URL de proyecto abajo para acceder a los que faltan.", "9b36829267": "No se encontraron vistas.", "72a05c04a6": "Cargando vistas…", "9bf55fa1e8": "Elige una vista", "a51b3337ab": "← Volver", - "8ab5447c64": "Alfiler", - "5009ffc2f3": "Quitar pasador", + "8ab5447c64": "Fijar", + "5009ffc2f3": "Quitar fijado", "fce99a24a7": "Agregar", - "5113ecc298": "Agregar por URL o propietario/número", + "5113ecc298": "Agregar por URL u owner/number", "7b6d39627e": "Cargando…", "b787682111": "Explorar todo", "ba0ab9a117": "Explorar todo (cargando…)", - "b3044b7a25": "Reciente", + "b3044b7a25": "Recientes", "707843206c": "Fijado", "f492e1b539": "Buscar proyectos", "44b2c6326b": "No se pudieron cargar las vistas: {{value0}}", "ab1a2c357d": "Hoja de ruta (no compatible)", - "d34ef9b554": "Tablero (sin soporte)", + "d34ef9b554": "Board (no compatible)", "43a88ae574": "BOARD_LAYOUT", - "1a2b8e512e": "Mesa", + "1a2b8e512e": "Tabla", "cafb908f34": "TABLE_LAYOUT" }, "ProjectRow": { - "75b5d816e3": "empezar a trabajar", + "75b5d816e3": "Empezar a trabajar", "e12be8b4d4": "Abrir en GitHub", - "c3b81ddea2": "BORRADOR_EDICIÓN" + "c3b81ddea2": "DRAFT_ISSUE" }, "ProjectViewList": { - "989f81dc2a": "columnas", + "989f81dc2a": "Columnas", "f949f5b2b7": "Configurar columnas", "eddfc7a794": "Ordenar por {{value0}}", "4f57d2e0b1": "Ningún elemento coincide con el filtro de esta vista." @@ -1928,22 +1931,22 @@ "ProjectViewWrapper": { "463f1205c0": "Cargando vista del proyecto", "23b87ba9f7": "Abrir en GitHub", - "4d2a77a119": "Solicitud de función de archivo", - "1bf8c01c8b": "Cambie a una vista de tabla para trabajar con este proyecto en Orca.", - "55de4fb57a": "{{value0}}. {{value1}} Presente una solicitud de función en {{value2}}.", - "2edf5e7e77": "{{value0}}: Orca aún no admite vistas del proyecto {{value1}}. Presente una solicitud de función en {{value2}}.", + "4d2a77a119": "Enviar feature request", + "1bf8c01c8b": "Cambia a una vista de tabla para trabajar con este proyecto en Orca.", + "55de4fb57a": "{{value0}}. {{value1}} Envía un feature request en {{value2}}.", + "2edf5e7e77": "{{value0}}: Orca aún no admite vistas de proyecto {{value1}}. Envía un feature request en {{value2}}.", "7245c3d7ac": "Borrar búsqueda", - "c5bc7ec007": "Ver filtro: {{value0}}", - "840c268665": "Agregar repo", + "c5bc7ec007": "Filtro de vista: {{value0}}", + "840c268665": "Agregar repositorio", "dffa899f36": "Cancelar", - "7037c8f5f1": "Repositorio no en Orca", - "512fc171d6": "Elija un proyecto para comenzar.", - "71fb69926c": "Refrescar", - "a8fa0d2bf5": "Refrescante", + "7037c8f5f1": "Repositorio no está en Orca", + "512fc171d6": "Elige un proyecto para comenzar.", + "71fb69926c": "Actualizar", + "a8fa0d2bf5": "Actualizando", "fd15491034": "Abrir vista en GitHub", - "22df63c393": "Los datos de la subemisión no están disponibles para su token.", - "067119985c": "Búsqueda de GitHub, p. cesionario:@yo es:abierto", - "1850fceac8": "{{value0}}/{{value1}} no se agrega a Orca. Agréguelo para comenzar a trabajar o ábralo en GitHub.", + "22df63c393": "Los datos de sub-issues no están disponibles para tu token.", + "067119985c": "Búsqueda de GitHub, p. ej. assignee:@me is:open", + "1850fceac8": "{{value0}}/{{value1}} no está agregado a Orca. Agrégalo para empezar a trabajar o ábrelo en GitHub.", "1aa7c952b9": "Vista del proyecto", "f352abf7c3": "La lista de repositorios se está actualizando.", "1ce21b8cff": "Este elemento está fuera de los repositorios seleccionados.", @@ -1953,15 +1956,15 @@ "dialog": { "AssigneesEditor": { "529fec247b": "Cargando…", - "98914e6b36": "Cesionarios:", + "98914e6b36": "Asignados:", "94a4e6e4fa": "ninguno" }, "Comments": { "fd5cccd138": "Comentario", "1c95937c8b": "Escribe un comentario…", "c0e576e96b": "Cancelar", - "c3e829b4d9": "Ahorrar", - "463d030ae4": "Borrar", + "c3e829b4d9": "Guardar", + "463d030ae4": "Eliminar", "8564f58542": "Editar", "5f104bf855": "Aún no hay comentarios.", "commentTooLarge": "El comentario es demasiado grande para enviarlo de forma segura." @@ -1975,11 +1978,11 @@ "598ad6a517": "Comentarios", "41169e41fb": "Añade una descripción…", "a91735d19f": "Cancelar", - "e64f6c3eff": "Ahorrar", + "e64f6c3eff": "Guardar", "e4ef8281e9": "Cargando…", - "ae98897edf": "Cerca", + "ae98897edf": "Cerrar", "69caf40ae8": "Abrir en GitHub", - "7c302f8174": "Intitulado" + "7c302f8174": "Sin título" } } } @@ -1988,7 +1991,7 @@ "015b4e607d": "Cancelar", "e3bd59143c": "Insertar", "f24783f470": "https://...", - "ec6310b731": "Utilice una URL de imagen http:// o https://.", + "ec6310b731": "Usa una URL de imagen http:// o https://.", "b7e4a1c902": "Pega, suelta o haz clic para agregar archivos", "8f1c2d4e6a": "No hay nada que previsualizar", "c91f0a2b14": "Escribir", @@ -1996,15 +1999,15 @@ "imageUrlTooLarge": "La URL de la imagen es demasiado grande." }, "IssueSourceSelector": { - "d6aeb2012b": "Mostrando problemas de", - "787c970baf": "Fuente del problema", - "643d7e9496": "río arriba", + "d6aeb2012b": "Mostrando issues de", + "787c970baf": "Fuente de issues", + "643d7e9496": "upstream", "51d1608920": "Origen", "cdc9bd64fa": "compacto", - "30b2c9df91": "Río arriba" + "30b2c9df91": "Ascendente" }, "PRFilterDropdowns": { - "979be3cf6b": "Cesionario", + "979be3cf6b": "Asignado", "b27b7e526c": "Revisión de", "7f1ba66c3e": "Revisado por", "9d0f2eda6d": "Etiqueta", @@ -2012,11 +2015,11 @@ "13b3ac0a84": "Estado", "79c54552f7": "Filtros", "8a2ffbf9b3": "Quitar filtro {{value0}}", - "19bb6f115f": "revisado por" + "19bb6f115f": "reviewed-by" }, "PRFilterPickers": { - "fdf387297c": "Claro (", - "472c12ae03": "Claro", + "fdf387297c": "Limpiar (", + "472c12ae03": "Limpiar", "2d1f58eda6": "Usar" }, "PRFilterSections": { @@ -2025,24 +2028,24 @@ "94b42b0edf": "Revisión solicitada", "de26e2eb06": "Sin etiquetas", "458ea3602b": "Sin autores", - "b69fa4fa20": "Atrás", + "b69fa4fa20": "Volver", "30ebb6ca44": "Limpiar todos los filtros", "8177eda37e": "Filtrar", - "ea3416d646": "Cesionario", + "ea3416d646": "Asignado", "b1d9fdea08": "Etiqueta", "24754c44ad": "Autor", "764a0b4ce1": "Estado", - "f0cf6dd591": "apagado", - "1e9b5244f2": "en", + "f0cf6dd591": "desactivado", + "1e9b5244f2": "activado", "b930de7194": "Solo borrador", "e0002f1eba": "seleccionado", - "2b2f019091": "cualquier estado", + "2b2f019091": "Cualquier estado", "0fd3249e2e": "Cerrado", "d78b60b5c2": "Abierto", - "bd162b7d5a": "Fusionado", - "2e639b84fa": "crítico", + "bd162b7d5a": "Mergeado", + "2e639b84fa": "reviewer", "712c5abdbf": "etiqueta", - "4e50c7bc03": "cesionario", + "4e50c7bc03": "assignee", "7bf3a6e5ac": "autor", "66256a73b3": "estado", "3ce4d5e96e": "PRs" @@ -2056,14 +2059,14 @@ "d12d3d6f33": "Actualizar el presupuesto de la API de GitHub", "d5e5de9070": "Orca usa REST, Search y GraphQL a través de la CLI de GitHub.", "58c5f88216": "Presupuesto de la API de GitHub", - "6da1858354": "izquierda · se reinicia en", + "6da1858354": "restantes · se reinicia en", "f42790d150": "de", "01f7323e58": "API GraphQL", - "1daf0f22a9": "GrafoQL", + "1daf0f22a9": "GraphQL", "1f2f28a4de": "API de búsqueda", "c377a4f06a": "Buscar", - "c392c749a6": "API DESCANSO", - "bb227706a6": "DESCANSAR", + "c392c749a6": "API REST", + "bb227706a6": "REST", "budget_scope_prefix": "Ámbito del presupuesto" } } @@ -2087,9 +2090,9 @@ "commentTooLarge": "El comentario es demasiado grande para enviarlo de forma segura." }, "GitHubWorkItemAssigneePopoverContent": { - "cddd9b04a7": "Cargando responsables", + "cddd9b04a7": "Cargando asignados", "a00830d3f7": "No hay usuarios", - "4f8b6f2c1d": "Filtrar responsables..." + "4f8b6f2c1d": "Filtrar asignados..." }, "GitHubWorkItemLabelPopoverContent": { "2aa9acdf34": "Editar etiquetas en GitHub", @@ -2120,13 +2123,13 @@ "f8f704a019": "Cancelar", "e603ee9156": "Configuración de la API del espacio de trabajo", "dc7ccb0f7c": "Claves API personales", - "e3100b36b9": "Si las claves API de los miembros están bloqueadas, solicite a un administrador del espacio de trabajo que las permita desde la configuración de la API del espacio de trabajo.", - "d56d3629f4": "Prefiera el acceso completo cuando Orca debería mostrar todos los equipos a los que la cuenta puede acceder en ese espacio de trabajo. Las claves restringidas solo exponen los equipos permitidos y los equipos privados requieren que el propietario de la clave tenga acceso.", - "af52a6227f": "Cree una clave API personal desde Cuenta > Seguridad y acceso.", + "e3100b36b9": "Si las claves API de miembros están bloqueadas, pide a un administrador del workspace que las permita desde los ajustes de API del workspace.", + "d56d3629f4": "Prefiere acceso completo cuando Orca deba mostrar todos los equipos a los que la cuenta puede acceder en ese workspace. Las claves restringidas solo exponen equipos permitidos, y los equipos privados requieren que el owner de la clave tenga acceso.", + "af52a6227f": "Crea una clave API personal desde Account > Security & Access.", "edec49dfae": "lin_api_...", "7d498f653c": "Clave API personal", "57a66522c8": "conectando", - "c9889a09f8": "Utilice Linear para elegir el espacio de trabajo deseado antes de crear la clave.", + "c9889a09f8": "Usa Linear para elegir el workspace correcto antes de crear la clave.", "e689a4d0a6": "error" } } @@ -2140,13 +2143,13 @@ "view": { "surfaces": { "8bbecb2510": "Ninguno", - "e1fa97d21d": "Seleccione un proyecto para ver su descripción general.", + "e1fa97d21d": "Selecciona un proyecto para ver su resumen.", "1748d3b9af": "Etiquetas", "65bda65159": "Miembros", - "c5f79616c3": "equipos", + "c5f79616c3": "Equipos", "25a2196732": "Objetivo", - "3fb6473111": "Comenzar", - "111bef9aa8": "Dirigir", + "3fb6473111": "Inicio", + "111bef9aa8": "Líder", "3be47aed6f": "Prioridad", "f5ef24cf46": "Salud", "9ddb58edbd": "Estado", @@ -2154,17 +2157,17 @@ "c8db98b73b": "Recursos", "bb1405eff8": "Hitos", "5d99315fb8": "Planificación", - "3ad562bdf4": "problemas de alcance", + "3ad562bdf4": "issues con alcance", "563501f191": "Progreso", "bb5664d456": "Sin descripción del proyecto.", "7b147907dc": "Linear", - "a9785c7158": "Refrescar", - "ee3d2caabd": "Asuntos", + "a9785c7158": "Actualizar", + "ee3d2caabd": "Issues", "5f79bc76b0": "Volver a proyectos", "aac9a4afc6": "Abrir en Linear", - "7616c986c6": "Problemas abiertos {{value0}}", + "7616c986c6": "Abrir issues de {{value0}}", "93e1f6bfca": "Cargando", - "98730088a6": ". Busque o abra Linear para ver el conjunto completo.", + "98730088a6": ". Busca o abre Linear para ver el conjunto completo.", "06b887d622": "Mostrando primero", "2c4b1c2c08": "número", "f2cc1e0ff6": "Linear / Proyectos", @@ -2173,14 +2176,14 @@ "20b9d09b7d": "Desconocido", "f059181bd9": "Privado", "27d91cb1a6": "Compartido", - "9f0f51fd9e": "Cree o guarde vistas en Linear y luego actualice.", - "f4c79cff5f": "Revise el error del espacio de trabajo a continuación y luego actualice.", + "9f0f51fd9e": "Crea o guarda vistas en Linear y luego actualiza.", + "f4c79cff5f": "Revisa el error del espacio de trabajo abajo y luego actualiza.", "ef90b21366": "No se encontraron vistas", "c0a50f96a4": "No se pueden cargar vistas", "df4bd63c1d": "No asignado", - "30402d2c6e": "Intente buscar o actualizar.", - "a2f31c4cd6": "No se encontraron proyectos Lineares", - "c9b6e9f90d": "No se pueden cargar proyectos Lineares" + "30402d2c6e": "Intenta buscar o actualizar.", + "a2f31c4cd6": "No se encontraron proyectos de Linear", + "c9b6e9f90d": "No se pueden cargar proyectos de Linear" } } }, @@ -2188,12 +2191,12 @@ "selector": { "91c8871dad": "Agregar acceso al equipo", "7783361266": "Todos los equipos", - "e1ae6bebb0": "equipos", + "e1ae6bebb0": "Equipos", "a14ce4df2b": "Todos los espacios de trabajo", "05baa5ae90": "Espacio de trabajo", - "89f6580dbf": "Equipos de búsqueda...", - "b3488fad3c": "No se buscó ningún equipo. El acceso puede depender del alcance de la clave, la membresía de un equipo privado, los equipos archivados, los permisos o un error de recuperación.", - "405b33c378": "Ningún equipo encontrado coincide con su búsqueda." + "89f6580dbf": "Buscar equipos...", + "b3488fad3c": "No se obtuvo ningún equipo. El acceso puede depender del scope de la clave, membresía en equipos privados, equipos archivados, permisos o un error al obtener datos.", + "405b33c378": "Ningún equipo obtenido coincide con tu búsqueda." } } }, @@ -2201,14 +2204,14 @@ "sound": { "options": { "e38b0a2e68": "Bip", - "0acd3d384e": "Charla", - "79919c832d": "Timbre", + "0acd3d384e": "Clack", + "79919c832d": "Ding", "2b44847d8d": "Blop", "020826ef17": "Sonar", - "588c90487d": "Punto luminoso en un radar", - "1e4b81d892": "Golpear", - "86af8d938c": "bong", - "80f7cc95b3": "dos tonos", + "588c90487d": "Blip", + "1e4b81d892": "Thump", + "86af8d938c": "Bong", + "80f7cc95b3": "Dos tonos", "017abebfa6": "Valor predeterminado del sistema" } } @@ -2216,32 +2219,32 @@ "worktree": { "creation": { "WorktreeCreationPanel": { - "dabd226118": "Despedir", - "34dd5ee38b": "Rever", - "ed2a664f8b": "No se pudo crear el árbol de trabajo", - "a3346fc6ed": "Cancelar la creación del árbol de trabajo", + "dabd226118": "Descartar", + "34dd5ee38b": "Reintentar", + "ed2a664f8b": "No se pudo crear el worktree", + "a3346fc6ed": "Cancelar creación del worktree", "532aea14ce": "Cancelar", - "767951265d": "Algo salió mal al crear el árbol de trabajo.", - "vmProvisioningTitle": "Aprovisionamiento de máquina virtual", + "767951265d": "Algo salió mal al crear el worktree.", + "vmProvisioningTitle": "Provisionando VM", "cancelProvisioning": "Cancelar", - "vmProvisioningLogEmpty": "Esperando la salida de la receta..." + "vmProvisioningLogEmpty": "Esperando output de la receta..." } } }, "workspace": { "space": { "WorkspaceSpacePage": { - "8d0048e1cb": "Uso del disco del espacio de trabajo y almacenamiento recuperable del árbol de trabajo.", + "8d0048e1cb": "Uso de disco del espacio de trabajo y almacenamiento de worktrees recuperable.", "e8d6ba11ab": "Beta", "45f6302dbc": "Espacio", - "ecf72fdc3b": "Atrás" + "ecf72fdc3b": "Volver" } }, "cleanup": { "WorkspaceCleanupDialog": { "3828408538": "Quitar {{value0}}", "352f15d6fc": "Último activo", - "cbf2f664e2": "Borrar", + "cbf2f664e2": "Eliminar", "b6bae1eed1": "Cancelar", "592fbab446": "Ordenado por actividad más antigua", "dba753e94f": "eliminar", @@ -2249,44 +2252,44 @@ "c4f4782c02": "No sugerido", "0a2e3c7cba": "Revisar", "e97e4580c7": "Sucio", - "9623a5107d": "commits no enviadas", + "9623a5107d": "commits sin push", "e8b3741ff7": "ignorado", "a9957007eb": "Ignorar {{value0}}", "1bffc07ba7": "Ver {{value0}}", "bef0adef9b": "Rama", - "0b1766738a": "Repo", - "bbb1ab6a6f": "Seleccione {{value0}}", + "0b1766738a": "Repositorio", + "bbb1ab6a6f": "Selecciona {{value0}}", "d1094dd529": "Necesita revisión", "4b93a235d8": "sugerido", "f68d538c63": "No hay espacios de trabajo en este conjunto de limpieza.", "4719327c9c": "Se ignoran todas las sugerencias de limpieza.", - "a19040cd67": "Ningún espacio de trabajo inactivo coincide con los repos seleccionados.", + "a19040cd67": "Ningún espacio de trabajo inactivo coincide con los repositorios seleccionados.", "97c772c4fe": "No se encontraron espacios de trabajo inactivos en los repositorios marcados.", "d3eef9463d": "No hay espacios de trabajo inactivos para eliminar.", "aaee139eab": "Restaurar sugerencias ignoradas", "06cf78521e": "Seleccionar todo en {{value0}}", "73690b0031": "Deseleccionar todo en {{value0}}", - "b771c92598": "Eliminar seleccionado", + "b771c92598": "Eliminar seleccionados", "37ab28277e": "no sugerido", "1b18868569": "necesita revisión", "b299f201b9": "seguro para eliminar", "2b31bf68de": "inactivo", "ac5ba84cc1": "seleccionado", - "8b74d4ea6e": "Escaneando árboles de trabajo y estado de git, luego combinando señales de pestaña abierta, terminal, agente en vivo y disponibilidad remota antes de sugerir eliminaciones.", - "7eee951968": "Comprobar la seguridad del lugar de trabajo", - "191f0bc98e": "Cerca", - "7ae2ad30f4": "Refrescar", - "e0b5a4deaa": "Revise los espacios de trabajo inactivos antes de eliminar sus archivos locales y el estado de Orca.", + "8b74d4ea6e": "Escaneando worktrees y estado de git, luego combinando señales de pestañas abiertas, terminal, agente en vivo y disponibilidad remota antes de sugerir eliminaciones.", + "7eee951968": "Verificando seguridad del espacio de trabajo", + "191f0bc98e": "Cerrar", + "7ae2ad30f4": "Actualizar", + "e0b5a4deaa": "Revisa los espacios de trabajo inactivos antes de eliminar sus archivos locales y el estado de Orca.", "b2c1331844": "Eliminar espacios de trabajo inactivos", - "41d594d01e": "{{value0}} espacio de trabajo {{value1}} no se pudo eliminar", + "41d594d01e": "No se pudo eliminar {{value0}} espacio de trabajo{{value1}}", "0f00612b6d": "Se eliminó {{value0}} espacio de trabajo{{value1}}", "7f451a3e2c": "No se pudo ignorar la sugerencia de limpieza", - "662b8ec3f8": "Error en el análisis de limpieza del espacio de trabajo", + "662b8ec3f8": "Falló el escaneo de limpieza del espacio de trabajo", "bc43c37faf": "oculto", "0c6672f5e3": "Sugerencias de limpieza ignoradas", - "fc49f79434": "mezclado", - "2ddbd6fe8a": "comprobado", - "ee81adfcef": "Vista", + "fc49f79434": "mixto", + "2ddbd6fe8a": "marcado", + "ee81adfcef": "Ver", "4d0b72481c": "Ignorar", "9cc26c019d": "Eliminar", "0e2d235c63": "Escaneo del espacio de trabajo inactivo listo", @@ -2297,12 +2300,12 @@ "e94b1f8bb4": "Limpiar filtros", "efb3843e75": "Filtrar y ordenar espacios de trabajo", "93b7381d50": "Filtros", - "a615e24679": "Clasificar", - "4cc5b73efe": "Encontrar espacios de trabajo inactivos...", - "5bf2e88480": "{{value0}}/{{value1}} {{value2}} escaneado", + "a615e24679": "Ordenar", + "4cc5b73efe": "Buscando espacios de trabajo inactivos...", + "5bf2e88480": "{{value0}}/{{value1}} {{value2}} escaneados", "searchPlaceholder": "Buscar espacios de trabajo", "ageFilter": "Edad", - "reviewFilter": "Revisar", + "reviewFilter": "Revisión", "gitFilter": "Git", "contextFilter": "Contexto", "sortBy": "Ordenar por", @@ -2314,18 +2317,18 @@ "color": { "picker": { "ebcf6ba29e": "Color hexadecimal no válido.", - "faa855a582": "Maleficio", + "faa855a582": "Hex", "1cec618bcc": "{{value0}} selector" } }, "dialog": { - "f26c4baeda": "Cerca" + "f26c4baeda": "Cerrar" }, "repo": { "multi": { "combobox": { "286ce70256": "SSH", - "4471d4a1c0": "Ningún proyecto coincide con su búsqueda.", + "4471d4a1c0": "Ningún proyecto coincide con tu búsqueda.", "bfd8ce21c6": "Todos los proyectos", "a58a0cd100": "Buscar proyectos...", "65a3dae41d": "Sin proyectos" @@ -2333,14 +2336,14 @@ } }, "sheet": { - "1189e9fe0a": "Cerca" + "1189e9fe0a": "Cerrar" }, "team": { "multi": { "combobox": { "de83523bf9": "Ningún equipo coincide con tu búsqueda.", "301f2a796e": "Todos los equipos", - "18ec58881e": "Equipos de búsqueda..." + "18ec58881e": "Buscar equipos..." } } } @@ -2349,33 +2352,33 @@ "quick": { "commands": { "TerminalQuickCommandActionToggle": { - "b0d58e37ed": "Mensaje del Agent", - "b5ea4d64f6": "Comando terminal" + "b0d58e37ed": "Prompt de agente", + "b5ea4d64f6": "Comando de terminal" }, "TerminalQuickCommandAppendEnterSwitch": { - "e4e5fed3b3": "Alternar agregar Ingresar", - "c936c2d6d2": "Envíelo inmediatamente en lugar de solo insertar texto.", - "5fa607d807": "Agregar Ingresar" + "e4e5fed3b3": "Alternar agregar Enter", + "c936c2d6d2": "Enviar inmediatamente en lugar de solo insertar texto.", + "5fa607d807": "Agregar Enter" }, "TerminalQuickCommandDialog": { "925b8e0f6e": "Avanzado", - "97e96cc027": "/meta", - "e604bd40d6": "Admite habilidades, rutas de archivos y comandos integrados como", - "79af0c0841": "npm ejecutar desarrollador", - "577a342c7d": "Pídale al agente que investigue este espacio de trabajo.", - "026cfb232a": "No admite comandos rápidos", + "97e96cc027": "/goal", + "e604bd40d6": "Admite skills, rutas de archivos y comandos integrados como", + "79af0c0841": "npm run dev", + "577a342c7d": "Pide al agente que investigue este espacio de trabajo", + "026cfb232a": "No admite comandos de prompt", "346d409ab2": "Elige agente", "0adba8fa0c": "Agente", "ec8f081919": "Acción", - "ed04233b3e": "Guarde los comandos del terminal o las indicaciones del agente para un acceso rápido.", + "ed04233b3e": "Guarda comandos de terminal o prompts de agente para acceso rápido.", "ca414324ee": "Texto de comando", - "dc921c17ee": "Inmediato", + "dc921c17ee": "Prompt", "5b3f634a55": "Agregar comando rápido", "f9b184fc16": "Editar comando rápido", "6751598542": "editar" }, "TerminalQuickCommandDialogFooter": { - "2e2b958dfc": "Ahorrar", + "2e2b958dfc": "Guardar", "8dff838dea": "Guardar ({{value0}})", "28370f16b9": "Cancelar" }, @@ -2384,21 +2387,21 @@ "db17f1e41e": "Etiqueta" }, "TerminalQuickCommandScopeField": { - "2db6edede7": "Al guardar se mantiene el alcance del proyecto existente a menos que elija otro.", + "2db6edede7": "Al guardar se mantiene el alcance del proyecto existente a menos que elijas otro.", "2496523a6f": "Elige proyecto", "2264edd5d3": "Proyecto no en la lista", "3834d24243": "Proyecto", "b83efc79e2": "Global", "c25cf350ef": "Alcance", - "f0631e4999": "repo" + "f0631e4999": "repositorio" } } }, "pane": { "CloseTerminalDialog": { - "ebd2fa844d": "Cerca", + "ebd2fa844d": "Cerrar", "1d1a7a9c1f": "Cancelar", - "6b9a6975f8": "La terminal todavía tiene un proceso en ejecución. Si cierra la terminal, el proceso finalizará.", + "6b9a6975f8": "El terminal aún tiene un proceso en ejecución. Si cierras el terminal, el proceso se terminará.", "78b79d854d": "¿Cerrar terminal?", "stop_agent_title": "¿Detener este agente?", "stop_command_title": "¿Detener comando en ejecución?", @@ -2409,27 +2412,27 @@ "stop_command_confirm": "Detener y cerrar" }, "MobileDriverOverlay": { - "c6460cf584": "Devolver", - "c8f2e1a4b9": "Recuperar este terminal", - "c44659e09f": "Teléfono al mando", - "7cffad954c": "Colapsar", + "c6460cf584": "Recuperar control", + "c8f2e1a4b9": "Recuperar control de este terminal", + "c44659e09f": "Teléfono controlando", + "7cffad954c": "Contraer", "3eed73394f": "Tu teclado está en pausa", "faa367dc74": "Tu teléfono dejó esto en tamaño móvil", - "54f7d6f69d": "Recuperar todos los terminales", + "54f7d6f69d": "Recuperar control de todos los terminales", "b3d8e1f42a": "Restaurar este terminal", "e8c4f2a91b": "Restaurar todos los terminales", "f2a8b9c1d3": "Desde tu teléfono", - "c7e4a2b8f1": "Tu teléfono está al mando", - "d9f3c6e2a4": "El teclado del escritorio está en pausa. Recupera este terminal para escribir aquí, recupera todos los terminales que controla tu teléfono, o colapsa para seguir mirando.", + "c7e4a2b8f1": "Tu teléfono tiene el control", + "d9f3c6e2a4": "El teclado de escritorio está en pausa. Recupera este terminal para escribir aquí, recupera todos los terminales que controla tu teléfono o contrae para seguir observando.", "a6b1d8f3e2": "La sesión del teléfono terminó. Restaura el tamaño de escritorio para este terminal o para todos los terminales que tu teléfono dejó en tamaño móvil." }, "TerminalAgentSessionForkDialog": { "17fc841e59": "Copiar contexto", - "0c8a8629b1": "La bifurcación aparece como su propio espacio de trabajo, no como un hijo anidado. El nuevo agente recibe una transcripción encuadernada como borrador editable.", - "620461df22": "Bifurcación de nivel superior", - "619b5a35d2": "Cree una bifurcación del espacio de trabajo de nivel superior e inicie una nueva pestaña de agente con contexto capturado.", - "64e292e8e3": "Sesión de Agent de bifurcación", - "9d25de2920": "crear bifurcación", + "0c8a8629b1": "El fork aparece como su propio espacio de trabajo, no como un hijo anidado. El nuevo agente recibe una transcripción acotada como borrador editable.", + "620461df22": "Fork de nivel superior", + "619b5a35d2": "Crea un fork de espacio de trabajo de nivel superior e inicia una nueva pestaña de agente con el contexto capturado.", + "64e292e8e3": "Fork de sesión de agente", + "9d25de2920": "Crear fork", "2b10412cfc": "Creando..." }, "TerminalContextMenu": { @@ -2440,25 +2443,25 @@ "39809d152f": "Establecer título…", "clearPaneTitle": "Borrar título del panel", "06c2b0f043": "Igualar tamaños de paneles", - "98bccf4fa2": "Terminal dividido hacia abajo", - "20e565d865": "Terminal dividido a la derecha", - "8a7ddb8b8a": "Sesión de Agent de bifurcación...", + "98bccf4fa2": "Dividir terminal hacia abajo", + "20e565d865": "Dividir terminal a la derecha", + "8a7ddb8b8a": "Fork de sesión de agente...", "0a82b0608c": "Agregar comando rápido…", "9528a65ef8": "Sin comandos rápidos", "3ce594a4a0": "Global", "ec85df5914": "Comandos rápidos", - "0a917b591a": "Pasta", + "0a917b591a": "Pegar", "f3eeb1de13": "Copiar", "c2f0b72b8d": "Insertar", "925f49f210": "Expandir panel", "df766809e0": "Contraer panel", - "cff67afad1": "Copy Context" + "cff67afad1": "Copiar contexto" }, "TerminalErrorToast": { - "e4aa243f8c": "Reiniciar demonio", - "a7e2fd2699": "presentar un problema", - "5c8ce20be6": "Si esto persiste por favor", - "cc6d997c65": "Reinicie el demonio del terminal desde aquí para borrar el estado obsoleto del demonio." + "e4aa243f8c": "Reiniciar servicio", + "a7e2fd2699": "abre un issue", + "5c8ce20be6": "Si esto persiste, por favor", + "cc6d997c65": "Reinicia el servicio del terminal desde aquí para borrar el estado obsoleto." }, "TerminalPane": { "ac112e9036": "Quitar título", @@ -2467,19 +2470,19 @@ "7dbbfcbecc": "Título del panel" }, "TerminalSessionStateSaveFailureDialog": { - "6bee0c8f17": "Analizador de espacio en disco abierto", - "ae20d0ffc2": "Despedir", - "38c282a2c4": "El analizador se abre directamente desde aquí. También puede abrirlo más tarde desde el menú de la caja de herramientas inferior izquierda eligiendo Space Analyzer.", - "e2fcf07c0d": "Orca no pudo guardar esta sesión de terminal porque el almacenamiento local está lleno o no se puede escribir. Abra el analizador de espacio en disco para encontrar espacio de almacenamiento que pueda limpiar.", + "6bee0c8f17": "Abrir analizador de espacio en disco", + "ae20d0ffc2": "Descartar", + "38c282a2c4": "El analizador se abre directamente desde aquí. También puedes abrirlo más tarde desde el menú de herramientas inferior izquierdo eligiendo Space Analyzer.", + "e2fcf07c0d": "Orca no pudo guardar esta sesión de terminal porque el almacenamiento local está lleno o no permite escritura. Abre el analizador de espacio en disco para encontrar almacenamiento de espacios de trabajo que puedas limpiar.", "678c780a2c": "El espacio en disco no está disponible" }, "osc52": { "clipboard": { "blocked": { "toast": { - "97c98f1afe": "Abrir configuración", - "7cf51f74fd": "Habilite las escrituras en el portapapeles TUI en la configuración de Terminal para copiar desde SSH, tmux, Neovim o fzf.", - "89eaa3e80b": "Escritura en el portapapeles del terminal bloqueada" + "97c98f1afe": "Abrir ajustes", + "7cf51f74fd": "Habilita las escrituras al portapapeles TUI en los ajustes de Terminal para copiar desde SSH, tmux, Neovim o fzf.", + "89eaa3e80b": "Escritura al portapapeles del terminal bloqueada" } } } @@ -2487,8 +2490,8 @@ "stale": { "agent": { "row": { - "ad991ece5c": "El panel del Agent ya no está disponible.", - "090d607412": "fila-agente-obsoleto-{{value0}}" + "ad991ece5c": "El panel del agente ya no está disponible.", + "090d607412": "stale-agent-row-{{value0}}" } } }, @@ -2496,13 +2499,13 @@ "agent": { "session": { "fork": { - "2317900211": "No se pudo copiar el contexto de la bifurcación.", - "88e34d00eb": "Bifurcación de sesión de nivel superior abierta en un nuevo espacio de trabajo", - "fd3d12a1e1": "No se pudo crear el espacio de trabajo de bifurcación.", - "38e41edc6e": "Este espacio de trabajo no se puede bifurcar en un árbol de trabajo de git.", - "f867385bb5": "No se pudo encontrar el espacio de trabajo de origen para esta bifurcación.", - "046e8d853c": "No hay contexto de terminal para bifurcar", - "c00421d320": "Se copió el contexto de la bifurcación. Inicie un agente y péguelo para iniciar la bifurcación.", + "2317900211": "No se pudo copiar el contexto del fork.", + "88e34d00eb": "Fork de sesión de nivel superior abierto en un nuevo espacio de trabajo", + "fd3d12a1e1": "No se pudo crear el espacio de trabajo del fork.", + "38e41edc6e": "Este espacio de trabajo no puede hacer fork hacia un worktree de git.", + "f867385bb5": "No se pudo encontrar el espacio de trabajo de origen para este fork.", + "046e8d853c": "No hay contexto de terminal para hacer fork", + "c00421d320": "Se copió el contexto del fork. Inicia un agente y pégalo para iniciar el fork.", "f62b40e2c7": "No hay contexto de terminal para copiar", "373a3103e7": "Contexto copiado", "3fc568a49d": "No se pudo copiar el contexto." @@ -2511,16 +2514,16 @@ }, "drop": { "handler": { - "1e072f611e": "No se pudo cargar {{value0}} {{value1}}.", - "53f015fd85": "Se omitió el enlace simbólico {{value0}}{{value1}}.", - "29c031b49a": "Subiendo el archivo {{value0}}{{value1}} al tiempo de ejecución...", - "0c77693641": "Worktree no está listo; inténtalo de nuevo en un momento.", - "ce8248b835": "Ruta del árbol de trabajo no disponible.", - "internalTooManyPaths": "La operación contiene demasiadas rutas para pegarlas de forma segura en la terminal.", - "internalPathsTooLarge": "La lista de rutas es demasiado grande para pegarla de forma segura en la terminal.", + "1e072f611e": "No se pudo subir {{value0}} {{value1}}.", + "53f015fd85": "Se omitió {{value0}} symlink{{value1}}.", + "29c031b49a": "Subiendo {{value0}} archivo{{value1}} al host…", + "0c77693641": "El worktree no está listo; inténtalo de nuevo en un momento.", + "ce8248b835": "Ruta del worktree no disponible.", + "internalTooManyPaths": "El drop contiene demasiadas rutas para pegarlas de forma segura en el terminal.", + "internalPathsTooLarge": "La lista de rutas del drop es demasiado grande para pegarla de forma segura en el terminal.", "b4cf68e889": "Se omitió {{value0}} {{value1}}.", - "writeTimeout": "Se canceló la suelta de archivos: la terminal no aceptó la ruta antes del tiempo de seguridad.", - "writeRejected": "Se canceló la suelta de archivos: la terminal no pudo aceptar la ruta." + "writeTimeout": "Se canceló el drop de archivos: el terminal no aceptó la ruta antes del tiempo límite de seguridad.", + "writeRejected": "Se canceló el drop de archivos: el terminal no pudo aceptar la ruta." } } }, @@ -2568,7 +2571,7 @@ "TabGroupPanel": { "814fb04c43": "Cargando editor...", "f7d6ce445e": "Cerrar grupo", - "0db2081805": "Partir", + "0db2081805": "Dividir hacia arriba", "30137df7d0": "Dividir a la izquierda", "4df2a06d36": "Dividir hacia abajo", "ab1e2bff04": "Dividir a la derecha", @@ -2585,8 +2588,10 @@ "localWorkspacesOnly": "Reanudar desde el historial solo está disponible en espacios de trabajo locales.", "openLocalWorkspace": "Abre un espacio de trabajo local antes de reanudar una sesión.", "sessionQueued": "Sesión en cola", - "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", - "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session." + "runtimeWorkspacesUnsupported": "Reanudar desde el historial no está disponible en espacios de trabajo alojados en un host.", + "openSupportedWorkspace": "Abre un espacio de trabajo local o SSH antes de reanudar una sesión.", + "localSessionSshWorkspaceUnsupported": "El historial de esta sesión solo existe en este equipo. Para reanudarla, arrástrala a un workspace local. Los workspaces SSH no tienen acceso a ese historial.", + "sessionHostMismatchUnsupported": "Esta sesión pertenece a un host diferente. Suéltala en un espacio de trabajo del mismo host." }, "TabGroupDropOverlay": { "paneColumnLabel": "Nueva división" @@ -2596,13 +2601,13 @@ "BrowserTab": { "6e0bc8f3a8": "Abrir en el navegador", "9dd880bd56": "Cerrar pestañas a la derecha", - "1611a1324b": "Cerca", + "1611a1324b": "Cerrar", "5d6e89891f": "Duplicar pestaña", "966feb9ad5": "Dividir a la derecha", "7e8106899f": "Dividir a la izquierda", "2186a8407c": "Dividir hacia abajo", - "96354ed249": "Partir", - "911542656f": "Pestaña Fijar", + "96354ed249": "Dividir hacia arriba", + "911542656f": "Fijar pestaña", "c5aaee8c39": "Desanclar pestaña" }, "EditorFileTab": { @@ -2611,24 +2616,24 @@ "EditorFileTabContextMenu": { "52ce4f4605": "Copiar ruta relativa", "5b85754786": "Copiar ruta", - "bfd5797ef4": "Abrir vista previa de Markdowns", + "bfd5797ef4": "Abrir vista previa de Markdown", "e5ff31ccaf": "Cerrar pestañas a la derecha", "ba1369dd24": "Cerrar todas las pestañas del editor", - "1ba8492c5b": "Cerca", - "68cc610e7f": "Rebautizar", + "1ba8492c5b": "Cerrar", + "68cc610e7f": "Renombrar", "f7c3d7d5af": "Dividir a la derecha", "e3ff145b98": "Dividir a la izquierda", "1d04b1630b": "Dividir hacia abajo", - "6b3efb106e": "Partir", - "fdd29eb669": "Pestaña Fijar", + "6b3efb106e": "Dividir hacia arriba", + "fdd29eb669": "Fijar pestaña", "8e9d603a09": "Desanclar pestaña" }, "QuickLaunchButton": { - "348a04c1ad": "Configuración del Agent...", - "ec2adf093e": "Inicie {{value0}} en una nueva terminal", + "348a04c1ad": "Ajustes de Agents…", + "ec2adf093e": "Iniciar {{value0}} en un nuevo terminal", "465e432ef1": "No se pudo generar el comando de inicio para {{value0}}.", - "e518f544b1": "No se detectaron agents", - "8dea9b5cdf": "No hay agents habilitados" + "e518f544b1": "No se detectaron agentes", + "8dea9b5cdf": "No hay agentes habilitados" }, "RecentTabSwitcher": { "329638ff6f": "Cambiar pestaña", @@ -2644,14 +2649,14 @@ "35e8892fd0": "Color de pestaña", "2f697b3c31": "Cambiar título", "c1ee099c7e": "Cerrar pestañas a la derecha", - "8d16f9cd30": "Cerrar Otros", - "89359a36f7": "Cerca", + "8d16f9cd30": "Cerrar otras", + "89359a36f7": "Cerrar", "21132389e9": "Dividir a la derecha", "0ce4bae39d": "Dividir a la izquierda", "af80ed83c1": "Dividir hacia abajo", - "591f9b12c1": "Partir", + "591f9b12c1": "Dividir hacia arriba", "7703990447": "Gris", - "845576bed1": "verde azulado", + "845576bed1": "Verde azulado", "be905e9b0a": "Verde", "69682e2ce4": "Amarillo", "a47629b3cf": "Naranja", @@ -2660,59 +2665,59 @@ "c2d8b0991f": "Púrpura", "cb3eadefd2": "Azul", "20baa43c05": "Ninguno", - "60f958ec75": "Pestaña Fijar", + "60f958ec75": "Fijar pestaña", "417722e9c2": "Desanclar pestaña", "splitTerminalRight": "Dividir terminal a la derecha", "splitTerminalDown": "Dividir terminal hacia abajo" }, "TabBar": { "b1a132357f": "Nueva pestaña", - "4f327c8b3d": "Abrir Markdowns...", - "3d5d6c960d": "Nueva Markdown", + "4f327c8b3d": "Abrir Markdown...", + "3d5d6c960d": "Nuevo Markdown", "fd2b42aaa3": "Nuevo emulador móvil", - "aea43b5748": "Abra la pestaña del emulador existente.", + "aea43b5748": "Abrir la pestaña del emulador existente.", "b426bb2615": "Ir al emulador móvil", - "4833fb2cbe": "Nueva pestaña del navegador", - "d364f3c8d4": "Nueva Terminal", - "7c1313d237": "Nueva terminal:", + "4833fb2cbe": "Nueva pestaña de navegador", + "d364f3c8d4": "Nuevo terminal", + "7c1313d237": "Nuevo terminal:", "d1afac112b": "WSL", - "efb33546ff": "Git bash", - "1a8af49530": "Mensaje CMD", + "efb33546ff": "Git Bash", + "1a8af49530": "CMD Prompt", "2148f65e04": "PowerShell", "ab589350e5": "No se pudo generar el comando de inicio para {{value0}}.", "7a9b4af2af": "Desplazar pestañas a la izquierda", "232e075b07": "Desplazar pestañas a la derecha" }, "TabBarCreateEntry": { - "d62d63b807": "crear archivo", + "d62d63b807": "Crear archivo", "25dc1cd653": "Abrir archivo", "7cdf8ee0c8": "Abrir URL", - "b27864279e": "Agente de lanzamiento", - "39676a184c": "Abra cualquier archivo, URL, agente, ..." + "b27864279e": "Iniciar agente", + "39676a184c": "Abrir cualquier archivo, URL, agente, ..." }, "TabBarQuickCommandsButton": { - "a2c7a33831": "Agregar comando rápido…", + "a2c7a33831": "Comando", "20bbd75896": "Sin comandos", - "b82e237a4b": "Comandos más rápidos", + "b82e237a4b": "Más comandos rápidos", "85482c57bc": "Ejecutar comando rápido", "b775303755": "Ejecutar comando rápido: {{value0}}", "196593b6a9": "Quitar {{value0}}", "15529ede69": "Editar {{value0}}", - "1d411fb6a5": "Guarde un comando rápido para este repo", + "1d411fb6a5": "Guarda un comando rápido para este repositorio", "8f1e971966": "Agregar comando rápido", - "3220e2da27": "Este comando rápido se eliminará de su lista guardada.", + "3220e2da27": "Este comando rápido se eliminará de tu lista guardada.", "e8e1a52edb": "¿Eliminar \"{{value0}}\"?", "37e1bb90ce": "Ejecutar: {{value0}}", - "77ac113df0": "Inicio {{value0}}: {{value1}}", - "7b1c9d6ae1": "Correr", + "77ac113df0": "Iniciar {{value0}}: {{value1}}", + "7b1c9d6ae1": "Ejecutar", "c781f992e4": "destructivo", - "be8f0ff166": "Borrar", + "be8f0ff166": "Eliminar", "f3a8c2d1e7": "Buscar comandos rápidos...", "b4e7f9a2c1": "No hay comandos coincidentes" }, "shell": { "icons": { - "d4ceaa227c": "git", + "d4ceaa227c": "Git", "e9b2e70613": "WSL" } }, @@ -2722,33 +2727,33 @@ "classifier": { "42e6262ae9": "No hay acción disponible.", "097a982ee0": "Cargando archivos...", - "5a9c83c04b": "Abra cualquier archivo, URL, agente, ...", - "90eb94dc48": "Introduzca una URL http:// o https://.", - "5553b283ce": "Ingrese una URL o ruta de archivo.", + "5a9c83c04b": "Abrir cualquier archivo, URL, agente, ...", + "90eb94dc48": "Introduce una URL http:// o https://.", + "5553b283ce": "Introduce una URL o ruta de archivo.", "queryTooLarge": "El texto de búsqueda es demasiado grande." } }, "menu": { "options": { "5501c2fb7a": "terminal", - "9630dd5494": "caparazón", - "a094576900": "nueva terminal", - "4f23f4d01d": "nuevo caparazón", + "9630dd5494": "shell", + "a094576900": "nuevo terminal", + "4f23f4d01d": "nueva shell", "4f2a91e15b": "navegador", "6d0e6a4b7a": "nuevo navegador", "c87ad57785": "pestaña del navegador", "cce7ef1d2c": "web", "5f17fb9d0c": "markdown", - "44caaf7b36": "Maryland", - "fb50e3d874": "nueva markdown", + "44caaf7b36": "md", + "fb50e3d874": "nuevo markdown", "6d8b6b4117": "nuevo archivo", - "b330f72434": "marca", - "37ff3ddca1": "markdown abierta", + "b330f72434": "mark", + "37ff3ddca1": "abrir markdown", "164c394bab": "abrir archivo", "bbaf4f85a4": "emulador móvil", "3784b83bd4": "emulador", "a63847a742": "simulador", - "1baeb07c17": "simulador de ios", + "1baeb07c17": "simulador de iOS", "8a580f88cf": "iPhone", "7ecdc5ef08": "iPad", "14965cc123": "móvil" @@ -2773,20 +2778,20 @@ "bar": { "PetStatusSegment": { "3668339495": "Quitar {{value0}}", - "cd8c6c654c": "Configuración de mascotas…", - "ed176ad68f": "Importar paquete .codex-pet…", - "59b5955621": "Sube el tuyo…", + "cd8c6c654c": "Ajustes de mascota…", + "ed176ad68f": "Importar bundle .codex-pet…", + "59b5955621": "Subir el tuyo…", "0608ad02a2": "Elige mascota", "b75484a01a": "Tamaño de mascota", "c6aa805b1b": "píxeles", "2f7bbaa457": "Tamaño", "34c25dfe9c": "Mascota", "aec479308a": "Menú de mascotas", - "cef0ab4636": "No se pudo importar el paquete de mascotas", - "2021d4f6db": "La importación de paquetes de mascotas necesita reiniciar completamente la aplicación (no solo recargarla).", + "cef0ab4636": "No se pudo importar el bundle de mascota", + "2021d4f6db": "Importar un bundle de mascota requiere reiniciar la app por completo (no solo recargarla).", "f395c9a685": "No se pudo importar el archivo", - "e6234bcc17": "La carga de mascotas personalizada necesita reiniciar la aplicación por completo (no solo recargarla).", - "6d0a8cd179": "mostrar mascota", + "e6234bcc17": "Subir una mascota personalizada requiere reiniciar la app por completo (no solo recargarla).", + "6d0a8cd179": "Mostrar mascota", "1fbc51cc77": "Ocultar mascota" }, "PortsStatusSegment": { @@ -2808,55 +2813,52 @@ }, "ResourceUsageStatusSegment": { "946d9f94d0": "Cancelar", - "67c4ecda49": "Forzar el cierre de esta terminal. Cualquier trabajo no guardado en el panel se perderá. Esto no se puede deshacer.", - "4bb076fa89": "Matar", - "996295bff2": "terminal huérfana", + "67c4ecda49": "Fuerza el cierre de este terminal. Cualquier trabajo sin guardar en el panel se perderá. Esto no se puede deshacer.", + "4bb076fa89": "Terminar", + "996295bff2": "terminal huérfano", "92924a14e3": "Revisar espacios de trabajo inactivos ({{value0}})", - "27a74f91f0": "Nada funcionando ahora mismo", + "27a74f91f0": "No hay nada ejecutándose ahora mismo", "1b24a32d3a": "Memoria", - "298f4be7f2": "UPC", + "298f4be7f2": "CPU", "2aa2de6cb9": "Nombre", "30ff2c3c31": "{{value0}} huérfano", - "6449a95c78": "En qué cantidad de RAM física de esta máquina se encuentran los procesos rastreados por Orca.", + "6449a95c78": "Cuánta RAM física de esta máquina están usando los procesos que Orca rastrea.", "e7ccce7e87": "de RAM del sistema", - "9e2525c89f": "Memoria residente en poder de Orca más los procesos bajo las terminales de cada árbol de trabajo.", - "1fedf94eae": "Carga de CPU combinada. Los valores superiores al 100% significan que más de un núcleo está funcionando a la vez.", + "9e2525c89f": "Memoria residente de Orca más los procesos bajo los terminales de cada worktree.", + "1fedf94eae": "Carga de CPU combinada. Los valores superiores al 100% significan que más de un núcleo está trabajando a la vez.", "e7cf14ec78": "Sesiones de terminal no disponibles. La lista puede estar obsoleta.", - "93b0de3c21": "Reanudar", - "f85af9cda6": "Las instantáneas de recursos y las sesiones de terminal no están disponibles.", - "f8e0d794b4": "Daemon no responde", - "bd19fd7a59": "Matar todas las sesiones", - "c9382662bb": "Reiniciar demonio", - "59f178fe11": "{{value0}}, demonio inalcanzable", + "93b0de3c21": "Reiniciar", + "f85af9cda6": "Las snapshots de recursos y las sesiones de terminal no están disponibles.", + "f8e0d794b4": "El servicio no responde", + "bd19fd7a59": "Terminar todas las sesiones", + "c9382662bb": "Reiniciar servicio", + "59f178fe11": "{{value0}}, servicio inaccesible", "21cacb16d1": "· remoto", - "73a3fd68a9": "Contraer repo", - "b12e31dfcb": "Expandir repo", + "73a3fd68a9": "Contraer repositorio", + "b12e31dfcb": "Expandir repositorio", "d659d71d2d": "Reanudar el espacio de trabajo {{value0}}", "bbcd9b7b85": "Contraer espacio de trabajo", - "c4a8968bdd": "Ampliar el espacio de trabajo", - "b10695d6ce": "Matar sesión", + "c4a8968bdd": "Expandir espacio de trabajo", + "b10695d6ce": "Terminar sesión", "288a4dd177": "Orca", - "53dd5560ae": "Colapso Orca", + "53dd5560ae": "Contraer Orca", "e419d27083": "Expandir Orca", - "41ae4fa725": "Asesinato…", + "41ae4fa725": "Terminando…", "138b99bd80": "esta sesión", "888dad8c55": "Cargando…", - "56b6888304": "Uso de recursos locales oculto para servidores de ejecución.", - "14ff448686": "No disponible para servidores de ejecución", "6d9793d4bc": "Administrador de recursos - Terminales", - "6a822b06a7": "Administrador de recursos", - "ca95d077db": "demonio inalcanzable", + "ca95d077db": "servicio inaccesible", "a82253b458": "Eliminar espacio de trabajo.", "946724a70a": "El espacio de trabajo principal no se puede eliminar.", "16bc3c998a": "Eliminar espacio de trabajo {{value0}}", "0f9e50eb07": "Otro", - "d406915b78": "renderizador", - "81cd37af99": "Principal", - "fa6d36758d": "Matar sesión {{value0}}", + "d406915b78": "Renderer", + "81cd37af99": "Main", + "fa6d36758d": "Terminar sesión {{value0}}", "b8f4a2c1d0e3": "{{value0}} huérfanos", - "c7e3b1a0d9f2": "Mata la terminal huérfana {{value0}}", - "d8f4c2b1e0a3": "Mata terminales huérfanas {{value0}}", - "e9a5d3c2b1f0": "¿Matar a {{value0}}?" + "c7e3b1a0d9f2": "Terminar {{value0}} terminal huérfano", + "d8f4c2b1e0a3": "Terminar {{value0}} terminales huérfanos", + "e9a5d3c2b1f0": "¿Terminar {{value0}}?" }, "SshStatusSegment": { "3ad70e0365": "Administrar hosts remotos…", @@ -2871,8 +2873,8 @@ "3d0128b105": "conectado", "fd9a3c600e": "error", "fbb3f9f05e": "conflicto", - "95e4ff5b4b": "emprendedor", - "63a2b965f6": "tracción", + "95e4ff5b4b": "haciendo push", + "63a2b965f6": "haciendo pull", "remote_server": "Servidor remoto", "runtime_checking": "Comprobando", "runtime_online": "Conectado", @@ -2896,134 +2898,138 @@ "75ded02687": "Administrar cuentas…", "ff0fbe9311": "Activo", "7657e3db9c": "Cuenta del Codex", - "38b5647724": "Tiempo de ejecución de uso del Codex", - "ba55303942": "Abrir detalles del Codex y cambiar de cuenta", + "38b5647724": "Servicio de uso de Codex", + "ba55303942": "Abrir detalles de Codex y cambiar de cuenta", "4dff061aab": "·", "2483c60695": "···", "c35af53b73": "Iniciar sesión", "f19a63e7cd": "Inicia sesión para ver el uso", "5c938d39ac": "% semana", + "54e8d6bb2d": "% Fable", "d79c3362c4": "% 5h", - "8295903d17": "Reinicie los terminales Claude en vivo antes de continuar conversaciones antiguas después de cambiar.", + "a79c64f87e": "Fable", + "8295903d17": "Reinicia los terminales Claude activos antes de continuar conversaciones antiguas después de cambiar.", "c98ea88392": "Ninguna otra cuenta", "9332ba8684": "Cambiar a", - "d450654fa2": "cuenta Claude", - "11e2354daf": "Tiempo de ejecución de uso de Claude", + "d450654fa2": "Cuenta de Claude", + "11e2354daf": "Servicio de uso de Claude", "3dd7ddfae1": "Abrir detalles de Claude y cambiar de cuenta", - "59c6e7b4e0": "Las sesiones visibles se reinician ahora. Otros reinician cuando su árbol de trabajo se activa.", - "c676918adc": "Valor predeterminado del sistema", - "3325d996cb": "Límites de frecuencia de actualización", + "59c6e7b4e0": "Las sesiones visibles se reinician ahora. Las demás se reinician cuando su worktree se activa.", + "c676918adc": "Predeterminado del sistema", + "3325d996cb": "Actualizar rate limits", "fda8146810": "Abrir detalles de uso de Kimi", "629251f4b6": "Abrir detalles de uso de OpenCode Go", "d2375976eb": "Abrir detalles de uso de Gemini", "3d79122c3f": "kimi", - "d7a0668acc": "código abierto-ir", + "d7a0668acc": "opencode-go", "68efc0345c": "gemini", "76b06d4da5": "claude", "a28a5dd9b1": "error", "cd9d7b40ff": "Reiniciar {{value0}} sesiones", "6cd6650b4c": "Reiniciar sesión", - "1446d0d8a0": "{{value0}} Las sesiones del Codex todavía están en la cuenta anterior.", - "605901a495": "1 sesión del Codex todavía está en la cuenta anterior", - "5e5f9f5160": "1 restablecimiento de límite disponible", - "5ecae9197c": "{{value0}} restablecimientos de límite disponibles", - "25d8bbde69": "Usando restablecimiento…", - "e159fc1fd7": "Restablecer ahora", + "1446d0d8a0": "{{value0}} sesiones de Codex siguen en la cuenta anterior.", + "605901a495": "1 sesión de Codex sigue en la cuenta anterior", + "5e5f9f5160": "1 reset de rate limit disponible", + "5ecae9197c": "{{value0}} resets de rate limit disponibles", + "25d8bbde69": "Aplicando reset…", + "e159fc1fd7": "Resetear ahora", "972a1ff497": "¿Restablecer límites de Codex?", - "6d1042aa6f": "Esto usa un crédito de restablecimiento de límite de Codex para la cuenta activa y restablece de inmediato las ventanas de uso aptas.", + "6d1042aa6f": "Esto usa un crédito de reset de rate limit de Codex para la cuenta activa y restablece de inmediato las ventanas de uso elegibles.", "f077f586db": "No volver a preguntar", - "c0e972d726": "Cancelar" + "c0e972d726": "Cancelar", + "06741a2f3d": "Abrir detalles de uso de MiniMax", + "3bbf140864": "Uso de MiniMax" }, "StatusBarUsageEmptyCta": { "828c764a79": "Conectar una cuenta", "caa0f39811": "Soporta:", - "97957ad3a3": "Conecte las cuentas de su proveedor de IA para ver su uso en tiempo real y cambiar fácilmente entre cuentas.", + "97957ad3a3": "Conecta tus cuentas de proveedores de IA para ver su uso en tiempo real y cambiar fácilmente entre cuentas.", "9a542f46c7": "Ocultar de la barra de estado", - "84c3b15dca": "Límites de uso del Agent", + "84c3b15dca": "Límites de uso de Agents", "d663430cf9": "Configurar seguimiento de uso" }, "UpdateStatusSegment": { - "5cd13105a3": "La actualización falló. Haga clic para ampliar.", - "2201df6987": "Error en la actualización: haga clic para ver los detalles", + "5cd13105a3": "La actualización falló. Haz clic para expandir.", + "2201df6987": "La actualización falló — haz clic para ver detalles", "8533c12c3c": "La actualización falló", - "962404f68e": "Actualización lista para instalar. Haga clic para ampliar.", + "962404f68e": "Actualización lista para instalar. Haz clic para expandir.", "248ee5d8ef": "Orca v{{value0}} descargando… {{value1}}%", "57a29c3b0e": "Actualización lista", - "fd1d3b3a1d": "Descarga de actualizaciones, {{value0}} por ciento. Haga clic para ampliar.", + "fd1d3b3a1d": "Descargando actualización, {{value0}} por ciento. Haz clic para expandir.", "9d13213a56": "Orca v{{value0}} lista para instalar" }, "WorkspaceSpaceCompactPanel": { "a471aa9c24": "Actualizado", "9be86c46a0": "Liberable", - "f4d2651498": "escaneado", + "f4d2651498": "Escaneado", "6a5dc3c61a": "Revisar", "c361440dc0": "Beta", "8ff597593d": "Espacio", "0582df6d2e": "Escanear", - "f5e1a84d79": "Refrescar", + "f5e1a84d79": "Actualizar", "2af2174d6d": "Cancelar", - "5691353a21": "Parada", - "2837dc7c72": "cancelado", - "0583c806ac": "El uso del disco del espacio de trabajo no se analiza.", - "39786e3b73": "Escaneo de tamaños de espacios de trabajo.", + "5691353a21": "Deteniendo", + "2837dc7c72": "cancelando", + "0583c806ac": "No se ha escaneado el uso de disco del espacio de trabajo.", + "39786e3b73": "Escaneando tamaños de espacios de trabajo.", "bef4dc0457": "{{value0}} recuperable · {{value1}} no disponible", "3d8d47ce77": "{{value0}} · último resultado mantenido" }, "WorkspaceSpaceManagerPanel": { - "2965415393": "Error al forzar la eliminación", + "2965415393": "Falló la eliminación forzada", "e031e93219": "No hay espacios de trabajo coincidentes.", - "a02d84d2d2": "Escaneo de espacios de trabajo. Puedes salir de esta página.", + "a02d84d2d2": "Escaneando espacios de trabajo. Puedes salir de esta página.", "be37293b10": "Estado", "33aef3e9cc": "Tamaño", "81f14d9924": "Repositorio", "e4aebea158": "Espacio de trabajo", - "1d0f8300d1": "Seleccionar espacios de trabajo visibles y eliminables", - "697d60c456": "Borrar selección visible", + "1d0f8300d1": "Seleccionar espacios de trabajo visibles que se pueden eliminar", + "697d60c456": "Limpiar selección visible", "81aaf1de65": "Mostrar solo espacios de trabajo eliminables", "d7ac56452e": "Actividad", "243287ac60": "Nombre", "6f8f6a6b04": "Filtrar espacios de trabajo", - "5caccea440": "Eliminar seleccionado", - "e4a12c455b": "Claro", - "0cb1501ccf": "reclamable", + "5caccea440": "Eliminar seleccionados", + "e4a12c455b": "Limpiar", + "0cb1501ccf": "recuperable", "65402b7192": "seleccionado", "43171f3e60": "Espacios de trabajo", "83f1a0a932": "Reclamable", "09960d86bd": "escaneado", "1cc6cd4c0f": "espacios de trabajo", "02b27c2230": "espacio de trabajo", - "63efebe0e6": "{{value0}} {{value1}} eliminado del espacio.", + "63efebe0e6": "{{value0}} {{value1}} eliminado de Space.", "eee5240810": "Espacios de trabajo eliminados", "9afc97f9a3": "Espacio de trabajo eliminado", "792a214457": "Eliminar espacio de trabajo", - "a998501630": "Fuerza", - "9155381019": "Escaso", + "a998501630": "Forzar", + "9155381019": "Sparse", "f39d291997": "Seleccionar", "16988df079": "No se encontraron archivos.", "b25c2c1086": "elementos de nivel superior", "d3f9c69ddc": "Zoom", "ef890d31b9": "Todo", "c28643d3da": "Ir al espacio de trabajo", - "66870929fb": "Asunto", + "66870929fb": "Issue", "fb2069acb7": "Revisar", "b9b4a3a25d": "Rama", - "c432278ec7": "Búfers del editor", - "0bc756efaf": "cambios de git", + "c432278ec7": "Buffers del editor", + "0bc756efaf": "Cambios de git", "e9528a89b3": "Terminales", - "a8d9e0de79": "Agentes", + "a8d9e0de79": "Agents", "d384a4ce9f": "Eliminar decisión", - "7d7745bb8f": "puede eliminar", + "7d7745bb8f": "Se puede eliminar", "720870a18e": "Mantener: vinculado", "cbc343a7a8": "Mantener: en uso", "2055bc6a5a": "Mantener: ediciones no guardadas", - "ec7b076a75": "Mantener: git no marcado", + "ec7b076a75": "Mantener: git no verificado", "7ab8d7e2d7": "Mantener: archivos modificados", "7f7895514e": "Mantener: activo", "2b501ee391": "Mantener: principal", "39801484e0": "Fallido", "33653dbac2": "Eliminando", "52b629eb84": "Actualizado", - "e91dd2a9ae": "Ejecute un escaneo para inspeccionar los tamaños del espacio de trabajo.", + "e91dd2a9ae": "Ejecuta un escaneo para inspeccionar los tamaños de espacios de trabajo.", "61e25239da": "No hubo filas del espacio de trabajo disponibles en el escaneo.", "8194a4fb29": "El escaneo falló antes de que se recopilaran los tamaños del espacio de trabajo.", "b2f82ed5ae": "Eliminable", @@ -3034,12 +3040,12 @@ "1fce91d1b9": "Parada", "d254f04097": "cancelado", "265d956765": "{{value0}}. Puedes salir de esta página.", - "d595295d7d": "{{value0}} se puede recuperar de árboles de trabajo vinculados.", + "d595295d7d": "{{value0}} se puede recuperar de worktrees vinculados.", "34174bd83d": "{{value0}}. Puedes salir de esta página; el último resultado permanece visible.", "433bb7f595": "OK", "0ba046fbc5": "El escaneo falló.", "5c6d25720c": "Seleccione un espacio de trabajo para inspeccionar.", - "c5135e7e4a": "Escaneo de tamaños de espacios de trabajo. Puedes salir de esta página.", + "c5135e7e4a": "Escaneando tamaños de espacios de trabajo. Puedes salir de esta página.", "0990a63160": "Aún no hay tamaños de espacio de trabajo escaneados.", "977bdf9a36": "No hay elementos de nivel superior para mostrar.", "131662ac65": "{{value0}} abierto", @@ -3049,13 +3055,13 @@ "status": { "popover": { "rows": { - "a49ea79246": "Ir al árbol de trabajo", + "a49ea79246": "Ir al worktree", "f2b813345f": "Espacio de trabajo no disponible", "0e72c8d9fb": "Detener proceso", "536d48a5dc": "Copiar {{value0}}", "085f4f0334": "Abrir en el navegador", "e4a709548c": "No se pudieron actualizar los puertos", - "acdb6df590": "Proceso detenido el {{value0}}", + "acdb6df590": "Proceso detenido en {{value0}}", "480d8f2347": "Copiado {{value0}}", "b854ec9ff5": "No se pudo abrir el navegador" } @@ -3063,20 +3069,21 @@ } }, "tooltip": { - "cedb7b99e3": "% izquierda", + "cedb7b99e3": "% restante", "6d6df77f41": "No hay datos disponibles", "7f7f208060": "Mensual", - "252c096536": "Semanalmente", + "252c096536": "Semanal", + "a79c64f87e": "Fable", "94038ad2fa": "Sesión", - "2c35eca8d4": "No se puede recuperar el uso", - "1292d4f2ee": "Indisponible", + "2c35eca8d4": "No se pudo obtener el uso", + "1292d4f2ee": "No disponible", "7567cd1c6b": "Uso no disponible", - "a9a318b7a3": "Error al actualizar: se muestran datos almacenados en caché", + "a9a318b7a3": "Error al actualizar — mostrando datos en caché", "7ad719c4bf": "Limitado", "e740f92596": "Error al actualizar", - "8418ec448d": "No se pudo actualizar el uso de {{value0}}. Es posible que las sesiones de agente sigan iniciadas.", - "45198c7d95": "1 restablecimiento de límite disponible", - "bce421cba3": "{{value0}} restablecimientos de límite disponibles", + "8418ec448d": "No se pudo actualizar el uso de {{value0}}. Es posible que las sesiones de agentes sigan iniciadas.", + "45198c7d95": "1 reset de rate limit disponible", + "bce421cba3": "{{value0}} resets de rate limit disponibles", "7ec6e030a0": "El siguiente vence ahora", "d1e442a9e5": "Vence ahora", "6cf9eaed10": "El siguiente vence en {{value0}}", @@ -3086,8 +3093,8 @@ "f8f0f9d8cc": "Problema de red", "bf2e739f18": "Inicio de sesión no disponible", "f8b8dbed85": "Uso no disponible", - "3d3c9c0c1f": "El uso de Claude se actualizará cuando la terminal Claude activa rote sus credenciales.", - "42fdd4da1d": "Se está actualizando el inicio de sesión de Claude. Las sesiones de agentes pueden seguir conectadas.", + "3d3c9c0c1f": "El uso de Claude se actualizará cuando el terminal Claude activo rote sus credenciales.", + "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." @@ -3099,54 +3106,54 @@ }, "stats": { "ClaudeUsageDailyChart": { - "2a6360c7cb": "escritura en caché", - "61c58f8976": "lectura de caché", - "7d2efeff5e": "Producción", - "d7fb787e6b": "Aporte", - "a7902d3c1d": "fichas", + "2a6360c7cb": "Escritura de caché", + "61c58f8976": "Lectura de caché", + "7d2efeff5e": "Salida", + "d7fb787e6b": "Entrada", + "a7902d3c1d": "tokens", "059945f71d": "Totales de entrada, salida, lectura de caché y escritura de caché por día.", "c9f7cd30e9": "Uso diario" }, "ClaudeUsagePane": { "21ea00bfa8": "Caché", - "a8b7487ff7": "Producción", - "faf3444859": "Aporte", - "0f03975d59": "vueltas", + "a8b7487ff7": "Salida", + "faf3444859": "Entrada", + "0f03975d59": "turnos", "1afc25eb06": "Modelo", "c17bed0416": "Proyecto", "01476891c7": "Último activo", "abfc4a4943": "Tasa de reutilización de caché:", "7e76c84153": "Sesiones recientes", - "32176e1d44": "vueltas", + "32176e1d44": "turnos", "02a046792e": "sesiones •", "f97435845c": "Proyecto principal:", "7dc9e5613b": "Por proyecto", - "c3fdbc5474": "Modelo superior:", + "c3fdbc5474": "Modelo principal:", "0f394c24e3": "Por modelo", "51ae85fa00": "La tasa de reutilización de caché se calcula como tokens de lectura de caché / (tokens de entrada + tokens de lectura de caché).", - "b26d4ddb58": "Est. Costo equivalente a API", + "b26d4ddb58": "Costo est. equivalente a API", "0f3e696ca9": "Sesiones / Turnos", - "8cc23be4a3": "Turnos de lectura de caché cero", + "8cc23be4a3": "Turnos sin lectura de caché", "1634c4f404": "Tasa de reutilización de caché", - "b786fb4a70": "escritura en caché", - "268cf0af51": "lectura de caché", - "2b8a2f14aa": "Fichas de salida", - "ea71fae8fc": "Fichas de entrada", + "b786fb4a70": "Escritura de caché", + "268cf0af51": "Lectura de caché", + "2b8a2f14aa": "Tokens de salida", + "ea71fae8fc": "Tokens de entrada", "7dde9331fd": "Aún no se ha encontrado ningún uso local de Claude para este ámbito.", "424cd50412": "Habilitar análisis de uso de Claude", - "8d18bbb771": "Refrescar", - "c5b9b344d0": "Actualizar el uso de Claude", + "8d18bbb771": "Actualizar", + "c5b9b344d0": "Actualizar uso de Claude", "505be9aac4": "Rango", "f61cffb9c8": "Alcance", "dd29209b21": "Filtros", "e9bf9fce0e": "Opciones de uso de Claude", "6afacbee37": "Seguimiento de uso de Claude", - "0cb1a36d7d": "Lee los registros de uso locales de Claude para mostrar estadísticas de token, modelo y sesión.", + "0cb1a36d7d": "Lee los registros locales de uso de Claude para mostrar estadísticas de tokens, modelo y sesión.", "5ce4842c2c": "Todo el uso local de Claude", - "4f8368c272": "Solo árboles de trabajo Orca", + "4f8368c272": "Solo worktrees de Orca", "cfe2282ffa": "Desconocido", - "7765a4c3e1": "n / A", - "2d41fd45c6": "• Error del último análisis: {{value0}}", + "7765a4c3e1": "n/a", + "2d41fd45c6": " • Error del último escaneo: {{value0}}", "rangeLast7Days": "Últimos 7 días", "rangeLast30Days": "Últimos 30 días", "rangeLast90Days": "Últimos 90 días", @@ -3154,17 +3161,17 @@ }, "CodexUsageDailyChart": { "1e6f62d7e3": "Razonamiento", - "c646e1783c": "Entrada en caché", - "7b596a88b2": "Producción", - "99a91d3143": "Aporte", - "e4bdcf0071": "fichas", + "c646e1783c": "Entrada cacheada", + "7b596a88b2": "Salida", + "99a91d3143": "Entrada", + "e4bdcf0071": "tokens", "c756cda6a8": "Totales de entrada, entrada en caché, salida y razonamiento por día.", "609aa96e8b": "Uso diario" }, "CodexUsagePane": { "e0b988599d": "Total", - "bbd20344b8": "Producción", - "3acc582214": "Aporte", + "bbd20344b8": "Salida", + "3acc582214": "Entrada", "bd0822ca47": "Eventos", "c2478bcc3c": "Modelo", "1a65900aea": "Proyecto", @@ -3175,31 +3182,31 @@ "bf1bf2f674": "sesiones •", "829ee743f2": "Proyecto principal:", "b98718aaab": "Por proyecto", - "95d2d89285": "Modelo superior:", + "95d2d89285": "Modelo principal:", "5a0d1d69cd": "Por modelo", - "94ac1f1ee7": "Los tokens de razonamiento se muestran para mayor visibilidad, pero el costo se calcula a partir de entradas no almacenadas en caché, entradas almacenadas en caché y salidas únicamente.", - "1a18fbd56b": "Est. Costo equivalente a API", + "94ac1f1ee7": "Los tokens de razonamiento se muestran para dar visibilidad, pero el costo se calcula solo con entrada no cacheada, entrada cacheada y salida.", + "1a18fbd56b": "Costo est. equivalente a API", "907b31865f": "Sesiones / Eventos", "6e18146e9b": "Salida de razonamiento", - "a9ac0f423a": "Entrada en caché", - "5d8eba87bd": "Fichas de salida", - "e365eaa6fd": "Fichas de entrada", + "a9ac0f423a": "Entrada cacheada", + "5d8eba87bd": "Tokens de salida", + "e365eaa6fd": "Tokens de entrada", "4c865393b4": "Aún no se ha encontrado ningún uso del Codex local para este ámbito.", "f7c1affbd5": "Habilitar análisis de uso del Codex", - "3022cda443": "Refrescar", - "ec4d270e2c": "Actualizar el uso del Codex", + "3022cda443": "Actualizar", + "ec4d270e2c": "Actualizar uso de Codex", "89162e019b": "Rango", "6d68e8399a": "Alcance", "1af1a39b2f": "Filtros", "70b5b8581f": "Opciones de uso del Codex", "408210470c": "Seguimiento del uso del Codex", - "13badcd8f2": "Lee los registros de uso del Codex local para mostrar estadísticas de token, modelo y sesión.", + "13badcd8f2": "Lee los registros locales de uso de Codex para mostrar estadísticas de tokens, modelo y sesión.", "4fe8820098": "Todo el uso del Codex local", - "201766b754": "Solo árboles de trabajo Orca", + "201766b754": "Solo worktrees de Orca", "bf6cf2d4dd": "Desconocido", - "ae255c3dba": "n / A", + "ae255c3dba": "n/a", "247c93ca92": "• precios inferidos", - "8a6655f7a2": "• Error del último análisis: {{value0}}", + "8a6655f7a2": " • Error del último escaneo: {{value0}}", "rangeLast7Days": "Últimos 7 días", "rangeLast30Days": "Últimos 30 días", "rangeLast90Days": "Últimos 90 días", @@ -3207,8 +3214,8 @@ }, "OpenCodeUsagePane": { "349f7c3f5c": "Total", - "dfc4513657": "Producción", - "0f2f266c9d": "Aporte", + "dfc4513657": "Salida", + "0f2f266c9d": "Entrada", "d416f5cf92": "Eventos", "08c78441b7": "Modelo", "a4738de041": "Proyecto", @@ -3219,30 +3226,30 @@ "bc0cb89901": "sesiones •", "048ffe4d65": "Proyecto principal:", "0f0a1684bb": "Por proyecto", - "a15206a63a": "Modelo superior:", + "a15206a63a": "Modelo principal:", "040c044d39": "Por modelo", - "e5bb23d85e": "El costo proviene de la base de datos local OpenCode cuando el mensaje del asistente registró uno.", + "e5bb23d85e": "El costo proviene de la base de datos local de OpenCode cuando el mensaje del asistente registró uno.", "15c34d4b08": "Costo registrado", "7e9433469a": "Sesiones / Eventos", "5a65d68b77": "Salida de razonamiento", - "603504ee3b": "Entrada en caché", - "7aa4d8ce35": "Fichas de salida", - "d637a892ed": "Fichas de entrada", + "603504ee3b": "Entrada cacheada", + "7aa4d8ce35": "Tokens de salida", + "d637a892ed": "Tokens de entrada", "bb6363e08c": "Aún no se ha encontrado ningún uso de OpenCode local para este ámbito.", "f04131b3be": "Habilitar análisis de uso de OpenCode", - "603cd138dc": "Refrescar", - "bed558df0b": "Actualizar el uso de OpenCode", + "603cd138dc": "Actualizar", + "bed558df0b": "Actualizar uso de OpenCode", "b5ed5c9fd0": "Rango", "40d283c837": "Alcance", "01583b30aa": "Filtros", "230d6de108": "Opciones de uso de OpenCode", "bea80ceae0": "Seguimiento del uso de OpenCode", - "b8b3522436": "Lee registros de uso de OpenCode locales para mostrar estadísticas de token, modelo y sesión.", + "b8b3522436": "Lee los registros locales de uso de OpenCode para mostrar estadísticas de tokens, modelo y sesión.", "144a6050e9": "Todo el uso local de OpenCode", - "e04c58327c": "Solo árboles de trabajo Orca", + "e04c58327c": "Solo worktrees de Orca", "362231082f": "Desconocido", - "8095a63426": "n / A", - "6cc7782458": "• Error del último análisis: {{value0}}", + "8095a63426": "n/a", + "6cc7782458": " • Error del último escaneo: {{value0}}", "rangeLast7Days": "Últimos 7 días", "rangeLast30Days": "Últimos 30 días", "rangeLast90Days": "Últimos 90 días", @@ -3251,86 +3258,86 @@ "ShareUsageButton": { "7d6b25323d": "Compartir en X", "b295c1c75d": "Copiar imagen", - "bd82c76a70": "copiado", + "bd82c76a70": "Copiado", "bce08eccb9": "Compartir uso", "cecefa7c32": "Compartir" }, "ShareUsageCard": { "4a4c6c79a3": "sesiones ·", - "66c83284cf": "Fichas diarias", - "b760c0b622": "modelo superior", - "2d9eb39264": "fichas totales", - "beb6f24f37": "Est. costo", + "66c83284cf": "Tokens diarios", + "b760c0b622": "Modelo principal", + "2d9eb39264": "Tokens totales", + "beb6f24f37": "Costo est.", "da62578d9d": "Uso", "0eb31e79ee": "IDE de Orca", "960324e9b8": "eventos", - "6adac63cfe": "vueltas" + "6adac63cfe": "turnos" }, "StatsPane": { "42d3e0bdf7": "Proveedor de análisis de uso: {{value0}}", "c79f073d4c": "Análisis de uso", - "a58aba506f": "RP creados", - "1c96f433e2": "Tiempo que trabajaron los agents", - "9dbec9e675": "Agents generados", - "73ed07859c": "Inicie su primer agente para comenzar a rastrear", + "a58aba506f": "PRs creados", + "1c96f433e2": "Tiempo trabajado por agentes", + "9dbec9e675": "Agents iniciados", + "73ed07859c": "Inicia tu primer agente para empezar a rastrear", "1e696db2f6": "OpenCode", "7d26110cea": "Codex", "85457c02fe": "Claude", - "b2cf4310ce": "Descripción general", + "b2cf4310ce": "Resumen", "908c470587": "codex", "eb6a066185": "claude", - "eee19cfade": "descripción general" + "eee19cfade": "resumen" }, "UsageOverviewPane": { "22ed1b7669": "sesiones", "444585cb41": "con datos", - "ecb0cd8a4c": "activado -", + "ecb0cd8a4c": "habilitado -", "33f7b043d2": "Proveedores", - "60002bb22f": "Aún no se ha encontrado ningún uso local de Claude, Codex u OpenCode. La descripción general se completará después de que la siguiente sesión del agente escriba registros de tokens.", - "70f36452d4": "compartir caché", + "60002bb22f": "Aún no se encontró uso local de Claude, Codex u OpenCode. El resumen se completará después de que la siguiente sesión de agente escriba logs de tokens.", + "70f36452d4": "Porcentaje de caché", "327603fe8b": "Días activos", "0eaf937335": "Est. costo", - "3887b94ce5": "fichas totales", + "3887b94ce5": "Tokens totales", "2d13e57f72": "Habilitar OpenCode", "2f1ee2878b": "Habilitar Codex", "0ea0cae435": "Habilitar Claude", - "6c00c46815": "Permita que un proveedor escanee los registros de los agentes locales y cree el libro mayor de tokens combinado.", - "49405ccc8d": "Comenzar a rastrear tokens", - "ca6bc5fded": "Refrescar", - "e06d1baf5c": "Actualizar descripción general de uso", - "c760c481c5": "Descripción general de uso", - "55c910f4f1": "- los precios de algunos modelos no están disponibles" + "6c00c46815": "Habilita un proveedor para escanear logs locales de agentes y crear el registro combinado de tokens.", + "49405ccc8d": "Empezar a rastrear tokens", + "ca6bc5fded": "Actualizar", + "e06d1baf5c": "Actualizar resumen de uso", + "c760c481c5": "Resumen de uso", + "55c910f4f1": "- algunos precios de modelos no están disponibles" }, "share": { "card": { "utils": { "19f4b4dc75": "github.com/stablyai/orca", - "d864fc5f98": "producción", - "5d66fdd7c2": "aporte", + "d864fc5f98": "salida", + "5d66fdd7c2": "entrada", "7080aeaebb": "Razonamiento", "4ee864629a": "Entrada en caché", - "33d38e2177": "Producción", - "c2d7b23d57": "Aporte", - "9d166247ee": "escritura en caché", - "cc28cb965e": "lectura de caché" + "33d38e2177": "Salida", + "c2d7b23d57": "Entrada", + "9d166247ee": "Escritura de caché", + "cc28cb965e": "Lectura de caché" } } }, "stats": { "search": { "cb6a9f0334": "caché", - "eaf251e183": "fichas", - "6953af58e6": "código abierto", + "eaf251e183": "tokens", + "6953af58e6": "opencode", "b77826fca3": "codex", "e9dc37d889": "claude", "8efeae0b22": "seguimiento", "5acbe1fdf2": "tiempo", "ef8bbf7739": "PRs", - "ce8533f02e": "agentes", - "0bba8ca244": "estadística", + "ce8533f02e": "agents", + "0bba8ca244": "estadísticas", "0e2a0b6431": "uso", "372debfac0": "estadísticas", - "26bb901fcd": "Estadísticas de Orca más análisis de uso, tokens, caché, modelos y sesiones combinados de Claude, Codex y OpenCode.", + "26bb901fcd": "Estadísticas de Orca más analíticas de uso combinadas de Claude, Codex y OpenCode: tokens, caché, modelos y sesiones.", "cb2430ae6a": "Estadísticas y uso" } }, @@ -3343,32 +3350,32 @@ }, "sections": { "9564a3b21b": "sesiones -", - "32330a6e66": "{{value0}}: fichas {{value1}}", - "57d1448ef8": "Permitir", + "32330a6e66": "{{value0}}: {{value1}} tokens", + "57d1448ef8": "Habilitar", "f6df0d7d6d": "Más", "1dd166c920": "Menos", - "52d9221dc0": "Mapa de calor de actividad reciente de tokens", + "52d9221dc0": "Heatmap de actividad reciente de tokens", "c424eb3f8e": "Mejor:", "f28ff1f852": "Actividad reciente de tokens combinados de Claude, Codex y OpenCode.", "69e2b50427": "Intensidad diaria", - "3a795542fa": "Mezcla de tokens combinada", + "3a795542fa": "Mix combinado de tokens", "e65084cb4b": "razonamiento", - "3bc4a01b24": "Tokens combinados de entrada, salida y caché entre proveedores habilitados.", - "4ff104da47": "Mezcla de tokens", + "3bc4a01b24": "Tokens combinados de entrada, salida y caché en proveedores habilitados.", + "4ff104da47": "Mix de tokens", "0015facc1f": "Caché", - "7f270458af": "Producción", + "7f270458af": "Salida", "9365b14a4e": "Nueva entrada", "3de9bf87fc": "Aún no hay modelo", - "6762f6a682": "fichas", + "6762f6a682": "tokens", "a7f937fb29": "{{value0}} sesiones - {{value1}} {{value2}}", - "c8f3a2d1e0b4": "vueltas", + "c8f3a2d1e0b4": "turnos", "d9a4b3e2f1c5": "eventos" } } }, "UsageBreakdownSection": { "7765a4c3e1": "n/a", - "247c93ca92": "• precio inferido" + "247c93ca92": "• precios inferidos" }, "UsageSessionsTable": { "1afc25eb06": "Turnos", @@ -3385,27 +3392,27 @@ }, "sparse": { "SparseCheckoutPresetSelect": { - "c4ac80151d": "Nuevo preajuste", + "c4ac80151d": "Nuevo preset", "7c3275d307": "Editar {{value0}}", - "c7f9b3f0c1": "Apagado", - "8b12c0850a": "Ahorrar", + "c7f9b3f0c1": "Desactivado", + "8b12c0850a": "Guardar", "de8fce5854": "Cancelar", - "ddbcaef7be": "src/paquetes de renderizador/ui", + "ddbcaef7be": "src/renderer packages/ui", "0e9ad9c798": "Directorios", - "064c1e2d12": "Interfaz de usuario del renderizador", + "064c1e2d12": "UI del renderer", "b3a500c623": "Nombre", - "16223dde6a": "Cargar ajustes preestablecidos", - "a683a4bc8e": "Reintentar cargar ajustes preestablecidos", - "14952d451e": "directorios {{value0}}", + "16223dde6a": "Cargar presets", + "a683a4bc8e": "Reintentar cargar presets", + "14952d451e": "{{value0}} directorios", "e9283eb171": "1 directorio", - "69c020eddc": "Editar preajuste", + "69c020eddc": "Editar preset", "bd6cec2056": "nuevo" } }, "source": { "control": { "SourceControlActionVariableChips": { - "1b77798d5f": "variables", + "1b77798d5f": "Variables", "6b921a0ac2": "Ejemplo", "4bf6d88039": "(vacío)" } @@ -3413,30 +3420,30 @@ }, "skills": { "SkillsPage": { - "cb142070b4": "Refrescar", - "984405683f": "Complemento", - "4d177feabd": "incluido", + "cb142070b4": "Actualizar", + "984405683f": "Plugin", + "4d177feabd": "Incluido", "aa59462502": "Repositorio", - "571c5818c1": "Hogar", + "571c5818c1": "Inicio", "0bc1379f4c": "Todas las fuentes", - "38e0951c3a": "Habilidades del Agent", + "38e0951c3a": "Agent Skills", "fb6bf60b52": "Claude", "426be2aac6": "Codex", "39b6998ddb": "Todos los proveedores", - "a68dee6a32": "Habilidades de búsqueda", + "a68dee6a32": "Buscar skills", "e46e162e2e": "de", "b088e0785d": "Beta", - "f43ad6edf3": "Habilidades", - "7e828fb2c6": "Atrás", - "ea72d6185b": "No se pudieron escanear las habilidades locales", - "dc4c3328ee": "Revelar archivo", + "f43ad6edf3": "Skills", + "7e828fb2c6": "Volver", + "ea72d6185b": "No se pudieron escanear los skills locales", + "dc4c3328ee": "Mostrar archivo", "9963dff6d3": "No se encontró ninguna descripción.", - "995fde8337": "No se pudo revelar el archivo de habilidad", - "ab5b777350": "Se comprobaron las carpetas de habilidades locales de inicio, repositorio, paquetes y complementos.", + "995fde8337": "No se pudo mostrar el archivo del skill", + "ab5b777350": "Se comprobaron las carpetas de skills locales, del repositorio, incluidas y de plugins.", "08a321a984": "Ajusta la búsqueda o los filtros.", - "4acd6d68ec": "No se encontraron habilidades locales", + "4acd6d68ec": "No se encontraron skills locales", "6a62a0168c": "No hay coincidencias", - "cd7893fbc1": "Habilidades de escaneo", + "cd7893fbc1": "Escaneando skills", "35b9a724a0": "Disponible", "0c74e7ff34": "Local" } @@ -3447,13 +3454,13 @@ "ref": { "suggestion": { "toast": { - "670864ab52": "Mantener actualizado el {{value0}} local", - "84c62e4d7f": "No se pudo encender {{value0}}", - "442552c656": "Abra Configuración e inténtelo de nuevo.", - "f15fd80989": "Su nuevo árbol de trabajo está actualizado, pero el {{value0}} local está {{value1}} {{value2}} atrás, por lo que las diferencias de IA pueden compararse con el historial obsoleto. Deja que Orca lo mantenga actualizado automáticamente. Cambie esto en cualquier momento en", - "3d260e1a5d": "Configuración › {{value0}}", - "34a03a6565": "Mantenga {{value0}} actualizado", - "4a18052018": "El local {{value0}} está detrás de {{value1}}", + "670864ab52": "Manteniendo {{value0}} local actualizado", + "84c62e4d7f": "No se pudo activar {{value0}}", + "442552c656": "Abre Ajustes e inténtalo de nuevo.", + "f15fd80989": "Tu nuevo worktree está actualizado, pero {{value0}} local está {{value1}} {{value2}} atrás, así que los diffs de IA pueden compararse con historial obsoleto. Deja que Orca lo mantenga actualizado automáticamente. Puedes cambiar esto en cualquier momento en", + "3d260e1a5d": "Ajustes › {{value0}}", + "34a03a6565": "Mantener {{value0}} actualizado", + "4a18052018": "{{value0}} local está detrás de {{value1}}", "commit": "commit", "commits": "commits" } @@ -3464,8 +3471,8 @@ "AddProjectFromFolderDialog": { "7d1f51678c": "Agregar proyecto", "7726a16374": "Cancelar", - "046751dbfb": "Agregue esta carpeta como un proyecto Orca separado.", - "e643b30398": "Proyecto remoto agregado" + "046751dbfb": "Agrega esta carpeta como un proyecto de Orca separado.", + "e643b30398": "Proyecto agregado en host SSH" }, "AddRepoCreateStep": { "0ae45b8238": "mi-proyecto", @@ -3473,7 +3480,7 @@ "11fd2a7db8": "Repositorio Git", "5e97f0c4b9": "Proyecto creado", "2c12db1511": "Proyecto ya agregado", - "875dda0995": "Introduzca una ruta principal del servidor.", + "875dda0995": "Introduce una ruta padre del host.", "45b7c26034": "Crear proyecto", "85085d74d2": "Creando…", "c7b9f94456": "Crear un proyecto nuevo", @@ -3481,21 +3488,21 @@ "685b5eefe1": "{{kind}} en {{parent}}", "2a762f3b19": "Comprobando Git en este host...", "fe1e616c5b": "Git es necesario para crear un proyecto.", - "c234df77f7": "Elige o introduce una carpeta principal del servidor antes de crear.", + "c234df77f7": "Elige o introduce una carpeta padre del host antes de crear.", "3a13f6e88b": "ubicación no seleccionada", - "6ed14c0281": "carpeta del servidor no seleccionada", + "6ed14c0281": "carpeta del host no seleccionada", "ssh_parent_manual": "Introduce una ruta padre SSH." }, "AddRepoNestedImportStep": { - "496f68cf8c": "Escaneo de repositorios. Haga clic para detener.", - "a32bef9516": "Dejar de escanear", + "496f68cf8c": "Escaneando repositorios. Haz clic para detener.", + "a32bef9516": "Detener escaneo", "2f8298f3c3": "Detener escaneo", "5f857ba8e6": "en", - "4df0d08cc5": "Encontró", - "8db50afe1a": "Importar repositorios desde la carpeta", + "4df0d08cc5": "Encontrado", + "8db50afe1a": "Importar repositorios desde carpeta", "5b2e6fe3c8": "Importar por separado", "cf9d382ca1": "Importar", - "220dd32d83": "Exploración...", + "220dd32d83": "Escaneando...", "fb33359f69": "¿Agrupar estos repositorios?", "d75170194e": "Elige esto si estos proyectos van juntos: un monorepo o simplemente un conjunto de repositorios relacionados. Orca los agrupará y te permitirá trabajar desde la carpeta principal.", "39d51212cc": "Nombre del grupo", @@ -3503,53 +3510,55 @@ "a0bc4d1f8e": "Sí, importar como grupo", "8401a7a0d0": "1 repositorio", "d4f1df62ef": "{{value0}} repositorios", - "b4263a2ac4": "Se encontraron {{value0}} en {{value1}}.", - "24eda6c8b2": "Explorando... {{value0}}" + "b4263a2ac4": "Se encontró {{value0}} en {{value1}}.", + "24eda6c8b2": "Escaneando... {{value0}}", + "6149d5203f": "No hay repositorios seleccionados. Abre la carpeta principal para usar el editor, la terminal y la búsqueda sin funciones de Git.", + "e52454b7f6": "Abrir como carpeta" }, "AddRepoRemoteStep": { "5b205b5281": "Detener escaneo", "6680289908": "/inicio/usuario/proyecto", - "ef410aa881": "Ruta remota", - "0416bde073": "Agregar configuración", + "ef410aa881": "Ruta del host", + "0416bde073": "Agregar en Ajustes", "df6fbcf880": "No hay destinos SSH configurados.", - "44637f43bd": "Objetivo SSH", - "80557be85a": "Elija un destino SSH conectado e ingrese la ruta a un repositorio Git.", - "91b93a90a4": "Abrir proyecto remoto", - "007651bdf9": "Navegue hasta un directorio y haga clic en Seleccionar para elegirlo.", + "44637f43bd": "Destino SSH", + "80557be85a": "Elige un destino SSH conectado e introduce la ruta a un repositorio Git.", + "91b93a90a4": "Abrir proyecto en host SSH", + "007651bdf9": "Navega hasta un directorio y haz clic en Seleccionar para elegirlo.", "dd3ff65486": "Explorar el sistema de archivos remoto", - "36d427bb66": "Agregar proyecto remoto", - "35831a7312": "Añadiendo...", + "36d427bb66": "Agregar proyecto en host SSH", + "35831a7312": "Agregando...", "lockedDescription": "Introduce la ruta a un repositorio Git en {{value0}}.", "lockedDisconnected": "{{value0}} está desconectado.", "93e0221434": "Conectar" }, "AddRepoServerStartStep": { - "ae990c86a0": "Volver para agregar opciones", + "ae990c86a0": "Volver a opciones de agregado", "e1710bf831": "Abrir como carpeta", "8da4d1a5be": "Agregar proyecto Git", - "ac66a3ed2d": "Explorar el sistema de archivos del servidor", + "ac66a3ed2d": "Explorar el sistema de archivos del host", "92d25420a0": "/inicio/usuario/proyecto", - "867692f505": "Ruta del servidor", - "423b5d3d31": "Agregue un repositorio o carpeta de Git que ya exista en el servidor de ejecución seleccionado.", - "3d0c035483": "Proyecto de servidor abierto", - "438493f214": "O ingrese una ruta de servidor manualmente", - "6b9958492a": "¿Quieres importar muchos repos a la vez? Busque la carpeta principal.", - "d40d751517": "Nuevo repo o carpeta", + "867692f505": "Ruta del host", + "423b5d3d31": "Agrega un repositorio Git o carpeta que ya exista en el host seleccionado.", + "3d0c035483": "Abrir proyecto del host", + "438493f214": "O introduce una ruta del host manualmente", + "6b9958492a": "¿Quieres importar muchos repositorios a la vez? Busca la carpeta padre.", + "d40d751517": "Nuevo repositorio o carpeta", "a81ffa0a99": "Crear en el servidor", "a2ea37d549": "Repositorio remoto de Git", "47759c9491": "Clonar desde URL", "516187414c": "Proyecto o carpeta existente", - "0adf083af7": "Examinar servidor", - "8efa930eb5": "Agregue otro proyecto del servidor de ejecución seleccionado.", + "0adf083af7": "Explorar host", + "8efa930eb5": "Agrega otro proyecto del host seleccionado.", "39bd249b3a": "Agregar un proyecto", - "0f8aba944c": "Navegue hasta un directorio y haga clic en Seleccionar para elegirlo." + "0f8aba944c": "Navega hasta un directorio y haz clic en Seleccionar para elegirlo." }, "AddRepoStartSteps": { "87596c1446": "Otras formas de agregar", "acf895cb42": "Agrega un proyecto para comenzar con Orca.", "d13757911c": "Agregar un proyecto", - "d301db1c9a": "Escaneo de repositorios. Haga clic para detener.", - "69ea7f8dc4": "Dejar de escanear", + "d301db1c9a": "Escaneando repositorios. Haz clic para detener.", + "69ea7f8dc4": "Detener escaneo", "9906cae183": "Detener escaneo" }, "AddRepoStepIndicator": { @@ -3557,46 +3566,46 @@ }, "AddRepoSteps": { "569326d9cc": "Elige carpeta", - "a93ef169b5": "Explorar el sistema de archivos del servidor", + "a93ef169b5": "Explorar el sistema de archivos del host", "2ce3f6edf8": "/ruta/al/destino", "04a4c4e84a": "Ubicación del clon", "b698a4a29d": "https://github.com/user/repo.git", "3d4acbe693": "URL de Git", - "5b2ea674b1": "Ingrese la URL de Git y elija dónde clonarla.", + "5b2ea674b1": "Introduce la URL de Git y elige dónde clonarla.", "c05f88a31f": "Clonar desde URL", - "fe8e629fe3": "Navegue hasta un directorio y haga clic en Seleccionar para elegirlo.", - "df8b0e6c22": "Proyecto remoto agregado", + "fe8e629fe3": "Navega hasta un directorio y haz clic en Seleccionar para elegirlo.", + "df8b0e6c22": "Proyecto agregado en host SSH", "3e64e8a70d": "La conexión falló", - "32a7256d85": "Clon", - "69f5b5380d": "Clonación...", + "32a7256d85": "Clonar", + "69f5b5380d": "Clonando...", "cloneOnHostDescription": "Introduce la URL de Git y elige dónde clonarlo en {{value0}}.", "cloneParentFolder": "Carpeta padre" }, "AutoRenameFailedDialog": { - "aed1623b1e": "Cerca", - "eab8b45238": "Error de copia", - "a23b22d16f": "copiado", + "aed1623b1e": "Cerrar", + "eab8b45238": "Error al copiar", + "a23b22d16f": "Copiado", "74fc00776f": "Detalles del error", "3afcad0497": "desde el primer mensaje del agente.", - "ff62a18580": "Orca no pudo generar un nombre de sucursal para", - "ca3b225195": "Error en el nombre automático de sucursal" + "ff62a18580": "Orca no pudo generar un nombre de rama para", + "ca3b225195": "Error en el nombre automático de rama" }, "CreateProjectLocationField": { - "95548e33bf": "Elija la carpeta principal...", + "95548e33bf": "Elige carpeta padre...", "632b456b1b": "Cambiar", - "afaf54f245": "Cambiar carpeta principal", - "f520f83a97": "Explorar el sistema de archivos del servidor", - "2a20a603a3": "/inicio/usuario/proyectos", + "afaf54f245": "Cambiar carpeta padre", + "f520f83a97": "Explorar el sistema de archivos del host", + "2a20a603a3": "/home/user/projects", "134e37f711": "Ubicación", - "b589b77997": "Navegue hasta un directorio y haga clic en Seleccionar para elegirlo." + "b589b77997": "Navega hasta un directorio y haz clic en Seleccionar para elegirlo." }, "DeleteWorktreeDialog": { "ff2a74ac0e": "y", "91492c9ad6": "Eliminar", "4f6750ca7b": "No se pudo eliminar el espacio de trabajo", - "42e610d6cf": "Error al forzar la eliminación", - "5cc1a6701c": "Abrir configuración", - "2b56b35f53": "Puedes cambiar esto en Configuración.", + "42e610d6cf": "Falló la eliminación forzada", + "5cc1a6701c": "Abrir Ajustes", + "2b56b35f53": "Puedes cambiar esto en Ajustes.", "dd3a45bbbd": "Omitiremos esta confirmación la próxima vez.", "fc23c4cbdf": "Eliminar espacio de trabajo", "86f0ae1257": "Eliminar espacios de trabajo" @@ -3611,56 +3620,56 @@ "66798cc6a2": "Al eliminar este espacio de trabajo, también se elimina 1 espacio de trabajo secundario." }, "DeleteWorktreeSkipConfirmOption": { - "29aefb7e52": "no vuelvas a preguntar" + "29aefb7e52": "No volver a preguntar" }, "DeleteWorktreeWarningPanels": { "026738155a": "(el directorio de clonación original).", - "c4f96a6e18": "árbol de trabajo principal", + "c4f96a6e18": "worktree principal", "e3be9eba15": "Este es el" }, "ImportedWorktreesVisibilityLine": { - "b7a87dc32f": "Mostrar en la lista del árbol de trabajo", + "b7a87dc32f": "Mostrar en la lista de worktrees", "ad99f4eea9": "Mantener oculto", - "9f4f14e821": "Cambie esto más tarde desde el menú del proyecto.", + "9f4f14e821": "Cambia esto más tarde desde el menú del proyecto.", "b2bc47c080": "más ubicaciones", - "b47ba1a9d2": "{{value0}} vista previa", - "2251d41ebb": "Grupos de árboles de trabajo ocultos", - "f54f2bec5d": "{{value0}} árboles de trabajo ocultos para {{value1}}", + "b47ba1a9d2": "Vista previa de {{value0}}", + "2251d41ebb": "Grupos de worktrees ocultos", + "f54f2bec5d": "{{value0}} worktrees ocultos para {{value1}}", "5a9688802a": "Mostrar {{value0}} más", "294de4aeb2": "Mostrar menos" }, "NonGitFolderDialog": { "e52454b7f6": "Abrir como carpeta", "05b33a17a9": "Cancelar", - "8fba4b8cbb": "Esta carpeta no es un repositorio de Git. Tendrás el editor, la terminal y la búsqueda, pero las funciones basadas en Git no estarán disponibles.", - "c49fb13492": "No se pudo agregar la carpeta remota", + "8fba4b8cbb": "Esta carpeta no es un repositorio Git. Tendrás editor, terminal y búsqueda, pero las funciones basadas en Git no estarán disponibles.", + "c49fb13492": "No se pudo agregar la carpeta en este host", "9a766f33ac": "Esta ruta se verificó en el host SSH.", - "79fd02cf5f": "Esta ruta fue verificada el {{hostName}}.", - "8851b77327": "Esta ruta fue verificada localmente." + "79fd02cf5f": "Esta ruta se verificó en {{hostName}}.", + "8851b77327": "Esta ruta se verificó localmente." }, "OrcaYamlTrustDialog": { - "f3e2b868fb": "Ejecutar ganchos", - "43b7bec4cd": "no corras", + "f3e2b868fb": "Ejecutar hooks", + "43b7bec4cd": "No ejecutar", "c494b3ccb1": "en", "79afc6772b": "orca.yaml", - "531689199b": "Confía siempre", - "bf800b7e04": ". Solo corre si confías", - "831f2cd9f0": "se ejecuta en su máquina", - "aa3ffb33fb": "este repositorio", - "c55beddbf8": "cambiado desde la última vez que lo aprobó. Vuelva a revisar antes de que se ejecute", - "95bf974a1a": "Guión {{value0}}", + "531689199b": "Confiar siempre", + "bf800b7e04": ". Ejecútalo solo si confías en ", + "831f2cd9f0": "se ejecuta en tu máquina", + "aa3ffb33fb": "El archivo ", + "c55beddbf8": "cambió desde la última vez que lo aprobaste. Revísalo de nuevo antes de que se ejecute", + "95bf974a1a": "Script {{value0}}", "9e52effffd": "Nuevo script {{value0}}", "e4a51dc4b3": "¿Ejecutar {{value0}} desde {{value1}}?", "02b0ede5ad": "El {{value1}} de {{value0}} cambió: ¿ejecutar la nueva versión?" }, "PendingWorktreeRow": { - "af21e953d1": "Cancelar la creación del árbol de trabajo", + "af21e953d1": "Cancelar creación del worktree", "188f6922a0": "Cancelar" }, "ProjectGroupDeleteDialog": { "ca65b78f78": "Cancelar", "9be10d49ea": "y desagrupar sus proyectos.", - "69f5cb97d0": "Borrar", + "69f5cb97d0": "Eliminar", "591f330288": "Eliminar grupo de proyectos", "2c14ce677a": "Eliminando...", "0e0e6764af": "Proyectos contenidos", @@ -3675,37 +3684,37 @@ "ProjectGroupNameDialog": { "d99a034073": "Cancelar", "83dfbc5313": "Nombre del grupo", - "4a64e78822": "Ahorro..." + "4a64e78822": "Guardando..." }, "RemoteFileBrowser": { - "2300612806": "Escriba para filtrar o ingresar una ruta...", + "2300612806": "Escribe para filtrar o introducir una ruta...", "9e060f5815": "Seleccionar carpeta", "f8b1deb1a4": "Cancelar", - "51001182e3": "directorio vacio", - "971d85cc84": "Se abre como proyecto remoto · {{value0}}", + "51001182e3": "Directorio vacío", + "971d85cc84": "Se abre como proyecto en este host · {{value0}}", "00c4235c10": "No hay coincidencias para '{{value0}}'", "largeInputNoMatches": "No hay coincidencias para esta entrada larga" }, "RemoveFolderDialog": { - "4dc5b5065b": "Eliminar", + "4dc5b5065b": "Quitar", "d36883e046": "Cancelar", - "8c097ef04e": "de Orca. Todavía está en tu disco.", - "e62415c3d0": "Esto sólo elimina", - "b79b39d865": "Eliminar proyecto" + "8c097ef04e": "de Orca. Sigue estando en tu disco.", + "e62415c3d0": "Esto solo quita", + "b79b39d865": "Quitar proyecto" }, "ScrollToCurrentWorkspaceToolbarButton": { - "23989bb663": "Revelar un espacio de trabajo activo" + "23989bb663": "Mostrar espacio de trabajo activo" }, "SetupGuideSidebarEntry": { "b0a7bfc34c": "Ocultar de la barra lateral", - "88d402b71d": "Lista de verificación de incorporación" + "88d402b71d": "Checklist de onboarding" }, "SetupScriptPromptCard": { "ff1e819a11": "Agregar un script de configuración", "70715947fb": "El script de configuración no puede estar vacío", "888b83bf78": "No se pudo guardar el script de configuración", - "a49196d538": "Se ejecuta cuando Orca crea un nuevo árbol de trabajo.", - "d9f2db2738": "configuración del proyecto", + "a49196d538": "Se ejecuta cuando Orca crea un nuevo worktree.", + "d9f2db2738": "ajustes del proyecto", "a5bb8c5135": "Guardado en este", "dcaa645da5": "OK" }, @@ -3713,39 +3722,39 @@ "96a7f4198c": "Guardar configuración local", "3933401d28": "Configurar", "31b8b01a45": "Ajustes", - "4a98f907ae": "Rever", - "0a98169776": "Agregue un comando de configuración para ejecutar cuando Orca cree nuevos árboles de trabajo.", - "8349e3fa4c": ". Guárdelo para ejecutarlo en busca de nuevos árboles de trabajo.", - "b56d1322f7": "Encontré un comando de configuración en", - "aef6c0a213": "Guarde el comando detectado para ejecutarlo cada vez que Orca cree un árbol de trabajo.", - "660cdc17f8": "guiones de configuración. Agregue un comando local o cambie la fuente en Configuración.", + "4a98f907ae": "Reintentar", + "0a98169776": "Agrega un comando de setup para ejecutar cuando Orca cree nuevos worktrees.", + "8349e3fa4c": ". Guárdalo para ejecutarlo en nuevos worktrees.", + "b56d1322f7": "Se encontró un comando de setup en", + "aef6c0a213": "Guarda el comando detectado para ejecutarlo cada vez que Orca cree un worktree.", + "660cdc17f8": "scripts de setup. Agrega un comando local o cambia la fuente en Ajustes.", "8f6be51aa1": "orca.yaml", - "bb879db364": "Este repo ignora lo compartido", - "0155fb9ed3": "No se pudo verificar el script de configuración de este repo en este momento.", + "bb879db364": "Este repositorio ignora lo compartido", + "0155fb9ed3": "No se pudo verificar el script de configuración de este repositorio en este momento.", "eefa756190": "Configurar manualmente", - "ca4efcbc25": "Ahorrar", + "ca4efcbc25": "Guardar", "d02e6a42b1": "Detectado desde", - "fdbc6cb064": "Script de configuración detectado", - "7275f674cc": "Configuración detectada", - "822ff300ad": "Despedir", + "fdbc6cb064": "Script de setup detectado", + "7275f674cc": "Setup detectado", + "822ff300ad": "Descartar", "5bfd5c8779": "Descartar scripts de configuración" }, "SidebarFeedbackDialog": { "8bf619e4cf": "Cancelar", - "8de03e23c5": "Envíelo solo con sus comentarios escritos o conecte \"gh\" para incluir la identidad de GitHub.", - "d20439c560": "Comprobando la identidad de GitHub...", + "8de03e23c5": "Envíalo solo con tu feedback escrito o conecta `gh` para incluir tu identidad de GitHub.", + "d20439c560": "Comprobando identidad de GitHub...", "5b120b9634": "Enviar de forma anónima", "c9e5ea0791": "GitHub:", "d46ddd66fc": "¿Qué podríamos mejorar?", "3460258a54": "Sigue en X", - "26108d3699": "Únete a la Discord", - "d245c4ef6c": "Problemas de GitHub", - "9b33530b3d": "Otras formas de llegar a nosotros", - "a828fa4aee": "Comparta lo que funciona, lo que no funciona o lo que Orca debería hacer a continuación.", - "0eb643f07f": "Enviar comentarios", - "60b721e857": "No se pudieron enviar comentarios. Por favor inténtalo de nuevo.", - "7a46c228b8": "Gracias por los comentarios.", - "a2fd890d9e": "Por favor ingrese sus comentarios antes de enviarlos.", + "26108d3699": "Únete a Discord", + "d245c4ef6c": "Issues de GitHub", + "9b33530b3d": "Otras formas de contactarnos", + "a828fa4aee": "Comparte qué funciona, qué está roto o qué debería hacer Orca después.", + "0eb643f07f": "Enviar feedback", + "60b721e857": "No se pudo enviar el feedback. Inténtalo de nuevo.", + "7a46c228b8": "Gracias por el feedback.", + "a2fd890d9e": "Introduce feedback antes de enviarlo.", "f2e42e1307": "Enviar", "69969ba364": "Envío…" }, @@ -3754,11 +3763,11 @@ "92a23e6d07": "Restablecer filtros", "81ded53722": "SSH", "b9e8802e73": "Ningún proyecto coincide", - "779b7ba05d": "Claro", + "779b7ba05d": "Limpiar", "139877b384": "Seleccionar todo", "5f7085a077": "Proyectos", "e5cb32a898": "Ocultar rama predeterminada", - "638a2d221d": "Ocultar durmiendo", + "638a2d221d": "Ocultar en reposo", "f506a1262a": "Filtrar espacios de trabajo", "75405270ed": "Editar filtros ({{value0}} activo)", "489d1c8c9f": "Buscar proyectos...", @@ -3767,7 +3776,7 @@ }, "SidebarHeader": { "92154beb7e": "Nuevo espacio de trabajo", - "49f62c5665": "tablero del espacio de trabajo", + "49f62c5665": "Tablero de espacios de trabajo", "5c9c7c16aa": "Agregar un proyecto para crear espacios de trabajo", "ca6f729da2": "Nuevo espacio de trabajo ({{value0}})", "a30e34eb5c": "Cerrar tablero del espacio de trabajo", @@ -3775,25 +3784,25 @@ }, "SidebarNav": { "80611a8b10": "Buscar", - "0c3395fd32": "Buscar árboles de trabajo y pestañas del navegador", + "0c3395fd32": "Buscar worktrees y pestañas del navegador", "c86d83b5c3": "Nuevo", "1b5c41caee": "Orca Móvil", "9c95e1ce91": "Agentes", "f323383e9a": "Automatizaciones", "e7ad3c540d": "Abrir tareas de Jira", - "c39ab10000": "Abrir tareas Lineares", + "c39ab10000": "Abrir tareas de Linear", "196c1b5362": "Abrir tareas de GitLab", "0ccba862b8": "Abrir tareas de GitHub", "fee535205b": "Tareas", "d599269755": "Ocultar de la barra lateral" }, "SidebarRepositoryFilterSection": { - "d3a9c4cea1": "Claro", + "d3a9c4cea1": "Limpiar", "7679f0c268": "Proyectos", "f10ca29601": "Quitar filtro {{value0}}", "2656053db4": "SSH", "83a820fa71": "Filtrar proyectos...", - "5a273fbfce": "Añadir proyecto...", + "5a273fbfce": "Agregar proyecto...", "4815c70605": "Ningún proyecto coincide", "bbbc6e8e3b": "Ningún proyecto no seleccionado coincide" }, @@ -3802,29 +3811,29 @@ "29c56f30ee": "Buscar actualizaciones", "eb9884e55b": "Discord", "5687ab246a": "GitHub", - "5f83d86d92": "Registro de cambios", - "cdc87f897e": "Documentos", + "5f83d86d92": "Changelog", + "cdc87f897e": "Documentación", "e565171a7c": "Atajos de teclado", "4cf5b868d7": "Enviar comentarios", "a428c25998": "Ajustes", "2991a0106c": "Ayuda", "4e8f5710d3": "No se pudo reiniciar Orca.", "5161eef55d": "Reiniciando Orca...", - "d396773ef0": "de cheques", - "f8a2c91d4e": "Hitos", - "b7e4d2a19c": "Introducción", + "d396773ef0": "comprobando", + "f8a2c91d4e": "Milestones", + "b7e4d2a19c": "Onboarding", "c4f8e1b72a": "X" }, "SidebarToolbar": { - "19e32d0e5f": "Abra el selector de carpetas para agregar un proyecto", + "19e32d0e5f": "Abrir el selector de carpetas para agregar un proyecto", "abc62b6328": "Agregar proyecto", - "87d0064026": "El tablero del espacio de trabajo se movió a la barra inferior", - "a30e34eb5c": "Cerrar tablero del espacio de trabajo", - "49f62c5665": "tablero del espacio de trabajo" + "87d0064026": "El tablero de espacios de trabajo se movió a la barra inferior", + "a30e34eb5c": "Cerrar tablero de espacios de trabajo", + "49f62c5665": "Tablero de espacios de trabajo" }, "SidebarWorkspaceFilterSection": { "c3fa13dc2e": "Ocultar rama predeterminada", - "ed1611b65b": "Ocultar durmiendo", + "ed1611b65b": "Ocultar en reposo", "82594419ba": "Filtros", "automationCreated": "Ocultar creados por automatizaciones" }, @@ -3845,19 +3854,19 @@ "b451c8b162": "Reciente", "6664282a7b": "Arrastra proyectos para organizarlos", "7b316bdd51": "Manual", - "7153d07485": "Arrastre los espacios de trabajo para organizarlos dentro de cada grupo.", + "7153d07485": "Arrastra los espacios de trabajo para organizarlos dentro de cada grupo.", "2170d553cf": "Proyecto", "b759bb87ee": "Agents que necesitan atención, luego actividad más reciente.", - "503462f2b4": "Actividad del Agent", + "503462f2b4": "Actividad de Agents", "3728165cdd": "Nombre", "2a81e07366": "Lista completa", "25105b28cb": "Compacto", - "d7084e8bc8": "Actividad del Agent", + "d7084e8bc8": "Actividad de Agents", "b64d8bcca0": "Puertos", "26c71e536c": "Notas", "b8dcc6f321": "Enlace PR/MR", - "ca4d3c522e": "Cuestión Linear", - "91dfc653e8": "billete de GitHub", + "ca4d3c522e": "Issue de Linear", + "91dfc653e8": "Ticket de GitHub", "bdd23b4e07": "Issues de GitHub", "44713a5d04": "Issues de Linear", "cc17bd443b": "Detallado", @@ -3865,7 +3874,7 @@ "e029a2d775": "Estado", "c2c7a45cda": "Ninguno", "680043342f": "compacto", - "c7591b6014": "repo", + "c7591b6014": "repositorio", "hosts": "Hosts", "allHostsDetail": "Mostrar todos los hosts", "configuredSshHost": "SSH configurado", @@ -3879,19 +3888,19 @@ "8d62c68b35": "Notas", "automation": "Automatización", "2d74665a56": "Puertos", - "65a9820bd1": "Estados del agente", + "65a9820bd1": "Estados de Agents", "219ebf1961": "Nombre de rama", "folderPathIdentity": "Rama / ruta de carpeta" }, "SshDisconnectedDialog": { "ca4a7892af": "Conectando...", - "89385db176": "Despedir", - "656368f3a2": "Error de reconexión", + "89385db176": "Descartar", + "656368f3a2": "Falló la reconexión", "376bed88e5": "La conexión al host remoto encontró un error.", "4afcca1d24": "Reconectar", "11552bf786": "SSH desconectado", "cb5938ae79": "Reconectando...", - "disconnected": "This remote repository is not connected.", + "disconnected": "Este host SSH no está conectado.", "reconnecting": "Reconectando con el host remoto...", "reconnectionFailed": "Falló la reconexión con el host remoto.", "authFailed": "Falló la autenticación con el host remoto." @@ -3904,13 +3913,13 @@ "cefae8983e": "Fijado" }, "WorkspaceKanbanDrawerHeader": { - "f369f5c5a3": "Cerca", - "e1a34450fc": "Organice espacios de trabajo por estado y abra tarjetas de espacios de trabajo.", + "f369f5c5a3": "Cerrar", + "e1a34450fc": "Organiza espacios de trabajo por estado y abre tarjetas de espacios de trabajo.", "81870af08f": "seleccionado", - "c6a77ab0f4": "tablero del espacio de trabajo" + "c6a77ab0f4": "Tablero de espacios de trabajo" }, "WorkspaceKanbanPinDropTarget": { - "c30151c5ee": "Vaya aquí para fijar sin cambiar el estado.", + "c30151c5ee": "Suelta aquí para fijar sin cambiar el estado.", "8fae2d0862": "Fijado" }, "WorkspaceKanbanSettingsMenu": { @@ -3919,17 +3928,17 @@ "b45b350eb0": "Mover {{value0}} a la izquierda", "8ce44af9a8": "Cambiar nombre {{value0}}", "395e541d5d": "Estados", - "34f03eb0de": "Configuración del tablero", - "26cbc92150": "Configuración del tablero del espacio de trabajo", - "87d24a0c2f": "Sincronizar el estado del tablero y de incidencias", - "4c2eaa78cc": "Al mover un espacio de trabajo vinculado, se actualiza el estado de su incidencia de Linear cuando existe un estado de flujo de trabajo coincidente." + "34f03eb0de": "Ajustes del tablero", + "26cbc92150": "Ajustes del tablero de espacios de trabajo", + "87d24a0c2f": "Sincronizar tablero y estado de issue", + "4c2eaa78cc": "Al mover un espacio de trabajo vinculado, se actualiza el estado de su issue de Linear cuando existe un estado de workflow coincidente." }, "WorkspaceKanbanStatusLane": { "8ad104642b": "Vacío", - "3611d1ae7f": "Cambiar el tamaño de las columnas del tablero del espacio de trabajo" + "3611d1ae7f": "Cambiar tamaño de columnas del tablero de espacios de trabajo" }, "WorkspaceStatusAppearancePopover": { - "514be2f569": "Establezca el color {{value0}} en {{value1}}", + "514be2f569": "Establecer color de {{value0}} en {{value1}}", "8be427206b": "Icono", "2ac106f6b2": "Color", "74b1413279": "Apariencia", @@ -3938,12 +3947,12 @@ "WorktreeCard": { "a88c92d0e3": "{{value0}}/{{value1}} ya existe.", "6f09f58541": "Eliminar espacio de trabajo", - "0777de5970": "Árbol de trabajo principal (directorio de clonación original)", - "0f33af979b": "Pago parcial. Los archivos fuera de estas rutas no están en el disco.", - "4f964d5e8c": "escaso", - "7d517f82e2": "primario", - "4eba2ea99e": "Falló el nombre automático. Haga clic para ver detalles.", - "74522ee457": "cambiar el nombre falló", + "0777de5970": "Worktree principal (directorio de clonación original)", + "0f33af979b": "Checkout parcial. Los archivos fuera de estas rutas no están en disco.", + "4f964d5e8c": "sparse", + "7d517f82e2": "principal", + "4eba2ea99e": "Falló el nombre automático. Haz clic para ver detalles.", + "74522ee457": "falló el cambio de nombre", "02e19349f4": "Error al cambiar el nombre automáticamente: ver error", "691ccfd622": "Eliminando…", "35ccfe2475": "Proyecto {{value0}}", @@ -3952,17 +3961,17 @@ "8cb634cda6": "Mostrar espacios de trabajo secundarios", "01f45d3d8a": "barra lateral", "93aebe4529": "Carpeta", - "0d224eff10": "árbol de trabajo primario", - "ca74db7550": "Proyecto remoto vía SSH", + "0d224eff10": "Worktree principal", + "ca74db7550": "Proyecto en host SSH", "021538e1d1": "SSH desconectado", "runtimeHostDisconnected": "Servidor desconectado", - "runtimeHostProject": "Proyecto en servidor Orca", + "runtimeHostProject": "Proyecto en servidor de Orca", "automationCreated": "Creado por automatización", "branchIdentity": "Rama", "branchFolderPathIdentity": "Rama o ruta de carpeta" }, "WorktreeCardAgents": { - "1b0a156717": "Agentes" + "1b0a156717": "Agents" }, "WorktreeCardReviewDetailSection": { "reviewHeader": "{{value0}} #{{value1}}", @@ -3976,15 +3985,15 @@ "dbe2d18972": "Más acciones de {{value0}}", "ae76907ca6": "Desvincular {{value0}}", "ad25c3ff05": "Ver en {{value0}}", - "2c67730e07": "Abierto en Orca", + "2c67730e07": "Abrir en Orca", "e42941631a": "Ver en Linear", "5e982e6128": "Linear {{value0}}", - "807b13b9ec": "Editar problema", + "807b13b9ec": "Editar issue", "b22f058067": "Ver en GitHub", - "e97d8f2876": "Número {{value0}}", + "e97d8f2876": "Issue #{{value0}}", "3ea2702e62": "Vinculado {{value0}} #{{value1}}", "b105fd3057": "Linear vinculado {{value0}}", - "3f2649eeb8": "Problema vinculado #{{value0}}", + "3f2649eeb8": "Issue vinculado #{{value0}}", "fe075cb851": "Notas del espacio de trabajo", "automationHeader": "Automatización", "openAutomation": "Abrir automatización", @@ -4005,19 +4014,19 @@ "fe188062a1": "Estado: Abierto", "2931b42b09": "Estado: Borrador {{value0}}", "e888362def": "Estado: Cerrado", - "f394b3e86e": "Estado: Fusionado", + "f394b3e86e": "Estado: merged", "af2b07bda5": "Estado: {{value0}}", - "29df45afa2": "SEÑOR" + "29df45afa2": "MR" }, "WorktreeCardPorts": { - "34f733dda2": "Ir al árbol de trabajo", + "34f733dda2": "Ir al worktree", "3240f320d7": "Puertos en vivo", "3e5f66564e": "Espacio de trabajo no disponible", "2f854442ff": "Detener proceso", "c8067a829a": "Copiar {{value0}}", "33bc7d7495": "Abrir en el navegador", "9950fe2d20": "No se pudieron actualizar los puertos", - "5d1a5d51bb": "Proceso detenido el {{value0}}", + "5d1a5d51bb": "Proceso detenido en {{value0}}", "c89f290e25": "Copiado {{value0}}", "d1113f4660": "No se pudo abrir el navegador", "fed49903c9": "{{value0}} en vivo {{value1}}" @@ -4028,57 +4037,57 @@ "e091caab15": "No se pudo encontrar el proyecto.", "439fa94d53": "Actualizar", "579b1a8e61": "Quitar del padre", - "8d9cd19d09": "Abrir árbol de trabajo padre", + "8d9cd19d09": "Abrir worktree padre", "d35dfeae58": "Quitar del grupo", "76865d827f": "Mover al grupo", "503ec0f8e6": "Nuevo grupo del proyecto", "3350101edb": "Copiar ruta", - "f4475537d8": "Borrar", - "f5ac91531d": "Eliminar proyecto de Orca", + "f4475537d8": "Eliminar", + "f5ac91531d": "Quitar proyecto de Orca", "b42391d8bf": "Eliminando…", - "0918b35e4f": "Cierre todos los paneles activos en este espacio de trabajo para liberar memoria y CPU.", - "7d190f7d2b": "Cierre todos los paneles activos en los espacios de trabajo seleccionados para liberar memoria y CPU.", + "0918b35e4f": "Cierra todos los paneles activos en este espacio de trabajo para liberar memoria y CPU.", + "7d190f7d2b": "Cierra todos los paneles activos en los espacios de trabajo seleccionados para liberar memoria y CPU.", "84cdbb7e30": "Mover al estado", "56cde9e8e6": "Mover estados a", "f50603c6b2": "Marcar como no leído", "8dacff1fe0": "Marcar como leído", - "3baa7d6507": "Alfiler", - "697d0f6e1b": "Desprender", + "3baa7d6507": "Fijar", + "697d0f6e1b": "Desanclar", "250de158fd": "Eliminar espacio de trabajo", - "changeParentWorkspace": "Cambiar árbol de trabajo padre...", - "setParentWorkspace": "Establecer árbol de trabajo padre...", + "changeParentWorkspace": "Cambiar worktree padre...", + "setParentWorkspace": "Establecer worktree padre...", "workspaceSection": "Espacio de trabajo", - "primaryDeleteDisabled": "El árbol de trabajo principal no se puede eliminar. Elimina el proyecto en su lugar.", - "deleteWorktree": "Eliminar árbol de trabajo" + "primaryDeleteDisabled": "El worktree principal no se puede eliminar. Quita el proyecto en su lugar.", + "deleteWorktree": "Eliminar worktree" }, "WorktreeList": { "d880ea0744": "Crea un grupo y mueve este proyecto a él.", - "bc1460beb3": "Actualice el nombre del grupo que se muestra en la barra lateral.", + "bc1460beb3": "Actualiza el nombre del grupo que se muestra en la barra lateral.", "13757c053c": "Nuevo grupo de proyecto", "f9dc6cc5d3": "Cambiar nombre del grupo de proyectos", - "370c6a55dd": "Borrar filtros", + "370c6a55dd": "Limpiar filtros", "b7acbf038b": "No se encontraron espacios de trabajo", - "0c6ee14f23": "niño", + "0c6ee14f23": "hijo", "5fc9d1891b": "Eliminando…", "c83968f87f": "Eliminar proyecto", "64e55f7f01": "Quitar del grupo", "4a08fb55f2": "Mover al grupo", "cbfd565f83": "Nuevo grupo del proyecto", "e82d3589a1": "Cambiar icono de proyecto", - "2cdffbc728": "Configuración del proyecto", + "2cdffbc728": "Ajustes del proyecto", "2ef41bf9a7": "Acciones del proyecto", "609633a9e6": "Acciones del proyecto para {{value0}}", "902115cdbe": "Eliminar grupo", "4d7b73658c": "Cambiar nombre de grupo", - "79465e9034": "Acciones grupales para {{value0}}", - "bfbedc547b": "árboles de trabajo", - "45fbfe0335": "rebautizar", + "79465e9034": "Acciones del grupo para {{value0}}", + "bfbedc547b": "Worktrees", + "45fbfe0335": "renombrar", "ebc5c7dcef": "Ocultar espacios de trabajo secundarios", "84a2238242": "Mostrar espacios de trabajo secundarios", - "045a8aed48": "niños", - "2ca6e29a3c": "repo", + "045a8aed48": "hijos", + "2ca6e29a3c": "repositorio", "bb85cd86ba": "Crear espacio de trabajo para {{value0}}", - "ebadb7eadb": "{{value0}} {{value1}} niño {{value2}}", + "ebadb7eadb": "{{value0}} {{value1}} hijo {{value2}}", "20bebf9c7f": "Mostrar {{value0}} espacio de trabajo secundario", "c1f4a31623": "Mostrar {{value0}} espacios de trabajo secundarios", "e97297cb75": "Ocultar {{value0}} espacio de trabajo secundario", @@ -4092,38 +4101,39 @@ "hostAuthNeeded": "Autenticación requerida", "hostDisconnected": "Desconectado", "failedNestWorkspace": "No se pudo anidar el espacio de trabajo", - "sidebarRowMissing": "El destino ya no existe" + "sidebarRowMissing": "El destino ya no existe", + "failedUnnestWorkspace": "No se pudo quitar la anidación del espacio de trabajo" }, "WorktreeMetaDialog": { "3db0a2a593": "Cancelar", - "b48c271d39": "para guardar, Mayús+Entrar para una nueva línea.", - "7f0be5e9a6": "Admite **markdowns**: negrita, listas, \"código\", enlaces. Presione Entrar o", - "030d484fc0": "Notas sobre este árbol de trabajo...", + "b48c271d39": "para guardar, Mayús+Enter para una nueva línea.", + "7f0be5e9a6": "Admite **markdown** — negrita, listas, `código`, enlaces. Pulsa Enter o", + "030d484fc0": "Notas sobre este worktree...", "9c1d1e9b71": "Comentario", - "5ae06f40fd": "Pegue una URL de solicitud de extracción o ingrese un número. Déjelo en blanco para eliminar el enlace.", + "5ae06f40fd": "Pega una URL de pull request o introduce un número. Déjalo en blanco para eliminar el enlace.", "077a4f7b5c": "PR # o URL de GitHub", "1b91db7e14": "GH PR", - "7c454be4c5": "Pegue la URL de un problema o ingrese un número. Déjelo en blanco para eliminar el enlace.", - "029ea5ec57": "Abrir problema de GitHub", - "741279e7b7": "Número de problema o URL de GitHub", - "645fa4a0fd": "Problema de GH", - "459ad7f650": "Solo cambia el nombre que se muestra en la barra lateral; la carpeta en el disco permanece igual. Déjelo en blanco para usar el nombre de la rama o la carpeta.", + "7c454be4c5": "Pega una URL de issue o introduce un número. Déjalo en blanco para eliminar el enlace.", + "029ea5ec57": "Abrir issue de GitHub", + "741279e7b7": "Issue # o URL de GitHub", + "645fa4a0fd": "GH Issue", + "459ad7f650": "Solo cambia el nombre que se muestra en la barra lateral — la carpeta en disco no cambia. Déjalo en blanco para usar el nombre de la rama o carpeta.", "7f21e0464f": "Nombre para mostrar personalizado...", "ad5e4e514f": "Nombre para mostrar", - "65770ad0f0": "Edite enlaces y notas de GitHub para este espacio de trabajo.", - "382fd11a3e": "Editar detalles del árbol de trabajo", - "2174f17011": "Ahorrar", - "61d6f612cf": "Ahorro..." + "65770ad0f0": "Edita enlaces y notas de GitHub para este espacio de trabajo.", + "382fd11a3e": "Editar detalles del worktree", + "2174f17011": "Guardar", + "61d6f612cf": "Guardando..." }, "WorktreeOpenInMenu": { - "1417fd8380": "Personalizar aplicaciones...", + "1417fd8380": "Personalizar apps...", "8009ab69a6": "Abrir en", - "bd0e8159f8": "Verifique el comando del editor o la configuración del administrador de archivos en esta máquina.", + "bd0e8159f8": "Verifica el comando del editor o la configuración del gestor de archivos en esta máquina.", "9a5381eb09": "No se pudo abrir la carpeta del espacio de trabajo.", - "0bed8727db": "Es posible que se haya movido o eliminado. Actualizar los espacios de trabajo o eliminarlos de Orca.", + "0bed8727db": "Puede que se haya movido o eliminado. Actualiza los espacios de trabajo o quítalo de Orca.", "3921d3d9a5": "No se encontró la carpeta del espacio de trabajo.", "f387af445b": "La ruta del espacio de trabajo no es una ruta local válida.", - "3ec372b664": "administrador de archivos" + "3ec372b664": "gestor de archivos" }, "WorktreeTitleInlineRename": { "2f42ae024f": "No leído:", @@ -4131,9 +4141,9 @@ "8df295a78d": "No se pudo cambiar el nombre del espacio de trabajo." }, "WorktreeVisibilityDialog": { - "83a5ba8dd1": "Árboles de trabajo que no son de Orca", + "83a5ba8dd1": "Worktrees que no son de Orca", "f1f71b9f02": "Importar", - "759371df43": "Esconder", + "759371df43": "Ocultar", "25ddf19920": "{{value0}} disponible para importar", "8372e4bbd9": "{{value0}} mostrado actualmente", "5d02a5647f": "Oculto de la barra lateral", @@ -4150,12 +4160,12 @@ "7edb8ebe24": "Clonar desde URL", "a6c20dca96": "Abrir un proyecto desde un destino SSH", "3d162cc76f": "Proyecto remoto", - "fb4fc5380e": "Proyecto local, repo de Git o carpeta con muchos repos", + "fb4fc5380e": "Proyecto local, repositorio de Git o carpeta con muchos repositorios", "2281fdc8c7": "Explorar carpeta", - "sshCreateUnavailable": "Not available for SSH hosts yet", - "sshBrowseTitle": "Open project on SSH host", - "sshBrowseDescription": "Existing Git repository or folder on this SSH host", - "runtimeBrowseDescription": "Existing Git repository or folder on this host" + "sshCreateUnavailable": "Aún no disponible para hosts SSH", + "sshBrowseTitle": "Abrir proyecto en host SSH", + "sshBrowseDescription": "Repositorio Git o carpeta existente en este host SSH", + "runtimeBrowseDescription": "Repositorio Git o carpeta existente en este host" } } } @@ -4164,18 +4174,18 @@ "delete": { "worktree": { "flow": { - "b81b4e40ca": "Actualice el espacio e inténtelo nuevamente si la lista de espacios de trabajo parece obsoleta.", - "7243145cd6": "No se han seleccionado espacios de trabajo eliminables", + "b81b4e40ca": "Actualiza el espacio e inténtalo de nuevo si la lista de espacios de trabajo parece obsoleta.", + "7243145cd6": "No se seleccionaron espacios de trabajo que se puedan eliminar", "ae57cbf6e4": "No se pudo eliminar el espacio de trabajo", - "7488ed8711": "Vista", + "7488ed8711": "Ver", "2b20ce87b3": "Forzar eliminación", - "4f3876c0f5": "Error al forzar la eliminación" + "4f3876c0f5": "Falló la eliminación forzada" }, "toast": { "1d0fa5c0a5": "No se pudo eliminar el espacio de trabajo {{value0}}", - "ead7b8ee15": "Ha cambiado archivos. Utilice Forzar eliminación para eliminarlo de todos modos.", - "905fc8efac": "Git ya eliminó este espacio de trabajo. Utilice Forzar eliminación para borrarlo de Orca.", - "0899ebdb28": "Git ya olvidó este espacio de trabajo, pero su directorio todavía está en el disco. Utilice Forzar eliminación para eliminar el directorio huérfano." + "ead7b8ee15": "Tiene archivos modificados. Usa Forzar eliminación para eliminarlo de todos modos.", + "905fc8efac": "Git ya eliminó este espacio de trabajo. Usa Forzar eliminación para borrarlo de Orca.", + "0899ebdb28": "Git ya olvidó este espacio de trabajo, pero su directorio todavía está en disco. Usa Forzar eliminación para eliminar el directorio huérfano." } } }, @@ -4193,9 +4203,9 @@ "header": { "create": { "state": { - "992cfbc44b": "Crear un nuevo árbol de trabajo para {{value0}}", - "3a70acd808": "Vuelva a conectar el destino SSH antes de crear espacios de trabajo para {{value0}}", - "6d022563a8": "Vuelva a conectar el destino SSH antes de crear espacios de trabajo", + "992cfbc44b": "Crear un nuevo worktree para {{value0}}", + "3a70acd808": "Reconecta el destino SSH antes de crear espacios de trabajo para {{value0}}", + "6d022563a8": "Reconecta el destino SSH antes de crear espacios de trabajo", "62e71f2d5d": "Crear espacio de trabajo para {{value0}}" } } @@ -4205,10 +4215,10 @@ "project": { "drop": { "669e12dd97": "Carpetas locales y repositorios Git", - "ffc769ca29": "Carpeta desplegable para agregar proyecto", - "740e8d0d46": "Utilice Agregar proyecto para rutas de servidor", - "e344666fb8": "Tiempo de ejecución del servidor activo", - "d0f8943f8b": "Preparando el flujo de adición del proyecto", + "ffc769ca29": "Suelta una carpeta para agregar el proyecto", + "740e8d0d46": "Usa Agregar proyecto para rutas de host", + "e344666fb8": "Servidor activo", + "d0f8943f8b": "Preparando el flujo para agregar proyecto", "18d3cf40e9": "Comprobando carpeta" } } @@ -4216,17 +4226,17 @@ "sleep": { "worktree": { "flow": { - "c460fecc4a": "No se pudieron dormir algunos espacios de trabajo", - "8bc3fc0671": "No se pudo dormir el espacio de trabajo" + "c460fecc4a": "No se pudieron poner en reposo algunos espacios de trabajo", + "8bc3fc0671": "No se pudo poner en reposo el espacio de trabajo" } } }, "useAddRepoCloneFlow": { "4d0013cc93": "Repositorio clonado", - "0dc4d1b657": "Introduzca una ruta de servidor para el destino del clon." + "0dc4d1b657": "Introduce una ruta del host para el destino del clon." }, "useAddRepoLocalFolderFlow": { - "7ab10e4974": "Utilice una ruta de servidor para agregar proyectos desde un tiempo de ejecución remoto.", + "7ab10e4974": "Usa una ruta del host para agregar proyectos desde un host remoto.", "skippedBatchFolders": "Se omitieron algunas carpetas", "skippedBatchFoldersDescription": "Agrega las carpetas omitidas de forma individual para revisarlas o confirmarlas." }, @@ -4236,32 +4246,32 @@ "1b33c5f090": "No se importaron repositorios" }, "useSidebarProjectDrop": { - "f34a286c0d": "No se pudo agregar la carpeta descartada.", + "f34a286c0d": "No se pudo agregar la carpeta soltada.", "451a4638db": "Suelta una carpeta para agregarla como proyecto.", - "5ccb56c7be": "Utilice Agregar proyecto para ingresar una ruta de servidor.", - "849ef13dc0": "Las ubicaciones de carpetas locales no están disponibles para los tiempos de ejecución del servidor.", - "c0315153d1": "Suelte una carpeta a la vez." + "5ccb56c7be": "Usa Agregar proyecto para introducir una ruta del host.", + "849ef13dc0": "No se pueden soltar carpetas locales en runtimes de servidor.", + "c0315153d1": "Suelta una carpeta a la vez." }, "workspace": { "status": { "cb387159f6": "En curso", "6c1efa2cf8": "En revisión", "6b8285b8dd": "Hecho", - "93ac840dcb": "Obstruido", - "2c19d1db33": "Jugar", + "93ac840dcb": "Bloqueado", + "2c19d1db33": "Reproducir", "111db162bf": "En pausa", "642da473f2": "Alerta", "6380517b10": "Bandera", - "251c817bdd": "Minutero", - "409528031f": "Revisar", - "5f9ca31a84": "Espera", - "821d156f54": "discontinuo", + "251c817bdd": "Temporizador", + "409528031f": "Revisión", + "5f9ca31a84": "En espera", + "821d156f54": "Discontinuo", "226d1e7773": "Progreso", "a702bc08d4": "Punto", "b4a7101fe1": "Círculo", - "1a9383112b": "Progreso del director", - "caebe3c10f": "Revisión del director", - "895f381714": "Director hecho", + "1a9383112b": "Conductor Progress", + "caebe3c10f": "Conductor Review", + "895f381714": "Conductor Done", "caabd5ca85": "Zinc", "7adb43ecf0": "Rosa", "ddf25b6262": "Esmeralda", @@ -4276,16 +4286,16 @@ "card": { "compact": { "agents": { - "a128d7006b": "{{value0}} {{value1}} niño {{value2}}", - "289a1d2ca7": "Expanda {{value0}}. {{value1}}", + "a128d7006b": "{{value0}} {{value1}} hijo {{value2}}", + "289a1d2ca7": "Expandir {{value0}}. {{value1}}", "0c1debfe84": "Contraer {{value0}}" } } }, "list": { "groups": { - "0ed04075b8": "Todo", - "4aeefc5996": "Fijado", + "0ed04075b8": "Todos", + "4aeefc5996": "Fijados", "682ed5d551": "Cerrado", "7c2f009786": "En curso", "6798dc7c94": "En revisión", @@ -4294,52 +4304,52 @@ } }, "CacheTimer": { - "07729cc155": "venció" + "07729cc155": "expirado" }, "DeleteWorktreeDialogFooter": { "c0e972d726": "Cancelar", - "cf95e3b5bb": "Cerca" + "cf95e3b5bb": "Cerrar" }, "index": { "b826a98b6f": "ocupado" }, "LinearAgentSkillSetupPrompt": { - "missingCliAndSkill": "Faltan la CLI de Orca y la habilidad de Agent de Linear.", - "modalTitle": "Activar acceso a tickets de Linear", - "modalDescription": "Instala la habilidad de Linear desde una terminal.", - "modalPrompt": "Permite que los Agents lean y editen el ticket de Linear adjunto.", + "missingCliAndSkill": "Faltan la CLI de Orca y el skill de Linear para Agents.", + "modalTitle": "Habilitar acceso a issues de Linear", + "modalDescription": "Instala el skill de Linear desde una terminal.", + "modalPrompt": "Permite que los Agents lean y editen el issue de Linear adjunto.", "dontShowAgain": "No volver a mostrar", "notNow": "Ahora no", - "missingBoth": "Faltan la CLI de Orca y la habilidad de Agent de Linear.", + "missingBoth": "Faltan la CLI de Orca y el skill de Linear para Agents.", "missingCli": "Falta la CLI de Orca.", - "missingSkill": "Falta la habilidad de Agent de Linear.", - "title": "Configurar habilidad de Agent de Linear", - "remoteCopy": "Esto instala la configuración del host; los entornos de Agent remotos pueden necesitar una configuración separada.", - "hostCopy": "Instálala para traspasos de Agent del host desde trabajo Linear enlazado.", - "dismiss": "Descartar configuración de habilidad de Agent de Linear", + "missingSkill": "Falta el skill de Linear para Agents.", + "title": "Configurar skill de Linear para Agents", + "remoteCopy": "Esto instala el setup del host; los entornos de Agents remotos pueden necesitar setup propio.", + "hostCopy": "Instálalo para handoffs de Agents del host desde trabajo de Linear enlazado.", + "dismiss": "Descartar setup del skill de Linear para Agents", "setup": "Configurar", "recheck": "Volver a comprobar", - "panelTitle": "Habilidad de Agent de Linear", - "panelDescription": "Instala la habilidad de Agent del host para traspasos desde tareas Linear enlazadas.", - "terminalTitle": "Instalar habilidad de Agent de Linear", - "terminalAria": "Terminal de instalación de la habilidad de Agent de Linear", - "install": "Instalar CLI y habilidad", - "successTitle": "El acceso a tickets de Linear está listo", - "successDescription": "Los Agents ya pueden leer y actualizar tickets de Linear enlazados desde este espacio de trabajo.", - "successDescriptionWsl": "Los Agents de WSL ya pueden usar tickets de Linear enlazados desde este espacio de trabajo.", - "successDescriptionRemote": "Los Agents del host ya pueden usar tickets de Linear enlazados. Los entornos de Agent remotos aún pueden necesitar su propia configuración.", - "successStatus": "Acceso a tickets de Linear listo", + "panelTitle": "Skill de Linear para Agents", + "panelDescription": "Instala el skill de Agents del host para handoffs desde tareas de Linear enlazadas.", + "terminalTitle": "Instalar skill de Linear para Agents", + "terminalAria": "Terminal de instalación del skill de Linear para Agents", + "install": "Instalar CLI y skill", + "successTitle": "El acceso a issues de Linear está listo", + "successDescription": "Los Agents ya pueden leer y actualizar issues de Linear enlazados desde este espacio de trabajo.", + "successDescriptionWsl": "Los Agents de WSL ya pueden usar issues de Linear enlazados desde este espacio de trabajo.", + "successDescriptionRemote": "Los Agents del host ya pueden usar issues de Linear enlazados. Los entornos de Agents remotos aún pueden necesitar setup propio.", + "successStatus": "Acceso a issues de Linear listo", "done": "Listo", - "wslCopy": "Instálala para traspasos de Agent en WSL desde trabajo Linear enlazado.", + "wslCopy": "Instálalo para handoffs de Agents en WSL desde trabajo de Linear enlazado.", "wslLabel": "WSL predeterminado", - "toastMissingCliAndSkill": "Faltan la CLI de Orca y la habilidad de Linear", + "toastMissingCliAndSkill": "Faltan la CLI de Orca y el skill de Linear", "toastMissingCli": "Falta la CLI de Orca", - "toastMissingSkill": "Falta la habilidad de Linear", - "toastInstallCliAndSkillDescription": "Instala la CLI de Orca y la habilidad de Linear para permitir que tus Agents lean y editen tareas de Linear.", + "toastMissingSkill": "Falta el skill de Linear", + "toastInstallCliAndSkillDescription": "Instala la CLI de Orca y el skill de Linear para permitir que tus Agents lean y editen tareas de Linear.", "toastInstallCliDescription": "Instala la CLI de Orca para permitir que tus Agents lean y editen tareas de Linear.", - "toastInstallSkillDescription": "Instala la habilidad de Linear para permitir que tus Agents lean y editen tareas de Linear a través de la CLI de Orca.", - "toastRemoteDescription": "{{value0}} Los entornos de Agent remotos pueden necesitar su propia configuración.", - "toastWslDescription": "{{value0}} Esta configuración se ejecuta en el runtime de Agent WSL seleccionado." + "toastInstallSkillDescription": "Instala el skill de Linear para permitir que tus Agents lean y editen tareas de Linear a través de la CLI de Orca.", + "toastRemoteDescription": "{{value0}} Los entornos de Agents remotos pueden necesitar setup propio.", + "toastWslDescription": "{{value0}} Este setup se ejecuta en el entorno de Agent WSL seleccionado." }, "FolderWorkspaceComposerDialog": { "connectFailed": "No se pudo conectar al proyecto.", @@ -4359,111 +4369,111 @@ "AddRepoHostSelector": { "host": "Host", "local": "Local", - "runtime": "Server", + "runtime": "Servidor", "ssh": "SSH", "connecting": "Conectando", "connect": "Conectar", "addSshHost": "Agregar host SSH", "addRemoteServer": "Agregar servidor remoto", "addRemoteHost": "Agregar host remoto", - "addRemoteHostTooltip": "Elija SSH para una máquina en la que pueda iniciar sesión o servidor remoto para un servidor Orca.", - "addSshHostDetail": "Utilice una máquina existente a través de SSH.", - "addRemoteServerDetail": "Emparéjelo con Orca ejecutándose en otra computadora.", + "addRemoteHostTooltip": "Elige SSH para una máquina donde puedas iniciar sesión, o servidor remoto para un servidor de Orca.", + "addSshHostDetail": "Usa una máquina existente mediante SSH.", + "addRemoteServerDetail": "Empareja con Orca ejecutándose en otro equipo.", "addRemoteHostDetail": "Host SSH o servidor Orca" }, "sidebarHostOptions": { - "3e102f111c": "All hosts", + "3e102f111c": "Todos los hosts", "visibleHostsCount": "{{value0}} hosts" }, "SidebarHostScopeStrip": { "scopedTo": "{{value0}} visible", - "backToAll": "All hosts" + "backToAll": "Todos los hosts" }, "HostRemoveDialog": { - "1a2b3c4d5e": "Removed {{value0}}", - "2b3c4d5e6f": "Failed to remove host", - "3c4d5e6f7a": "Remove {{value0}}?", - "4d5e6f7a8b": "This opens the Orca servers settings where you can remove this server.", - "5e6f7a8b9c": "This removes the saved SSH host and its credentials from this computer. Remote files are not deleted.", + "1a2b3c4d5e": "Se quitó {{value0}}", + "2b3c4d5e6f": "No se pudo quitar el host", + "3c4d5e6f7a": "¿Quitar {{value0}}?", + "4d5e6f7a8b": "Esto abre los ajustes de servidores de Orca, donde puedes quitar este servidor.", + "5e6f7a8b9c": "Esto quita el host SSH guardado y sus credenciales de este equipo. Los archivos remotos no se eliminan.", "6f7a8b9c0d": "Cancelar", - "7a8b9c0d1e": "Open settings", - "8b9c0d1e2f": "Remove host" + "7a8b9c0d1e": "Abrir ajustes", + "8b9c0d1e2f": "Quitar host" }, "HostRenameDialog": { - "1a2b3c4d5e": "Rename host", - "2b3c4d5e6f": "This label is shown only on this computer. Leave it blank to use the default name.", - "3c4d5e6f7a": "Display name", - "4d5e6f7a8b": "Reset to default", + "1a2b3c4d5e": "Renombrar host", + "2b3c4d5e6f": "Esta etiqueta se muestra solo en este equipo. Déjala en blanco para usar el nombre predeterminado.", + "3c4d5e6f7a": "Nombre para mostrar", + "4d5e6f7a8b": "Restablecer predeterminado", "5e6f7a8b9c": "Cancelar", - "6f7a8b9c0d": "Save" + "6f7a8b9c0d": "Guardar" }, "HostSectionHeaderMenu": { - "5b8b4b6a01": "Update server required", - "9b3c1d2e44": "Update client required", - "2c29e2de68": "Connection failed", + "5b8b4b6a01": "Se requiere actualizar el servidor", + "9b3c1d2e44": "Se requiere actualizar el cliente", + "2c29e2de68": "Falló la conexión", "bf07aee59e": "No se pudo desconectar", - "7f1a2b3c4d": "{{value0}} is reachable", - "4f2c8a9b10": "Host actions for {{value0}}", - "6b7c8d9e10": "Host actions", - "8d1e2f3a4b": "Rename…", - "63f36455cc": "Reconnect", - "59b553e2aa": "Disconnect", - "2d3e4f5a6b": "Check connection", - "3c4d5e6f7a": "Manage host…", - "6e7f8a9b0c": "Remove host…" + "7f1a2b3c4d": "{{value0}} está accesible", + "4f2c8a9b10": "Acciones de host para {{value0}}", + "6b7c8d9e10": "Acciones de host", + "8d1e2f3a4b": "Renombrar…", + "63f36455cc": "Reconectar", + "59b553e2aa": "Desconectar", + "2d3e4f5a6b": "Verificar conexión", + "3c4d5e6f7a": "Administrar host…", + "6e7f8a9b0c": "Quitar host…" }, "WorkspaceKanbanDrawer": { - "1975a4e480": "Error al sincronizar el estado de la tarea", - "e02b0d92ff": "Sincronización del estado de la tarea omitida", - "c1d2e3f4a5": "No se pudo leer la incidencia {{value0}} de Linear.", - "d2e3f4a5b6": "No hay ningún estado de flujo de trabajo de Linear que coincida con {{value0}}.", - "e3f4a5b6c7": "Varios estados de flujo de trabajo de Linear coinciden con {{value0}}.", - "f4a5b6c7d8": "No se pudo actualizar la incidencia {{value0}} de Linear.", - "a5b6c7d8e9": "No se pudo sincronizar la incidencia {{value0}} de Linear.", + "1975a4e480": "Falló la sincronización del estado de la tarea", + "e02b0d92ff": "Se omitió la sincronización del estado de la tarea", + "c1d2e3f4a5": "No se pudo leer el issue {{value0}} de Linear.", + "d2e3f4a5b6": "No hay ningún estado de workflow de Linear que coincida con {{value0}}.", + "e3f4a5b6c7": "Varios estados de workflow de Linear coinciden con {{value0}}.", + "f4a5b6c7d8": "No se pudo actualizar el issue {{value0}} de Linear.", + "a5b6c7d8e9": "No se pudo sincronizar el issue {{value0}} de Linear.", "b6c7d8e9f0": "No se pudo completar la sincronización del estado de la tarea.", - "c7d8e9f0a1": "{{value0}} actualizadas", - "d8e9f0a1b2": "{{value0}} omitidas", - "e9f0a1b2c3": "{{value0}} fallidas" + "c7d8e9f0a1": "{{value0}} actualizado", + "d8e9f0a1b2": "{{value0}} omitido", + "e9f0a1b2c3": "{{value0}} falló" }, "WorktreeParentPickerPopover": { - "failedSetParent": "No se pudo establecer el árbol de trabajo padre", + "failedSetParent": "No se pudo establecer el worktree padre", "current": "Actual", - "searchPlaceholder": "Buscar árboles de trabajo...", - "empty": "No hay árboles de trabajo elegibles que coincidan." + "searchPlaceholder": "Buscar worktrees...", + "empty": "No hay worktrees elegibles que coincidan." }, "WorktreeCardStatusSlot": { "branchIdentity": "Rama" }, "NewExternalWorktreesInboxLine": { - "9f2d4c8b17": "Ocultar árboles de trabajo externos permanentemente para {{value0}}", - "d9f7b2a14c": "Contraer árboles de trabajo creados externamente para {{value0}}", - "e2c4a8d91f": "Expandir árboles de trabajo creados externamente para {{value0}}", - "7c4e9b2a81": "Nuevos árboles de trabajo creados externamente", - "5e1b8d3f62": "Ocultar árboles de trabajo externos permanentemente", - "4d7a1c9e53": "Estos árboles de trabajo se crearon fuera de Orca.", + "9f2d4c8b17": "Ocultar worktrees externos permanentemente para {{value0}}", + "d9f7b2a14c": "Contraer worktrees nuevos creados externamente para {{value0}}", + "e2c4a8d91f": "Expandir worktrees nuevos creados externamente para {{value0}}", + "7c4e9b2a81": "Nuevos worktrees creados externamente", + "5e1b8d3f62": "Ocultar worktrees externos permanentemente", + "4d7a1c9e53": "Estos worktrees se crearon fuera de Orca.", "8b3f2e1d74": "Importar", "1c9e7a4b28": "Mantener ocultos", "6f2d8c1e95": "Importar todo", "c3e8a1f4b2": "No volver a mostrar" }, "newExternalWorktreesInboxActions": { - "a11c2f6d89": "No se pudieron mantener ocultos los árboles de trabajo externos. Inténtalo de nuevo.", - "b7e4d1a062": "No se pudieron importar los árboles de trabajo externos. Inténtalo de nuevo.", - "c94f0b3a15": "No se pudieron ocultar permanentemente los árboles de trabajo externos. Inténtalo de nuevo." + "a11c2f6d89": "No se pudieron mantener ocultos los worktrees externos. Inténtalo de nuevo.", + "b7e4d1a062": "No se pudieron importar los worktrees externos. Inténtalo de nuevo.", + "c94f0b3a15": "No se pudieron ocultar permanentemente los worktrees externos. Inténtalo de nuevo." }, "SuppressExternalWorktreeInboxDialog": { - "a4c2d8f1b0": "¿Ocultar árboles de trabajo externos?", - "6e91b3c4d2": "Los árboles de trabajo externos ya no se mostrarán en la barra lateral ni en esta lista para {{value0}}, incluidos los que se creen más adelante.", + "a4c2d8f1b0": "¿Ocultar worktrees externos?", + "6e91b3c4d2": "Los worktrees externos ya no se mostrarán en la barra lateral ni en esta lista para {{value0}}, incluidos los que se creen más adelante.", "1f8a5d9e73": "Puedes volver a activarlo más tarde desde los ajustes del proyecto.", - "8c0b2e7a41": "Abrir ajustes de árboles de trabajo no pertenecientes a Orca", + "8c0b2e7a41": "Abrir ajustes de worktrees que no son de Orca", "5d1c9f0a82": "Cancelar", - "3b7e4a1c96": "Ocultar árboles de trabajo externos" + "3b7e4a1c96": "Ocultar worktrees externos" }, "useAddRepoHostSelection": { "connectionFailed": "La conexión SSH falló." }, "AddRemoteHostDialog": { - "sshHostRequired": "Se requiere un alias de configuración de host o SSH.", + "sshHostRequired": "Se requiere un host o alias de configuración SSH.", "sshPortInvalid": "El puerto debe estar entre 1 y 65535.", "sshRelayGraceInvalid": "El tiempo de espera del terminal debe estar entre 60 y {{value0}} segundos.", "sshSaved": "Se agregó el host SSH.", @@ -4473,14 +4483,14 @@ "serverSaveFailed": "No se pudo agregar el servidor remoto.", "serverTitle": "Agregar servidor remoto", "sshTitle": "Agregar host SSH", - "serverDescription": "Emparéjelo con Orca ejecutándose en otra computadora.", - "sshDescription": "Agregue una máquina persistente en la que pueda iniciar sesión a través de SSH.", + "serverDescription": "Empareja con Orca ejecutándose en otro equipo.", + "sshDescription": "Agrega una máquina persistente donde puedas iniciar sesión por SSH.", "cancel": "Cancelar", - "saving": "Ahorro...", - "save": "Ahorrar", + "saving": "Guardando...", + "save": "Guardar", "label": "Etiqueta", "sshLabelPlaceholder": "Dev box", - "sshHost": "Anfitrión o alias", + "sshHost": "Host o alias", "sshHostPlaceholder": "deploy@server:22", "username": "Nombre de usuario", "usernamePlaceholder": "deploy", @@ -4489,36 +4499,36 @@ "identityFilePlaceholder": "~/.ssh/id_ed25519 (opcional)", "serverName": "Nombre del servidor", "serverNamePlaceholder": "Dev box", - "pairingCode": "código de emparejamiento", + "pairingCode": "Código de emparejamiento", "pairingCodePlaceholder": "orca://pair?code=...", "pairingHelpPrefix": "Ejecuta", "pairingCommand": "orca serve --pairing-address ", "pairingHelpSuffix": "en el servidor y pega la URL de emparejamiento impresa.", "sshImportAlreadySynced": "~/.ssh/config ya está sincronizado.", - "sshImportSynced": "Sincronizado {{value0}} host{{value1}}.", + "sshImportSynced": "Se sincronizó {{value0}} host{{value1}}.", "sshImportFailed": "No se pudo importar la configuración SSH.", - "importing": "Importador...", + "importing": "Importando...", "importSshConfig": "Importar ~/.ssh/config", - "sshPersistenceDefault": "Los terminales remotos de este host seguirán activos hasta que los cierres o restablezcas el relé." + "sshPersistenceDefault": "Los terminales remotos en este host seguirán activos hasta que los cierres o restablezcas el relay." } }, "shared": { "useDaemonActions": { "01af244097": "Cancelar", - "28c8e53176": "Esto fuerza el cierre de todos los paneles de terminal en ejecución en todos los espacios de trabajo. Cualquier trabajo no guardado en esas sesiones se perderá. El demonio sigue ejecutándose y se pueden abrir nuevas terminales inmediatamente. Esto no se puede deshacer.", - "1bbea41a77": "¿Eliminar todas las sesiones de terminal?", - "01d6b7c64e": "Elimina todos los paneles de terminal en ejecución y reinicia el proceso del demonio. Los paneles muestran \"Proceso cerrado\" y se pueden volver a abrir inmediatamente. Se conservan las sesiones de protocolo heredado de una versión anterior de la aplicación. Esto no se puede deshacer.", - "922548bc66": "¿Reiniciar el demonio del terminal?", + "28c8e53176": "Esto fuerza el cierre de todos los paneles de terminal en ejecución en todos los espacios de trabajo. Cualquier trabajo sin guardar en esas sesiones se perderá. El servicio sigue ejecutándose y se pueden abrir nuevos terminales inmediatamente. Esto no se puede deshacer.", + "1bbea41a77": "¿Terminar todas las sesiones de terminal?", + "01d6b7c64e": "Termina todos los paneles de terminal en ejecución y reinicia el proceso del servicio. Los paneles muestran \"Process exited\" y se pueden volver a abrir inmediatamente. Se conservan las sesiones de protocolo heredado de una versión anterior de la app. Esto no se puede deshacer.", + "922548bc66": "¿Reiniciar el servicio del terminal?", "2b4efdc162": "No se pudieron finalizar las sesiones.", "d18f3005c2": "{{value0}} sesión{{value1}} se negó a salir.", "baad8cd651": "No hay sesiones en ejecución.", - "fe2ab66d45": "Eliminó {{value0}} de {{value1}} sesiones. {{value2}} se negó a salir.", + "fe2ab66d45": "Se terminaron {{value0}} de {{value1}} sesiones. {{value2}} se negó a salir.", "d762b41f41": "El reinicio falló.", - "b5954e12d3": "Error al reiniciar: verifique los registros.", - "0e9da1b98e": "Daemon se reinició.", - "d6372cc797": "Mató a {{value0}} sesión{{value1}}.", - "87412c2a68": "Sesión {{value0}} eliminada.", - "a2f040ac1c": "Mató {{value0}} sesiones.", + "b5954e12d3": "Error al reiniciar — revisa los logs.", + "0e9da1b98e": "Servicio reiniciado.", + "d6372cc797": "Se terminó {{value0}} sesión{{value1}}.", + "87412c2a68": "Se terminó {{value0}} sesión.", + "a2f040ac1c": "Se terminaron {{value0}} sesiones.", "63520148e2": "La sesión {{value0}} se negó a salir.", "cc0a26cb14": "{{value0}} sesiones se negaron a salir." } @@ -4526,10 +4536,10 @@ "setup": { "guide": { "SetupGuideModal": { - "3598a3ca0c": "Termine los flujos de trabajo principales que hacen que Orca sea útil para el trabajo de agentes paralelos.", - "48a9e5ef2d": "Empezando", - "28cf59fcb4": "Esto ocultará la lista de verificación de la barra lateral.", - "f3b5ffb2a6": "Ocultar lista de verificación de la barra lateral" + "3598a3ca0c": "Completa los flujos de trabajo principales que hacen útil a Orca para trabajar con agentes en paralelo.", + "48a9e5ef2d": "Primeros pasos", + "28cf59fcb4": "Esto ocultará el checklist de la barra lateral.", + "f3b5ffb2a6": "Ocultar checklist de la barra lateral" }, "SetupGuideProgressRing": { "dac3a4724a": "{{value0}} de {{value1}} pasos de configuración completos" @@ -4544,7 +4554,7 @@ "to": { "date": { "setting": { - "f8bda25f29": "Mantenga actualizado el principal local" + "f8bda25f29": "Mantener main local actualizado" } } } @@ -4555,88 +4565,129 @@ "AccountsPane": { "c2d2751587": "Eliminar cuenta", "dbb9626ed1": "Cancelar", - "854ebbcc45": "Orca eliminará la autenticación de Claude administrada para esta cuenta guardada. Si está actualmente activo, Orca recurre al inicio de sesión Claude predeterminado del sistema.", + "854ebbcc45": "Orca eliminará la autenticación de Claude gestionada para esta cuenta guardada. Si está activa actualmente, Orca volverá al login predeterminado de Claude del sistema.", "63843e37e2": "¿Eliminar la cuenta de Claude?", - "99c8f9e498": "Orca eliminará el hogar del Codex administrado para esta cuenta guardada. Si está actualmente activo, Orca recurre al inicio de sesión predeterminado del Codex del sistema.", - "0d47394635": "¿Eliminar cuenta del Codex?", + "99c8f9e498": "Orca eliminará el home de Codex gestionado para esta cuenta guardada. Si está activo actualmente, Orca volverá al login predeterminado de Codex del sistema.", + "0d47394635": "¿Eliminar cuenta de Codex?", "ae3b21eb6c": "opencode.ai/workspace/wrk__…/go", - "51c9104e13": "Encuentre esto en la URL después de iniciar sesión en opencode.ai (p. ej.", - "b398b834c9": "Claro", + "51c9104e13": "Encuéntralo en la URL después de iniciar sesión en opencode.ai (p. ej.", + "b398b834c9": "Limpiar", + "316ca4e610": "Olvidar cookie", "a122332371": "wrk_… (dejar en blanco para búsqueda automática)", - "dbdb0b0bd8": "Anulación de ID del espacio de trabajo", - "d70a5287a4": "Anulación opcional del ID del espacio de trabajo si falla la búsqueda automática.", - "02cb127710": "ID del espacio de trabajo de OpenCode Go", - "7ce0e1907c": "). Encuéntrelo en DevTools → Red → cualquier solicitud de opencode.ai → Encabezado de cookies de su navegador. La autenticación OpenCode Go está basada en la web y se comparte entre terminales Windows y WSL.", - "8951c5309f": "autenticación=Fe26.2**…", + "dbdb0b0bd8": "Override de Workspace ID", + "d70a5287a4": "Override opcional de Workspace ID si falla la búsqueda automática.", + "02cb127710": "Workspace ID de OpenCode Go", + "7ce0e1907c": "). Encuéntralo en DevTools → Network → cualquier request de opencode.ai → encabezado Cookie. La autenticación de OpenCode Go está basada en web y se comparte entre terminales Windows y WSL.", + "8951c5309f": "auth=Fe26.2**…", "338820326a": ") o el encabezado completo de la cookie (p. ej.", "922b51e02d": "Fe26.2**…", - "0023cc336e": "Pegue el valor del token sin formato (p. ej.", + "0023cc336e": "Pega el valor raw del token (p. ej.", "a7e38affcd": "Fe26.2**… token o auth=Fe26.2**… encabezado", "67e3c33670": "Cookie de sesión de OpenCode Go", - "b2b1aa936d": "Pegue su cookie de sesión de opencode.ai para obtener el límite de velocidad.", + "b2b1aa936d": "Pega tu cookie de sesión de opencode.ai para obtener rate limits.", "36223200ac": "Cookie de sesión de OpenCode Go", - "ea631977b5": "Configure los ajustes del proveedor OpenCode Go.", + "ea631977b5": "Configura los ajustes del proveedor OpenCode Go.", "4ac10b4d08": "OpenCode Go", - "c2aee76420": "Extrae las credenciales de OAuth de su instalación local de CLI de Gemini para autenticarse con Google para {{value0}}. Esto utiliza credenciales emitidas para la aplicación Gemini CLI, no para Orca. Puede fallar si Google actualiza la CLI. Úselo bajo su propio riesgo.", - "96f3649526": "Utilice las credenciales de Gemini CLI (experimental)", - "d676c41fc6": "Extrae las credenciales de OAuth de su instalación local de CLI de Gemini para autenticarse con Google. Esto utiliza credenciales emitidas para la aplicación Gemini CLI, no para Orca. Puede fallar si Google actualiza la CLI. Úselo bajo su propio riesgo.", - "0c7f915b01": "Utilice las credenciales de la CLI de Gemini", - "973741a871": "Configure los ajustes del proveedor Gemini.", + "c2aee76420": "Extrae credenciales OAuth de tu instalación local de Gemini CLI para autenticarse con Google para {{value0}}. Usa credenciales emitidas para la app Gemini CLI, no para Orca. Puede romperse si Google actualiza la CLI. Úsalo bajo tu propio riesgo.", + "96f3649526": "Usar credenciales de Gemini CLI (experimental)", + "d676c41fc6": "Extrae credenciales OAuth de tu instalación local de Gemini CLI para autenticarse con Google. Usa credenciales emitidas para la app Gemini CLI, no para Orca. Puede romperse si Google actualiza la CLI. Úsalo bajo tu propio riesgo.", + "0c7f915b01": "Usar credenciales de Gemini CLI", + "973741a871": "Configura los ajustes del proveedor Gemini.", "0c64dc2a64": "Gemini", "db209ee572": "Eliminar", "8a0f870153": "Volver a autenticar", "3d245ef7d9": "Codex informó que este inicio de sesión no está actualizado", "589eba1eee": "Necesita volver a autenticarse", "e74831fb6b": "Activo", - "b4c9450319": "No hay cuentas gestionadas de Codex para {{value0}}. Orca utilizará el inicio de sesión de Codex predeterminado del sistema de ese entorno hasta que agregue uno aquí.", + "b4c9450319": "No hay cuentas de Codex gestionadas para {{value0}}. Orca usará el login predeterminado de Codex del sistema de ese entorno hasta que agregues una aquí.", "93c47b333a": "Necesita iniciar sesión", - "f2a265f8c7": "Valor predeterminado del sistema", + "f2a265f8c7": "Predeterminado del sistema", "b0e948a4f9": "Agregar cuenta", "c0a52abfc5": "Mostrando cuentas para {{value0}}. Las nuevas cuentas se agregan allí.", "94d351af4a": "Cuentas", - "d0d53b7eb0": "Administre qué cuenta Codex utiliza Orca para obtener el límite de tasa en vivo.", - "3180536c7a": "Cuentas del Codex", - "340d6f7a85": "Cada cuenta mantiene su propio contexto de inicio de sesión local en Orca. La autenticación de la cuenta permanece en este dispositivo.", - "cedfab35ab": "Opcional. Orca puede utilizar su inicio de sesión normal del Codex; agregue cuentas solo si desea un cambio rápido en Orca.", + "d0d53b7eb0": "Administra qué cuenta de Codex usa Orca para obtener rate limits en vivo.", + "3180536c7a": "Cuentas de Codex", + "340d6f7a85": "Cada cuenta mantiene su propio contexto de login local en Orca. La autenticación de la cuenta permanece en este dispositivo.", + "cedfab35ab": "Opcional. Orca puede usar tu login normal de Codex; agrega cuentas solo si quieres cambiar rápido en Orca.", "ef91cfa06b": "Codex", - "3fe7862418": "No hay cuentas administradas de Claude para {{value0}}. Orca utilizará el inicio de sesión de Claude predeterminado del sistema de ese entorno hasta que agregue uno aquí.", - "3455cf43fa": "Claude iniciar sesión.", - "fcc4093fc1": "Utilice su inicio de sesión actual del Codex {{value0}}.", - "79e484c3b2": "Conmutador de cuenta opcional para los archivos de autenticación compartidos de Claude.", - "8bbfd74556": "Cuentas Claude", - "72b36ea174": "Opcional. Orca puede utilizar su inicio de sesión normal de Claude; agregue cuentas solo si desea cambiar rápidamente sin mover sesiones de chat.", + "3fe7862418": "No hay cuentas de Claude gestionadas para {{value0}}. Orca usará el login predeterminado de Claude del sistema de ese entorno hasta que agregues una aquí.", + "3455cf43fa": "Login de Claude.", + "fcc4093fc1": "Usa tu login actual de Codex de {{value0}}.", + "79e484c3b2": "Switcher de cuenta opcional para los archivos de autenticación compartidos de Claude.", + "8bbfd74556": "Cuentas de Claude", + "72b36ea174": "Opcional. Orca puede usar tu login normal de Claude; agrega cuentas solo si quieres cambiar rápido sin mover sesiones de chat.", "26ef4b55be": "Claude", "2743cdc0af": "Error al actualizar la cuenta de Claude.", - "b15ce90870": "{{value0}} -> {{value1}}. Reinicie los terminales Claude en vivo antes de continuar con sesiones anteriores.", + "b15ce90870": "{{value0}} -> {{value1}}. Reinicia los terminales Claude activos antes de continuar sesiones antiguas.", "f921d32606": "Cuenta Claude actualizada.", "5bf8764953": "Error al actualizar la cuenta del Codex.", - "9baf45d071": "este dispositivo", + "9baf45d071": "Este dispositivo", "2358ac71d2": "WSL predeterminado", "ad47a33f72": "Cargando WSL", "8619f9afa9": "WSL", "46cf7e7495": "Ubicación de la cuenta", - "0b4591ff93": "Elija qué entorno local inspeccionar y dónde se agregan las nuevas cuentas administradas de Claude y Codex.", + "0b4591ff93": "Elige qué entorno local inspeccionar y dónde se agregan nuevas cuentas gestionadas de Claude y Codex.", "0c67a2a1aa": "WSL no está disponible en esta máquina.", - "2cd197025c": "Elija si las cuentas de proveedor se inspeccionan y agregan en {{value0}} o WSL.", + "2cd197025c": "Elige si las cuentas de proveedor se inspeccionan y agregan en {{value0}} o WSL.", "f54b4fbd71": "Ubicación de la cuenta", "9107406589": "No se pudieron cargar las cuentas de Claude.", "b8c2905c2b": "No se pudieron cargar las cuentas del Codex.", - "fd62f37c24": "Codex informó que este inicio de sesión {{value0}} no está actualizado.", - "b10cb4f696": "añadiendo", - "e4a28e8894": "Codex informó que el inicio de sesión {{value0}} necesita un nuevo inicio de sesión. Inicie sesión nuevamente antes de iniciar nuevas sesiones del Codex.", - "75ca9b718e": "Codex informó que la cuenta activa necesita un nuevo inicio de sesión. Vuelva a autenticarlo antes de iniciar nuevas sesiones del Codex.", + "fd62f37c24": "Codex informó que este login de {{value0}} está desactualizado.", + "b10cb4f696": "agregando", + "e4a28e8894": "Codex informó que el login de {{value0}} requiere un inicio de sesión nuevo. Inicia sesión de nuevo antes de iniciar nuevas sesiones de Codex.", + "75ca9b718e": "Codex informó que la cuenta activa requiere un inicio de sesión nuevo. Reautentícala antes de iniciar nuevas sesiones de Codex.", "b11078a9c2": "wsl", "350b2a1aa7": "Usa tu actual", - "e05d0ff737": "Utilice su inicio de sesión actual de {{value0}} Claude." + "e05d0ff737": "Usa tu login actual de Claude de {{value0}}.", + "2f24f244a4": "La cookie de MiniMax es obligatoria.", + "8e6f0cb1d8": "La cookie de MiniMax no se guardó.", + "8d61637a77": "Cookie de MiniMax guardada.", + "b43e761fe5": "No se pudo actualizar la cookie de MiniMax.", + "5d63bbfbec": "MiniMax", + "15e831350e": "Configura el seguimiento de uso de MiniMax desde platform.minimax.io.", + "21d6eb141e": "Cookie de sesión de MiniMax", + "33bba5ad83": "Pega tu cookie de sesión de MiniMax para obtener límites de uso locales.", + "73ea15f24b": "Guardado", + "23afe8f226": "No guardado", + "566d9a99ab": "_token=…; minimax_group_id_v2=…", + "f38b9cc4bd": "Reemplazar", + "590a3130f9": "Guardar", + "79418c782a": "Abre platform.minimax.io/console/usage en tu navegador, inicia sesión y copia el encabezado Cookie de DevTools (Red → cualquier solicitud a remains → Cookie).", + "9dd50d3f75": "Avanzado", + "174fb408f9": "Deja estos valores predeterminados a menos que la actualización de uso de MiniMax apunte al workspace o modelo equivocado.", + "bf160bb6c0": "Anulación de Group ID", + "b1e2743313": "Opcional. Déjalo en blanco para usar minimax_group_id_v2 de la cookie.", + "0747d6391a": "Usar el Group ID de la cookie", + "4ff2af7524": "Nombres de modelos de uso", + "5cf4b0f85f": "Nombres de modelos opcionales separados por comas. Déjalo en general a menos que MiniMax devuelva un error específico del modelo.", + "3c92b0d31c": "general", + "0d8e77bc40": "Abrir consola", + "0b8c1c7e02": "Almacenado localmente", + "1fd1b1b6b4": "Cookie no configurada", + "5e08b0fe57": "Se almacena localmente y se envía solo a platform.minimax.io para actualizar el uso.", + "43d7a45b97": "Cómo copiar", + "b8a4f21c3e": "Pega el encabezado Cookie desde DevTools", + "53f7b8c7a2": "Última actualización: {{value0}}", + "31d24a4e87": "La cookie caduca cuando cierras sesión en el navegador.", + "3a30aaf526": "ahora mismo", + "f5d8d2a6a1": "Abre platform.minimax.io/console/usage en tu navegador e inicia sesión.", + "24560fe830": "Abre DevTools.", + "4cab0fa42d": "Ve a la pestaña Red y activa Preserve log.", + "bee4e63e1c": "Recarga la página.", + "87f814af6f": "Filtra por remains y selecciona la solicitud coding_plan/remains.", + "435df0ee51": "En Request Headers, copia el valor de Cookie.", + "7492fb3bba": "Pégalo aquí y haz clic en Guardar.", + "9fec52de4b": "Cómo copiar la cookie", + "4e32e030b2": "Se almacena localmente. Orca solo la envía a platform.minimax.io para actualizar el uso." }, "AdvancedPane": { - "40b29e0bf3": "Reanudar", + "40b29e0bf3": "Reiniciar", "87a2cb2ac8": "Orca aplica este modo de red al inicio.", - "89958d7edf": "Reiniciar requerido", - "b3ad629640": "Úselo solo cuando una VPN o proxy corporativo interrumpa las descargas de actualizaciones con errores de protocolo HTTP/2. Afecta a todas las redes de Electron después del reinicio.", + "89958d7edf": "Reinicio requerido", + "b3ad629640": "Úsalo solo cuando una VPN o proxy corporativo rompa las descargas de actualizaciones con errores de protocolo HTTP/2. Afecta toda la red de Electron después del reinicio.", "6627e75c92": "Explicar la compatibilidad HTTP/1.1", "e9506d3377": "Compatibilidad HTTP/1.1", - "8b7a8df299": "Soluciones alternativas de bajo nivel para la resolución de problemas de soporte.", + "8b7a8df299": "Workarounds de bajo nivel para troubleshooting de soporte.", "8d8d8ac599": "Compatibilidad", "network": "Red", "networkDescription": "Enrutamiento de red a nivel de aplicación para proxies y entornos corporativos." @@ -4645,19 +4696,19 @@ "92f4238f1a": "WSL predeterminado", "fc806485ae": "Cargando WSL", "43663b5e69": "WSL", - "9bccf48906": "Ubicación del Agent", - "d00949e59b": "Mostrar agents instalados desde {{value0}}. Actualizar vuelve a verificar la RUTA en ese entorno.", + "9bccf48906": "Ubicación de Agents", + "d00949e59b": "Muestra Agents instalados desde {{value0}}. Actualizar vuelve a revisar PATH en ese entorno.", "c7c516946f": "WSL no está disponible en esta máquina.", "f97b986b7f": "wsl" }, "AgentSkillSetupPanel": { "0b810ec59f": "Presiona Enter para ejecutar el comando.", - "ed197f59a2": "comando copiar", + "ed197f59a2": "Copiar comando", "817d3f9f18": "Copiar comando", "5289300939": "No instalado", "9fcebceb2a": "Instalado", - "68a468752e": "De cheques...", - "c689392435": "Vuelva a comprobar", + "68a468752e": "Comprobando...", + "c689392435": "Volver a comprobar", "a31e2aa302": "No se pudo copiar el comando.", "378ad26865": "Comando copiado.", "copiedCommand": "Comando copiado.", @@ -4669,42 +4720,42 @@ }, "AgentsPane": { "d83834f5e6": "Detectando agents instalados…", - "024bd95089": "agentes", + "024bd95089": "agents", "e8da2af684": "Disponible para instalar", "ed3e110e61": "detectado", "02e0143be5": "Instalado", "110b74b022": "Sin agente (terminal en blanco)", "92033495ff": "Auto", - "9b175d0f5e": "Agente preseleccionado al abrir un nuevo espacio de trabajo.", - "385212c7a1": "Agente predeterminado", - "f9f127d664": "Override the binary path or name, and edit the default launch arguments or environment for this agent.", + "9b175d0f5e": "Agent preseleccionado al abrir un nuevo espacio de trabajo.", + "385212c7a1": "Agent predeterminado", + "f9f127d664": "Override de la ruta o nombre del binario, y edición de argumentos de lanzamiento o entorno predeterminados para este agent.", "f95b5c79b8": "Instalar", - "fe4d630c94": "Documentos", + "fe4d630c94": "Documentación", "8dc0192e48": "Desactivado", "df123171d1": "No instalado", "c8794e622e": "Detectado", "5200dac9da": "Reiniciar", - "2e45ca29b6": "Dominio", + "2e45ca29b6": "Comando", "d4d2a45d63": "Activado", "1c9a9679ec": "{{value0}} disponibilidad", "0d9e293a02": "Refrescar", - "c9b33eb5c0": "Refrescante…", - "13647f9f80": "Vuelva a leer la RUTA de su shell y vuelva a detectar los agents instalados", - "dc4a2ffdc0": "Ampliar anulación del comando", - "cea7d97be1": "Anulación del comando Contraer", + "c9b33eb5c0": "Actualizando…", + "13647f9f80": "Vuelve a leer PATH de tu shell y vuelve a detectar los Agents instalados", + "dc4a2ffdc0": "Expandir override del comando", + "cea7d97be1": "Contraer override del comando", "db9e9e5887": "Personalizar comando", "959b67385b": "Establecer predeterminado", - "24e032fa34": "Por defecto", + "24e032fa34": "Predeterminado", "5f986a9b92": "Establecer como predeterminado", - "d7625cf8b2": "Agente predeterminado", + "d7625cf8b2": "Agent predeterminado", "cfb3f35775": "Argumentos", - "6f99bf5dd0": "No default arguments", + "6f99bf5dd0": "Sin argumentos predeterminados", "8fbe1f37c1": "Entorno", - "2d133152fa": "No default environment", - "agentPermissions": "Agent Permissions", - "agentPermissionsInfo": "Agent permissions info", - "agentPermissionsTooltip": "Custom agent arguments stay unchanged when switching modes.", - "agentPermissionsDescription": "Choose whether Orca launches agents with fewer permission prompts or with manual checks.", + "2d133152fa": "Sin entorno predeterminado", + "agentPermissions": "Permisos de Agents", + "agentPermissionsInfo": "Información de permisos de Agents", + "agentPermissionsTooltip": "No se aplica a agents con argumentos de lanzamiento sobrescritos.", + "agentPermissionsDescription": "Elige si Orca lanza agents con menos prompts de permisos o con checks manuales.", "agentPermissionsYolo": "Yolo", "agentPermissionsManual": "Manual", "3f1bdf3cb4": "El texto de entorno es demasiado grande para analizarlo de forma segura." @@ -4716,32 +4767,32 @@ }, "AppearancePane": { "3057983501": "No asignado", - "0cd9b8228f": "Elija el ícono de la aplicación que se muestra en el Dock y en el selector de ventanas.", - "ca1590d42f": "Icono de aplicación", + "0cd9b8228f": "Elige el icono de la app que se muestra en el Dock y en el selector de ventanas.", + "ca1590d42f": "Icono de la app", "61d842eca0": "Muestra el acceso directo de Orca Mobile en la barra lateral. Sigue estando disponible en Toolbox.", - "9da1020447": "Mostrar botón móvil de Orca", + "9da1020447": "Mostrar botón de Orca Mobile", "5db6ba961f": "Muestra el botón de Orca Mobile en la parte superior de la barra lateral izquierda.", - "fa882a3e6b": "Muestra el botón Automatizaciones en la parte superior de la barra lateral izquierda.", - "511f270ebb": "Botón Mostrar automatizaciones", - "661942ab7f": "Muestra el botón Tareas en la parte superior de la barra lateral izquierda.", - "cf81907069": "Botón Mostrar tareas", + "fa882a3e6b": "Muestra el botón de Automatizaciones en la parte superior de la barra lateral izquierda.", + "511f270ebb": "Mostrar botón de Automatizaciones", + "661942ab7f": "Muestra el botón de Tareas en la parte superior de la barra lateral izquierda.", + "cf81907069": "Mostrar botón de Tareas", "dc29f3cc0d": "Barra lateral", - "ea943d0db0": "Elija qué indicadores aparecen en la parte inferior de la ventana. También puede hacer clic derecho en la barra de estado para los mismos cambios.", + "ea943d0db0": "Elige qué indicadores aparecen en la parte inferior de la ventana. También puedes hacer clic derecho en la barra de estado para cambiar lo mismo.", "3e4175e5c6": "Barra de estado", "2df8f79aa5": "Muestra Orca en la barra de título.", "9868f39007": "Nombre de la aplicación de la barra de título", "4de76f6902": "Controla lo que aparece en la barra de título de la aplicación.", "6a272ca553": "Barra de título", - "e9f2ca5582": "Desactívelo para ocultar archivos que coincidan con .gitignore desde el explorador de archivos.", + "e9f2ca5582": "Desactívalo para ocultar en el explorador de archivos los archivos que coincidan con .gitignore.", "0fafabcf35": "Mostrar archivos ignorados por Git", - "75f07ab60c": "Mostrar archivos que coincidan con .gitignore en el explorador de archivos.", + "75f07ab60c": "Muestra en el explorador de archivos los archivos que coincidan con .gitignore.", "d496901cd0": "Explorador de archivos", - "42554f615f": "Elija la fuente utilizada por la interfaz de Orca.", + "42554f615f": "Elige la fuente usada por la interfaz de Orca.", "102d6b5f9b": "Fuente IDE", "ef89200c1f": "cuando no está en un panel de terminal.", - "f687711a9b": "Escale toda la interfaz de la aplicación. Usar", - "5e6d7aba8d": "Ampliación de la interfaz de usuario", - "622e1c3465": "Escale toda la interfaz de la aplicación.", + "f687711a9b": "Escala toda la interfaz de la app. Usa", + "5e6d7aba8d": "Zoom de la UI", + "622e1c3465": "Escala toda la interfaz de la app.", "fd89b5487c": "Luz", "7d26ccabe8": "Oscuro", "fb0e0b4453": "Sistema", @@ -4749,9 +4800,9 @@ "0f28e7b30c": "Elige cómo se ve Orca en la ventana de la aplicación.", "leftSidebarAppearance": { "title": "Apariencia de la barra lateral izquierda", - "rowDescription": "Haz que la barra lateral izquierda coincida con tu terminal, conserve el valor predeterminado o use un tinte.", + "rowDescription": "Haz que la barra lateral izquierda coincida con tu terminal, mantén el valor predeterminado o usa un tinte.", "default": "Predeterminado", - "matchTerminal": "Coincidir con terminal", + "matchTerminal": "Coincidir con el terminal", "tinted": "Con tinte", "tintColor": "Tinte de la barra lateral", "tintColorDescription": "El color que se mezcla en la superficie de la barra lateral izquierda.", @@ -4768,75 +4819,75 @@ "terminalTitle": "Terminal", "windowSidebarTitle": "Ventana y barra lateral", "windowSidebarSummary": "Barra lateral, barra de estado y explorador de archivos", - "statusBarCount": "Indicadores visibles: {{value0}}.", - "gitIgnoredGlossary": "Archivos coincidentes con .gitignore.", - "statusBarDescription": "Choose which indicators appear in the status bar." + "statusBarCount": "{{value0}} indicadores visibles.", + "gitIgnoredGlossary": "Archivos que coinciden con .gitignore.", + "statusBarDescription": "Elige qué indicadores aparecen en la barra de estado." }, "AutoRenameBranchFromWorkSetting": { "1626524572": "Nautilus", - "0de9fda203": "Desechar", + "0de9fda203": "Descartar", "c71770c455": "{basePrompt}", - "f19a56498d": "; la configuración del prefijo de rama aún se aplica.", - "800edb1e54": "arreglar-flujo-de-inicio de sesión", - "5d569f5199": ". Orca genera sólo el segmento final, como", + "f19a56498d": "; se seguirá aplicando tu prefijo de rama.", + "800edb1e54": "fix-login-flow", + "5d569f5199": ". Orca genera solo el segmento final, como", "570817d126": "y", - "56580dcf60": ". También puedes hacer referencia", - "9c9b54e4ea": "mensaje de nombre de rama incorporado", - "69bf4830c2": "para incluir a Orca", - "9241b59bf5": "Usar", - "a869d0edd8": "Plantilla de comando de nombre de sucursal", + "56580dcf60": ". También puedes referenciar", + "9c9b54e4ea": "prompt integrado para nombres de rama", + "69bf4830c2": "para incluir el", + "9241b59bf5": "Usa", + "a869d0edd8": "Plantilla de comando para nombre de rama", "e784ea62dc": "Avanzado", - "d9b65054ef": ") a un nombre corto que resume la tarea. Sólo se cambia el nombre de las ramas que Orca nombró a sí misma, y ​​nunca después de haber sido empujadas.", - "12ea4a408d": "Cuando un agente comienza a trabajar en un nuevo espacio de trabajo, Orca cambia el nombre de su rama generada automáticamente (p. ej.", - "ef787db0e3": "Rama de cambio de nombre automático", - "6a051586d2": "Cambie el nombre de la rama generada automáticamente según el trabajo una vez que comience un agente.", - "ec3e0c388e": "Ahorrar", - "cfd82406dd": "Ahorro...", + "d9b65054ef": ") a un nombre corto que resuma la tarea. Solo se renombran las ramas que Orca nombró por sí misma, y nunca después de que se hayan pusheado.", + "12ea4a408d": "Cuando un agente empieza a trabajar en un nuevo espacio de trabajo, Orca renombra su rama generada automáticamente (p. ej.", + "ef787db0e3": "Renombrar rama y worktree automáticamente", + "6a051586d2": "Renombra la rama generada automáticamente según el trabajo una vez que un agente empieza.", + "ec3e0c388e": "Guardar", + "cfd82406dd": "Guardando...", "40e7be7850": "Guardado", - "7c7e34a66d": "Cambios no guardados", - "a4fa380b67": "{mensaje del asistente}", - "2ee2779c05": "{primer mensaje}" + "7c7e34a66d": "Cambios sin guardar", + "a4fa380b67": "{assistantMessage}", + "2ee2779c05": "{firstPrompt}" }, "AutoRenameBranchPromptEditor": { - "63121132c0": "Desechar", - "4416b25d29": "Prefiera nombres de dominio de la tarea, evite los ID de ticket y mantenga los nombres fáciles de usar para los revisores.", - "39278f4411": "; la configuración del prefijo de rama aún se aplica.", - "ebb942a2ec": "arreglar-flujo-de-inicio de sesión", - "af2d9a2cc6": ". Orca genera sólo el segmento final, como", - "182d419b97": "mensaje de nombre de rama incorporado", - "2f5dc661fe": "Adjunto a Orca", - "7d6176f506": "Inmediato", - "5968112152": "Ahorrar", - "54ac229ad4": "Ahorro...", + "63121132c0": "Descartar", + "4416b25d29": "Prefiere sustantivos del dominio de la tarea, evita IDs de issues y mantén nombres fáciles de revisar.", + "39278f4411": "; se seguirá aplicando tu prefijo de rama.", + "ebb942a2ec": "fix-login-flow", + "af2d9a2cc6": ". Orca genera solo el segmento final, como", + "182d419b97": "prompt integrado para nombres de rama", + "2f5dc661fe": "Se agrega al", + "7d6176f506": "Prompt", + "5968112152": "Guardar", + "54ac229ad4": "Guardando...", "af0831a590": "Guardado", - "0691753cf2": "Cambios no guardados" + "0691753cf2": "Cambios sin guardar" }, "BaseRefPicker": { - "1b8e54151f": "No se encontraron sucursales coincidentes.", + "1b8e54151f": "No se encontraron ramas coincidentes.", "d166ff883d": "Actual", - "a4a9372eb2": "Buscando sucursales...", - "7db7fb87e5": "Buscar sucursales por nombre...", - "773a5687a3": "Usar primario", - "ade9a5bb03": ") para determinar el alcance de los resultados.", - "b468f46726": "aguas arriba/principal", - "80f7c82303": ") o una referencia completa (p. ej.", - "915ad97875": "río arriba", - "a5c16712c1": "Se detectaron varios controles remotos. Escriba un nombre remoto (p. ej.", - "9a14ec7400": "Elija una rama base a continuación", - "086ce7f369": "Siguiente rama principal ({{value0}})", - "2f3cda96f5": "Fijado para este repo", - "ee110e1830": "Sin referencia base predeterminada" + "a4a9372eb2": "Buscando ramas...", + "7db7fb87e5": "Buscar ramas por nombre...", + "773a5687a3": "Usar rama principal", + "ade9a5bb03": ") para acotar los resultados.", + "b468f46726": "upstream/main", + "80f7c82303": ") o una ref completa (p. ej.", + "915ad97875": "upstream", + "a5c16712c1": "Se detectaron varios remotes. Escribe el nombre de un remote (p. ej.", + "9a14ec7400": "Elige una rama base abajo", + "086ce7f369": "Siguiendo la rama principal ({{value0}})", + "2f3cda96f5": "Fijada para este repositorio", + "ee110e1830": "Sin ref base predeterminada" }, "BrowserDefaultZoomSetting": { - "2622126877": "Nivel de zoom aplicado a las pestañas del navegador recién abiertas.", - "bbeec087d3": "Se aplica a las pestañas del navegador recién abiertas.", + "2622126877": "Nivel de zoom aplicado a nuevas pestañas del navegador.", + "bbeec087d3": "Se aplica a nuevas pestañas del navegador.", "265597101f": "Zoom predeterminado" }, "BrowserHomePageSetting": { - "d4ddcd0056": "Ahorrar", + "d4ddcd0056": "Guardar", "37a30c5bfd": "https://google.com", "c6cbd1c105": "Página de inicio guardada.", - "6a37540f4b": "URL abierta al crear una nueva pestaña del navegador. Déjelo vacío para abrir una pestaña en blanco.", + "6a37540f4b": "URL que se abre al crear una nueva pestaña del navegador. Déjalo vacío para abrir una pestaña en blanco.", "70224e37b1": "Página de inicio predeterminada" }, "BrowserPane": { @@ -4847,331 +4898,335 @@ "8481ee0331": "Nuevo perfil del navegador", "6f2584b39e": "Agregar perfil", "e4aaf8051b": "menú de la barra de herramientas.", - "cd47bc9622": "Seleccione un perfil predeterminado para las nuevas pestañas del navegador. Importe cookies y cambie perfiles por pestaña a través del", + "cd47bc9622": "Selecciona un perfil predeterminado para nuevas pestañas del navegador. Importa cookies y cambia perfiles por pestaña desde el", "2d66a6efb5": "Sesión y cookies", - "aa1074bfe9": "Administre perfiles de navegador e importe cookies desde Chrome, Edge, Comet u otros navegadores.", + "aa1074bfe9": "Administra perfiles del navegador e importa cookies desde Chrome, Edge, Comet u otros navegadores.", "113cd2dc9b": "Sesión y cookies", "d3eb69c0aa": "Enrutamiento de enlaces", - "3e46903ad4": "Se utiliza al escribir texto que no es una URL en la barra de direcciones.", + "3e46903ad4": "Se usa al escribir texto que no es una URL en la barra de direcciones.", "0d9c987f21": "Motor de búsqueda predeterminado", - "7b225c78f5": "Motor de búsqueda utilizado al escribir texto que no es una URL en la barra de direcciones.", + "7b225c78f5": "Motor de búsqueda usado al escribir texto que no es una URL en la barra de direcciones.", "64898ecdab": "Crear", "7b649a578a": "Creando…", - "4399c77caa": "Por defecto", + "4399c77caa": "Predeterminado", "4af9a17947": "kagi", - "c0f85056d9": "Browser profiles on this Orca server.", - "86b7c83fee": "This computer", - "6480776a03": "Browser profiles for the selected host.", + "c0f85056d9": "Perfiles del navegador en este servidor de Orca.", + "86b7c83fee": "Este equipo", + "6480776a03": "Perfiles del navegador para el host seleccionado.", "5e19a692f7": "Host" }, "BrowserProfileRow": { "8e636cae25": "Perfil \"{{value0}}\" eliminado.", "2d4bea7f35": "Se borraron las cookies predeterminadas.", "ebb78dfd6f": "Desde archivo…", - "7df818977e": "De", + "7df818977e": "Desde ", "cdec84552f": "Importar cookies", - "796d846483": "No se importan cookies", + "796d846483": "No se han importado cookies", "c29648fe5b": "Activo", "d420c43729": "Se importaron {{value0}} cookies de {{value1}}{{value2}} a {{value3}}.", - "b4c167764d": "Se importaron {{value0}} cookies del archivo a {{value1}}.", + "b4c167764d": "Se importaron {{value0}} cookies desde un archivo a {{value1}}.", "a3f8c2d1e0b4": "Se importaron {{value0}} cookies de {{value1}} ({{value2}}) a {{value3}}.", - "b4e9d3f2a1c5": "Se importaron {{value0}} cookies de {{value1}} a {{value2}}." + "b4e9d3f2a1c5": "Se importaron {{value0}} cookies de {{value1}} a {{value2}}.", + "c5a273a809": "Desde {{value0}}" }, "BrowserUseComputerUseNotice": { - "15b5e680ba": "Uso abierto de la computadora", - "79209b37b9": "Si la importación de cookies no es la opción adecuada, Uso de la Computadora puede controlar las aplicaciones locales y puede utilizar sesiones de navegador existentes cuando corresponda. Instale la habilidad Uso de computadora; macOS también requiere permisos de privacidad.", - "333984cf90": "Utilice una sesión de navegador existente" + "15b5e680ba": "Abrir Computer Use", + "79209b37b9": "Si importar cookies no encaja, Computer Use puede controlar apps locales y usar sesiones de navegador existentes donde aplique. Instala el skill Computer Use; macOS también requiere permisos de privacidad.", + "333984cf90": "Usar una sesión de navegador existente" }, "BrowserUseEnableSwitch": { - "aea3f45349": "Habilitar el uso del navegador del Agent" + "aea3f45349": "Activar Agent Browser Use" }, "BrowserUseExamples": { "1199258ace": "Copiar", - "1188e56af4": "Copiar mensaje de ejemplo", + "1188e56af4": "Copiar prompt de ejemplo", "b84807f228": "\"", "59722f31b4": "\"", - "c5325e91f6": "Pegue cualquiera de estos en Claude Code, Codex u otro agente en un proyecto donde esté instalada la habilidad.", - "2a180694f7": "Pruébelo: indicaciones de ejemplo", + "c5325e91f6": "Pega cualquiera de estos en Claude Code, Codex u otro agente en un proyecto donde el skill esté instalado.", + "2a180694f7": "Pruébalo: prompts de ejemplo", "5ec620ccc4": "No se pudo copiar.", "a602d43069": "Copiado {{value0}}." }, "BrowserUsePane": { "be6df68384": "Desde archivo…", - "e44c5d681e": "De", - "67d9a53f47": "Administrar perfiles para inicios de sesión separados", - "112f70adc4": "Última importación desde", - "72d4815523": "Incorpore sus inicios de sesión existentes a Orca para que los agents puedan acceder a páginas autenticadas. Importa al perfil predeterminado.", + "e44c5d681e": "Desde ", + "67d9a53f47": "Gestionar perfiles para logins separados", + "112f70adc4": "Última importación desde {{value0}}", + "72d4815523": "Trae tus logins existentes a Orca para que los agents puedan acceder a páginas autenticadas. Importa al perfil predeterminado.", "2eb906706c": "Importar cookies del navegador", - "af8c83ed61": "Importe cookies de Chrome, Edge u otros navegadores para que los agents puedan reutilizar sus inicios de sesión.", - "68ea76eb71": "Instale la habilidad Uso del navegador para que los agents puedan operar el navegador de Orca.", - "2d6ead9ab2": "Instalar la habilidad de uso del navegador", + "af8c83ed61": "Importa cookies de Chrome, Edge u otros navegadores para que los agents puedan reutilizar tus logins.", + "68ea76eb71": "Instala el skill Browser Use para que los agents puedan operar el navegador de Orca.", + "2d6ead9ab2": "Instalar skill Browser Use", "e9f3f3b488": "Instalado en", - "9fca1f7f5d": "Registra el comando CLI de Orca para que los agents puedan organizar el navegador desde su shell.", - "c6065d205d": "Habilitar CLI de Orca", - "c79eff0213": "Registre la CLI de Orca para que los agents puedan controlar el navegador.", - "702488a5f7": "Deje que los agents de codificación controlen este navegador con sus inicios de sesión. Complete los tres pasos siguientes.", - "b8a1f2d84d": "Uso del navegador del Agent", - "96b91c6349": "Deje que los agents de codificación controlen este navegador con sus inicios de sesión.", + "9fca1f7f5d": "Registra el comando Orca CLI para que los agents puedan orquestar el navegador desde su shell.", + "c6065d205d": "Activar Orca CLI", + "c79eff0213": "Registra Orca CLI para que los agents puedan controlar el navegador.", + "702488a5f7": "Deja que los coding agents controlen este navegador con tus logins. Completa los tres pasos de abajo.", + "b8a1f2d84d": "Agent Browser Use", + "96b91c6349": "Deja que los coding agents controlen este navegador con tus logins.", "2ea4617e3a": "Se importaron {{value0}} cookies de {{value1}}{{value2}}.", - "721aee31b4": "Registré Orca CLI en PATH.", + "721aee31b4": "Se registró Orca CLI en PATH.", "180a9abf3a": "No se pudo cargar el estado de CLI.", "2ccfc9cff8": "Importar", "0462565413": "Reimportar", - "de9b2f32f3": "Permitir", - "ad8cb0ee22": "Arreglar RUTA", + "de9b2f32f3": "Activar", + "ad8cb0ee22": "Corregir PATH", "0289434ed6": "Activado", "8b3054dac7": "Registrando...", - "8f2675c2f3": "Se importaron {{value0}} cookies del archivo." + "8f2675c2f3": "Se importaron {{value0}} cookies desde un archivo.", + "5301857d88": "Desde {{value0}}" }, "BrowserUseSkillStep": { "0871b6998d": "Permite a los agents navegar y verificar páginas en el navegador de Orca.", - "459e24eebc": "Habilidad de uso del navegador" + "459e24eebc": "Skill Browser Use" }, "CliSection": { "8671e406f0": "Cancelar", "a4aafe46e3": "Ruta de destino:", - "e8012c03a1": "Permite a los agents utilizar el espacio de trabajo, la terminal y los comandos de progreso de Orca.", - "cliSkillTerminalTitle": "Configuración de habilidad CLI", - "cliSkillTerminalAria": "Terminal de instalación de la habilidad CLI", - "6053cf736c": "Habilidad CLI", - "36a6f919ba": "Ofrezca a los agents espacios de trabajo, terminales y flujos de trabajo de progreso compatibles con Orca.", - "04873eea3e": "Habilidades del Agent", - "7f2747f7dd": "Actualmente no está visible en PATH para este shell.", - "b0c310ab46": "Objetivo del lanzador existente:", - "15eaad0d31": "Ruta de comando:", - "5dae812f50": "Refrescar", - "52e640f3a0": "Actualizar el estado de la CLI", + "e8012c03a1": "Permite a los agents usar comandos de workspace, terminal y progreso de Orca.", + "cliSkillTerminalTitle": "Configuración del skill CLI", + "cliSkillTerminalAria": "Terminal de instalación del skill CLI", + "6053cf736c": "Skill CLI", + "36a6f919ba": "Da a los agents workflows de workspace, terminal y progreso integrados con Orca.", + "04873eea3e": "Agent skills", + "7f2747f7dd": "no está visible en PATH para este shell.", + "b0c310ab46": "Destino del launcher existente:", + "15eaad0d31": "Ruta del comando:", + "5dae812f50": "Actualizar", + "52e640f3a0": "Actualizar estado de la CLI", "38edbb5721": "Comando de shell", - "6930feda9e": "Utilice Orca desde su terminal para abrir la aplicación, administrar árboles de trabajo e interactuar con los terminales Orca.", - "c5c0f2641d": "CLI de Orca", + "6930feda9e": "Usa Orca desde tu terminal para abrir la app, administrar worktrees e interactuar con terminales de Orca.", + "c5c0f2641d": "Orca CLI", "d77352f2df": "No se pudo eliminar `{{value0}}` de PATH.", - "af5540930c": "Se eliminó `{{value0}}` de RUTA.", + "af5540930c": "Se eliminó `{{value0}}` de PATH.", "a2b13efa94": "No se pudo registrar `{{value0}}` en PATH.", - "9cbcd31338": "Registrado `{{value0}}` en PATH.", + "9cbcd31338": "Se registró `{{value0}}` en PATH.", "7baec27029": "No se pudo cargar el estado de CLI.", - "d00df2e397": "Registro", + "d00df2e397": "Registrar", "9a5f8a4568": "Eliminar", - "b0fca411a0": "Registrándose…", + "b0fca411a0": "Registrando…", "4c7e3e4c5f": "instalar", "068552b191": "Eliminando…", "8d96213669": "eliminar", - "aa6536977e": "Orca registrará {{value0}} para que el comando funcione desde su terminal.", - "a030816e3e": "Esto elimina el enlace simbólico del comando Shell. La propia Orca permanece instalada.", + "aa6536977e": "Orca registrará {{value0}} para que el comando funcione desde tu terminal.", + "a030816e3e": "Esto elimina el symlink del comando de shell. Orca permanece instalada.", "fa87db3d6e": "¿Registrar `{{value0}}` en PATH?", - "14444243ba": "¿Eliminar `{{value0}}` de RUTA?", + "14444243ba": "¿Eliminar `{{value0}}` de PATH?", "5d432fe44d": "instalado", - "8a9b784c60": "duro", - "d363e5929b": "Comprobando el registro CLI..." + "8a9b784c60": "desactualizado", + "d363e5929b": "Comprobando registro de CLI..." }, "CliSkillRuntimeSetup": { "04325573f8": "WSL", - "a58ba464ad": "Ubicación de la habilidad", - "0ed08febc5": "No se pudo registrar el comando de shell WSL.", - "3728a94fb6": "El comando de shell WSL necesita atención", - "775a4cfbb8": "El registro del comando de shell WSL no está disponible", + "a58ba464ad": "Ubicación del skill", + "0ed08febc5": "No se pudo registrar el comando de shell de WSL.", + "3728a94fb6": "El comando de shell de WSL necesita atención", + "775a4cfbb8": "El registro del comando de shell de WSL no está disponible", "c47127f222": "WSL predeterminado", - "0c9f3cf9da": "Elija dónde Orca verifica e instala las habilidades de los agentes globales.", + "0c9f3cf9da": "Elige dónde Orca comprueba e instala agent skills globales.", "f00d6aa9b5": "WSL no está disponible en esta máquina.", "7c776ff9d8": "wsl", - "fc0fcf72fd": "Registre el comando de shell WSL antes de configurar la habilidad." + "fc0fcf72fd": "Registra el comando de shell de WSL antes de configurar el skill." }, "CommitMessageAiPane": { - "841ed9884a": "Utilizado por repositorios que no han personalizado Source Control AI.", - "ad66ff886d": "Valores predeterminados de IA de control de fuente", - "347094560b": "Utilizado por repositorios que heredan los valores predeterminados globales de revisión alojada.", - "2dafc7646e": "Valores predeterminados de creación de reseñas alojadas", - "e9d46a544d": "Valores predeterminados utilizados cuando se abre el redactor de reseñas alojadas.", - "b125eabffa": "Abra la reseña alojada creada en su navegador después de enviarla.", - "7662715213": "Abrir revisión alojada después de la creación", - "b27b0809f3": "Ejecute la generación de detalles de revisión alojada una vez cuando se abra el compositor.", + "841ed9884a": "Usado por repositorios que no han personalizado Source Control AI.", + "ad66ff886d": "Valores predeterminados de Source Control AI", + "347094560b": "Usado por repositorios que heredan los valores predeterminados globales de revisión alojada.", + "2dafc7646e": "Valores predeterminados para crear revisiones alojadas", + "e9d46a544d": "Valores predeterminados usados cuando se abre el composer de revisión alojada.", + "b125eabffa": "Abre la revisión alojada creada en tu navegador después de enviarla.", + "7662715213": "Abrir revisión alojada después de crearla", + "b27b0809f3": "Ejecuta una vez la generación de detalles de la revisión alojada cuando se abra el composer.", "d5f0de6309": "Generar detalles al abrir Crear PR", - "6278c0ce43": "Prefiere las plantillas de solicitud de extracción del repositorio cuando no se establece ninguna descripción.", - "d8b6764d79": "Utilice la plantilla de revisión cuando esté disponible", - "e001734396": "Cree reseñas alojadas como borradores a menos que se modifiquen en el compositor.", - "6ba48f07a4": "Borrador por defecto", - "15b60d54b2": "p.ej. ollama ejecuta llama3.1 {rápido}", - "3f1b26cc91": "pasar la entrada del comando como argumento; de lo contrario, Orca lo canaliza en stdin.", - "4f722a5f53": "Usado por recetas de mensaje de commit, solicitud de extracción y nombre de rama que seleccionan el comando Personalizado. Usar", + "6278c0ce43": "Prefiere las plantillas de pull request del repositorio cuando no hay descripción.", + "d8b6764d79": "Usar plantilla de revisión cuando esté disponible", + "e001734396": "Crea revisiones alojadas como borradores salvo que se cambie en el composer.", + "6ba48f07a4": "Borrador predeterminado", + "15b60d54b2": "p. ej. ollama run llama3.1 {prompt}", + "3f1b26cc91": "para pasar la entrada del comando como argumento; si no, Orca la envía por stdin.", + "4f722a5f53": "Usado por recetas de commit-message, pull-request y nombre de rama que seleccionan Comando personalizado. Usa", "47e45cbd5a": "Comando personalizado", - "1ef29f8c29": "Línea de comando Orca se ejecuta cuando una receta de texto usa un comando personalizado.", + "1ef29f8c29": "Línea de comando que Orca ejecuta cuando una receta de texto usa Comando personalizado.", "2339a89104": "Agrega botones AI que ejecutan el agente seleccionado con la plantilla de comando para esa acción.", - "d5b45a3628": "Mostrar acciones de IA de control de código fuente", - "7bcad2b200": "Agrega recetas de acciones para acciones de commit de control de código fuente, solicitud de extracción, nombre de rama y reparación.", + "d5b45a3628": "Mostrar acciones de Source Control AI", + "7bcad2b200": "Agrega recetas de acción para commit, pull request, nombre de rama y arreglos de Source Control.", "d54c64163d": "activado", "4ec89c319e": "agent", "34d0348e34": "generar", "8cd2be0948": "mensaje", "ca433708cb": "commit", "0b7eafe55f": "ai", - "2c5436c018": "abierto", + "2c5436c018": "abrir", "6c84ba6de3": "plantilla", "ebed4d2a29": "borrador", - "02bab6542c": "pr", - "fdee745b87": "solicitud de fusión", - "b388463881": "solicitud de extracción", + "02bab6542c": "PR", + "fdee745b87": "merge request", + "b388463881": "pull request", "19e10a12bb": "revisión alojada", - "b8b6fd55b4": "{inmediato}", - "fc1a525fa5": "marcador de posición", - "a69e1fe91a": "inmediato", + "b8b6fd55b4": "{prompt}", + "fc1a525fa5": "placeholder", + "a69e1fe91a": "prompt", "1df7d71313": "binario", "407d28bde6": "cli", - "54038660e0": "dominio", - "25350d670f": "costumbre" + "54038660e0": "comando", + "25350d670f": "personalizado" }, "ComputerUsePane": { - "1735461723": "Permite a los agents inspeccionar y operar aplicaciones de escritorio locales.", - "93255aaf18": "Habilidad de uso de computadora", - "45f8e22c2e": "Abierto", - "d95d1cfab8": "Refrescar", + "1735461723": "Permite a los agents inspeccionar y operar apps de escritorio locales.", + "93255aaf18": "Skill Computer Use", + "45f8e22c2e": "Abrir", + "d95d1cfab8": "Actualizar", "0c29da5805": "Listo", - "3383ea1aab": "No se pudieron restablecer los permisos de uso de la computadora", - "f189f448a3": "Restablecer el acceso al uso de la computadora", - "5c45349665": "No se pudieron abrir los permisos de uso de la computadora", - "7801ac08ec": "Los permisos de uso de computadora solo se requieren en macOS", - "740766c291": "La configuración de uso de la computadora ya está completa", - "697005758f": "Privacidad y seguridad de macOS abiertas", - "2168fa5ab0": "No se pudieron cargar los permisos de uso de la computadora", - "0c9a33f468": "Capture ventanas de aplicaciones para que los agents puedan inspeccionar el estado visual.", + "3383ea1aab": "No se pudieron restablecer los permisos de Computer Use", + "f189f448a3": "Restablecer acceso de Computer Use", + "5c45349665": "No se pudieron abrir los permisos de Computer Use", + "7801ac08ec": "Los permisos de Computer Use solo se requieren en macOS", + "740766c291": "La configuración de Computer Use ya está completa", + "697005758f": "Se abrió Privacidad y seguridad de macOS", + "2168fa5ab0": "No se pudieron cargar los permisos de Computer Use", + "0c9a33f468": "Captura ventanas de apps para que los agents puedan inspeccionar el estado visual.", "07bbe4c4cb": "Capturas de pantalla", - "4d03dec2d0": "Lea los árboles de la interfaz de la aplicación y realice las acciones solicitadas.", + "4d03dec2d0": "Lee árboles de interfaz de apps y realiza las acciones solicitadas.", "6b5a2cd3a5": "Accesibilidad", "6b17602073": "Restablecer acceso", "506f2acf7a": "Restableciendo acceso...", "4b65070096": "darwin" }, "DeveloperPermissionsPane": { - "4c17304beb": "Refrescar", - "6326a4c5cc": "Utilice estos controles cuando una CLI, una aplicación local o una herramienta de automatización necesiten acceso privado a macOS. Orca no pregunta al inicio.", - "6f011b9bf6": "Las herramientas de terminal heredan la envoltura de privacidad de macOS de Orca.", + "4c17304beb": "Actualizar", + "6326a4c5cc": "Usa estos controles cuando una CLI, una app local o una herramienta de automatización necesite permisos de privacidad de macOS. Orca no pregunta al iniciar.", + "6f011b9bf6": "Las herramientas de terminal heredan el contexto de privacidad de macOS de Orca.", "bfa3402305": "No se pudo solicitar permiso", "66e94d6cf3": "Solicitud de permiso enviada", - "fa809e8ada": "Privacidad y seguridad de macOS abiertas", + "fa809e8ada": "Se abrió Privacidad y seguridad de macOS", "48d87edcd2": "Permiso concedido", "a552887288": "No se pudieron cargar los permisos de desarrollador", "4cfaa7e98a": "Herramientas de dispositivos Bluetooth y experimentos de hardware local.", - "b2210b1b4f": "bluetooth", - "dfbc12c8c8": "Herramientas de dispositivos y depuración de hardware que se comunican con dispositivos USB.", + "b2210b1b4f": "Bluetooth", + "dfbc12c8c8": "Depuración de hardware y herramientas que se comunican con dispositivos USB.", "bf51e4a542": "Dispositivos USB", - "f903bf20b5": "Descubrimiento y acceso a servidores de desarrollo en su red.", - "e7bb06007c": "Red Local", - "4a73f5217a": "Apple Events para scripts que controlan otras aplicaciones locales.", + "f903bf20b5": "Descubrimiento y acceso para servidores de desarrollo en tu red.", + "e7bb06007c": "Red local", + "4a73f5217a": "Apple Events para scripts que controlan otras apps locales.", "e119f0d66b": "Automatización", - "7ca17b62c8": "Recomendado cuando proyectos, worktrees o archivos con enlaces simbólicos usan carpetas protegidas de macOS.", - "c566bca278": "Acceso completo al disco", - "9f35980756": "Herramientas de inyección de pulsaciones de teclas, control de ventanas y automatización de UI.", + "7ca17b62c8": "Recomendado cuando proyectos, worktrees o archivos symlinked tocan carpetas protegidas de macOS.", + "c566bca278": "Acceso total al disco", + "9f35980756": "Herramientas de inyección de teclas, control de ventanas y automatización de UI.", "5b2f22ca2d": "Accesibilidad", - "0639db5496": "Herramientas de captura de pantalla, automatización visual y inspección de la interfaz de usuario.", + "0639db5496": "Herramientas de capturas de pantalla, automatización visual e inspección de UI.", "f24f31a884": "Grabación de pantalla", - "550cfa3750": "Captura de cámara web y aplicaciones de prueba locales controladas por cámara.", + "550cfa3750": "Captura de webcam y apps locales de prueba controladas por cámara.", "e5b5f3d6b9": "Cámara", - "cc8151d9fa": "Entrada de voz, transcripción, grabación de audio, sox, ffmpeg y CLI Whisper.", + "cc8151d9fa": "Entrada de voz, transcripción, grabación de audio, sox, ffmpeg y CLI de Whisper.", "16381e040a": "Micrófono", - "dac08ec03e": "Laboral..." + "dac08ec03e": "Trabajando..." }, "ExperimentalPane": { - "9762364929": "Usa APFS clone-copy en macOS cuando es posible; de lo contrario, crea enlaces simbólicos de las carpetas o archivos configurados en los árboles de trabajo creados.", - "24416f42cd": "Rutas compartidas en árboles de trabajo", - "fb82ea1d7a": "Materializa automáticamente archivos o carpetas configurados en árboles de trabajo recién creados.", - "a20d5ea365": "Mantiene visible un resaltado a nivel de panel después de que suena la terminal o los eventos de finalización del agente hasta que interactúa con ese panel. Experimental mientras sintonizamos la señal.", - "ec897e8d89": "Atención terminal", - "88b7613afb": "Resaltado de panel persistente para eventos de finalización de agente y timbre de terminal.", - "0277901cf7": "Agrega una entrada de Agents a la barra lateral izquierda con una fuente de árbol de trabajo encadenada para Agents completados, preguntas de bloqueo, estado no leído y eventos de creación de árbol de trabajo. Experimental: el modelo de evento y la interfaz de usuario pueden cambiar.", - "a05bcdaf57": "Vista de Agents", - "f63ea281e3": "Feed integrado en la barra lateral izquierda para completar agentes y estados de bloqueo.", - "ca2219fe5e": "Muestra una pequeña mascota animada fijada en la esquina inferior derecha. Elige un personaje (Claudino, OpenCode, Gremlin) o carga tu propio PNG, APNG, GIF, WebP, JPG o SVG desde el menú de mascotas de la barra de estado. Ocultarlo en cualquier momento desde el mismo menú sin desactivar esta configuración.", + "9762364929": "Usa APFS clone-copy en macOS cuando sea posible; si no, crea symlinks de las carpetas o archivos configurados en los worktrees creados.", + "24416f42cd": "Rutas compartidas en worktrees", + "fb82ea1d7a": "Materializa automáticamente archivos o carpetas configurados en worktrees recién creados.", + "a20d5ea365": "Mantiene visible un resaltado a nivel de panel después de una campana del terminal o eventos de finalización de agente hasta que interactúes con ese panel. Experimental mientras ajustamos la señal.", + "ec897e8d89": "Atención del terminal", + "88b7613afb": "Resaltado persistente del panel para campanas del terminal y eventos de finalización de agentes.", + "0277901cf7": "Agrega una entrada Agents a la barra lateral izquierda con un feed en hilos de worktrees para agentes completados, preguntas bloqueantes, estado sin leer y eventos de creación de worktrees. Experimental: el modelo de eventos y la UI pueden cambiar.", + "a05bcdaf57": "Vista Agents", + "f63ea281e3": "Feed en hilos en la barra lateral izquierda para finalizaciones de agentes y estados bloqueantes.", + "ca2219fe5e": "Muestra una pequeña mascota animada fijada en la esquina inferior derecha. Elige un personaje (Claudino, OpenCode, Gremlin) o carga tu propio PNG, APNG, GIF, WebP, JPG o SVG desde el menú de mascota de la barra de estado. Ocúltala cuando quieras desde el mismo menú sin desactivar este ajuste.", "dd6f0a1d45": "Mascota", "0e89a574ae": "Mascota animada flotante en la esquina inferior derecha.", "agentHibernation": { "copy": "Detiene los terminales de agentes en segundo plano que estén inactivos después del intervalo configurado y reanuda las sesiones compatibles cuando las vuelves a abrir. La suspensión de agentes conserva las opciones de inicio de los agentes iniciados por Orca. Los agentes iniciados manualmente pueden reanudarse con tus valores predeterminados actuales de Orca. Experimental mientras ajustamos el modelo de seguridad.", "description": "Detiene los terminales de agentes en segundo plano que estén inactivos después del intervalo configurado y reanuda las sesiones compatibles cuando las vuelves a abrir.", - "idleMinutesDescription": "Cuántos minutos inactivo debe esperar un agente en segundo plano completado antes de que Orca pueda suspenderlo.", + "idleMinutesDescription": "Cuántos minutos debe esperar inactivo un agente en segundo plano completado antes de que Orca pueda suspenderlo.", "idleMinutesLabel": "Suspender después de", "idleMinutesSuffix": "minutos", "title": "Suspensión de agentes", "toggleLabel": "Alternar suspensión de agentes" }, "newWorktreeCardStyle": { - "copy": "Previsualiza el diseño actualizado de tarjetas de worktree, la ubicación de metadatos, las opciones del menú de visualización de tarjeta y la presentación de estado.", - "description": "Previsualiza el diseño actualizado de tarjetas de worktree, la ubicación de metadatos, las opciones del menú de visualización de tarjeta y la presentación de estado.", + "copy": "Previsualiza el diseño actualizado de tarjetas de worktree, la ubicación de metadatos, las opciones del menú de visualización de tarjetas y la presentación de estado.", + "description": "Previsualiza el diseño actualizado de tarjetas de worktree, la ubicación de metadatos, las opciones del menú de visualización de tarjetas y la presentación de estado.", "title": "Nuevo estilo de tarjeta", "toggleLabel": "Alternar nuevo estilo de tarjeta" }, "nativeChat": { - "title": "chat nativo", - "description": "Obtenga una vista previa de la superficie de chat del escritorio para las sesiones de terminal de Claude y Codex.", - "copy": "Agrega una vista de chat nativa a la que puede cambiar desde los paneles de terminal compatibles de Claude y Codex. Experimental mientras ajustamos la fidelidad de la transcripción, la transmisión y la paridad terminal.", + "title": "Chat nativo", + "description": "Previsualiza la superficie de chat de escritorio para sesiones de terminal de Claude y Codex.", + "copy": "Agrega una vista de chat nativa a la que puedes cambiar desde paneles de terminal compatibles de Claude y Codex. Experimental mientras ajustamos la fidelidad de transcripción, streaming y paridad con el terminal.", "toggleLabel": "Alternar chat nativo", "defaultTitle": "Vista predeterminada", - "defaultCopy": "Elija cómo se abren las nuevas pestañas de terminal de Claude y Codex.", - "defaultViewLabel": "Vista de chat nativa predeterminada", - "defaultViewTerminal": "charla terminal", - "defaultViewNative": "chat nativo" + "defaultCopy": "Elige cómo se abren las nuevas pestañas de terminal de Claude y Codex.", + "defaultViewLabel": "Vista predeterminada de chat nativo", + "defaultViewTerminal": "Chat en terminal", + "defaultViewNative": "Chat nativo" } }, "FloatingWorkspacePane": { "aeaf76fda9": "Barra de estado", "9fb225f2d7": "Botón flotante", - "3c900e26e5": "El método abreviado de teclado funciona independientemente de dónde se muestre el interruptor.", - "5e5a8da236": "Ubicación del botón de alternancia", - "505001823e": "Elija el directorio del espacio de trabajo flotante", - "81afb79785": "Las nuevas pestañas de terminal flotante comienzan aquí. Las notas de Markdown se guardan en el espacio de trabajo flotante propiedad de la aplicación de Orca.", - "12aa09f10c": "Directorio de terminales", - "41eb95f7f0": "Muestra el botón y el panel del espacio de trabajo flotante.", - "5136813663": "Habilitar espacio de trabajo flotante", - "37df688d6f": "Habilite el espacio de trabajo flotante y elija dónde comienzan las nuevas pestañas.", - "1f67f39384": "Espacio de trabajo flotante" + "3c900e26e5": "El atajo de teclado funciona independientemente de dónde se muestre el botón.", + "5e5a8da236": "Ubicación del botón de activación", + "505001823e": "Elegir directorio del workspace flotante", + "81afb79785": "Las nuevas pestañas de terminal flotante comienzan aquí. Las notas Markdown se guardan en el workspace flotante propiedad de la app de Orca.", + "12aa09f10c": "Directorio del terminal", + "41eb95f7f0": "Muestra el botón y el panel del workspace flotante.", + "5136813663": "Activar workspace flotante", + "37df688d6f": "Activa el workspace flotante y elige dónde comienzan las nuevas pestañas.", + "1f67f39384": "Workspace flotante" }, "AgentCacheTimerSection": { "05de84a104": "1 hora", "54395ecd7c": "5 minutos", - "8b9e202e0a": "Haga coincidir esto con el TTL de caché de su proveedor. El valor predeterminado es 5 minutos.", + "8b9e202e0a": "Haz que esto coincida con el TTL de caché de tu proveedor. El valor predeterminado es 5 minutos.", "a2a8962138": "Duración del temporizador", "b4e7302944": "Temporizador de caché", "487b176240": "Muestra una cuenta regresiva en la barra lateral después de que un agente de Claude quede inactivo.", "9c20253679": "Muestra una cuenta regresiva después de que un agente de Claude quede inactivo.", - "fe590653c1": "Claude almacena en caché su conversación para reducir costos. Cuando está inactivo durante demasiado tiempo, la memoria caché caduca y el siguiente mensaje reenvía el contexto completo a un costo mayor. Esto muestra una cuenta atrás para que sepas cuándo reanudar.", - "a137f8854d": "Temporizador de caché rápido", - "80c454e8a6": "Haga coincidir esto con el TTL de caché de su proveedor." + "fe590653c1": "Claude cachea tu conversación para reducir costos. Si queda inactiva demasiado tiempo, la caché caduca y el siguiente mensaje reenvía todo el contexto con mayor costo. Esto muestra una cuenta regresiva para que sepas cuándo reanudar.", + "a137f8854d": "Prompt Cache Timer", + "80c454e8a6": "Haz que esto coincida con el TTL de caché de tu proveedor." }, "GeneralEditorSettingsSection": { - "f80603d293": "Muestre controles de notas de markdowns locales en modo de editor enriquecido y acciones de transferencia de agentes.", - "4edc104f0f": "Notas de revisión de Markdowns", - "5f02e6fb21": "Muestre los controles de notas de revisión de markdowns locales en el modo de editor enriquecido.", - "51161d1647": "Muestra la descripción general del minimapa al editar un archivo.", + "f80603d293": "Muestra controles de notas Markdown locales en modo de editor enriquecido y acciones de handoff de agentes.", + "4edc104f0f": "Notas Markdown de revisión", + "5f02e6fb21": "Muestra controles de notas Markdown locales en modo de editor enriquecido.", + "51161d1647": "Muestra la vista general del minimapa al editar un archivo.", "6690b1ffb9": "Minimapa", "5a1ea6eaa2": "Oculto", - "73a09aad63": "Mostrado", - "1de48ad940": "Árbol de archivos de diferencias predeterminado", - "1b87897af9": "Muestre u oculte el árbol de archivos al abrir vistas de diferencias combinadas.", + "73a09aad63": "Visible", + "1de48ad940": "Árbol de archivos de diff predeterminado", + "1b87897af9": "Muestra u oculta el árbol de archivos al abrir vistas de diff combinado.", "12cbc0d0d6": "Lado a lado", "05b6df93b3": "En línea", - "7311f67ee7": "Vista de diferencias predeterminada", - "b492397d34": "Formato de presentación preferido para mostrar diferencias de git de forma predeterminada.", - "a5db1d3975": "EM", - "fc5c5306ff": "EM.", - "8112cd6dcf": "Cuánto tiempo espera Orca después de su última edición antes de guardar automáticamente. El primer lanzamiento predeterminado es", + "7311f67ee7": "Vista de diff predeterminada", + "b492397d34": "Formato de presentación preferido para mostrar diffs de git de forma predeterminada.", + "a5db1d3975": "ms", + "fc5c5306ff": "ms.", + "8112cd6dcf": "Cuánto espera Orca después de tu última edición antes de guardar automáticamente. El valor predeterminado en el primer inicio es", "d6cf227ca0": "Retraso de guardado automático", - "1bec6d8318": "Cuánto tiempo espera Orca después de su última edición antes de guardar automáticamente.", - "70bb30feb1": "Guarde el editor y los cambios de diferencias editables automáticamente después de una breve pausa.", + "1bec6d8318": "Cuánto espera Orca después de tu última edición antes de guardar automáticamente.", + "70bb30feb1": "Guarda automáticamente los cambios del editor y de diffs editables después de una breve pausa.", "0df2e4fd12": "Guardar archivos automáticamente", - "d21136d9ef": "Configure cómo Orca persiste en las ediciones de archivos.", + "d21136d9ef": "Configura cómo Orca persiste ediciones de archivos.", "45c6e85c4d": "Editor", - "8f1afdfbd8": "Ajuste de palabras diferencial", - "4aa4d9fb73": "Ajuste líneas largas en editores de diferencias en lugar de requerir desplazamiento horizontal.", - "bf16ef0af2": "Apagado", - "3f6892f307": "En" + "8f1afdfbd8": "Ajuste de línea en diffs", + "4aa4d9fb73": "Ajusta líneas largas en editores de diff en lugar de requerir desplazamiento horizontal.", + "bf16ef0af2": "Desactivado", + "3f6892f307": "Activado", + "b82f86d7d2": "Corrector ortográfico de Rich Markdown", + "5195f0b9ef": "Muestra subrayados y sugerencias ortográficas del navegador al editar Rich Markdown." }, "AdvancedNetworkSettingsSection": { - "3e431564b5": "host local, 127.0.0.1, *.interno", - "33ee3ca3af": "Opcional. Separe los hosts con comas, punto y coma o líneas nuevas.", + "3e431564b5": "localhost, 127.0.0.1, *.internal", + "33ee3ca3af": "Opcional. Separa hosts con comas, punto y coma o líneas nuevas.", "f6d76cc8f4": "Reglas de omisión de proxy", - "fb7130dcb9": "Hosts que deberían omitir el proxy HTTP configurado.", - "0adfce9fa7": "Admite URL http, https, calcetines, calcetines4 y calcetines5.", + "fb7130dcb9": "Hosts que deben omitir el proxy HTTP configurado.", + "0adfce9fa7": "Admite URLs http, https, socks, socks4 y socks5.", "476f302aca": "http://proxy.example.com:8080", - "1e214e265a": "Déjelo vacío para usar la configuración del proxy del sistema y las variables de entorno del proxy heredadas.", + "1e214e265a": "Déjalo vacío para usar la configuración de proxy del sistema y las variables de entorno de proxy heredadas.", "f00daf6324": "Proxy HTTP", - "823e0f15b1": "URL proxy para solicitudes de red Orca y terminales secundarios locales.", - "d93c7cd531": "Configure el enrutamiento de red a nivel de aplicación.", + "823e0f15b1": "URL de proxy para requests de red de Orca y procesos de terminal locales hijos.", + "d93c7cd531": "Configura el enrutamiento de red a nivel de app.", "c46cdbbd4e": "Red", "configureProxy": "Configurar proxy" }, @@ -5179,18 +5234,18 @@ "d58fccfd84": "Navegación", "5cb5475664": "Confirmar antes de cerrar pestañas fijadas", "36b2a5dc6d": "Muestra un diálogo de confirmación antes de cerrar una pestaña fijada.", - "projectRuntime": "Project Runtime", - "projectRuntimeDescription": "Default runtime for local Windows projects that do not override it." + "projectRuntime": "Runtime del proyecto", + "projectRuntimeDescription": "Runtime predeterminado para proyectos locales de Windows que no lo sobrescriben." }, "GeneralSupportSection": { "af7d9f4396": "¡Gracias por el apoyo!", - "6922c1fa2b": "Orca estrella en GitHub", - "511782265b": "Apoye el proyecto con una estrella de GitHub a través de la CLI de gh.", - "55a87e5fd1": "Apoya a la Orca", - "964acc6bb4": "Dar estrella", + "6922c1fa2b": "Dale una estrella a Orca en GitHub", + "511782265b": "Apoya el proyecto con una estrella de GitHub mediante la CLI gh.", + "55a87e5fd1": "Apoya a Orca", + "964acc6bb4": "Dar una estrella", "73b327e793": "Intentar otra vez", "c9f96d4234": "error", - "397719bee5": "Dando estrella...", + "397719bee5": "Dando una estrella...", "1e29570462": "dando estrella", "9d181300e3": "con estrella", "5c49f02662": "oculto", @@ -5202,16 +5257,16 @@ "8a52ca1d02": "Notas de la versión", "d89806cc89": "está listo para instalar.", "a6b37929dc": "Versión", - "8311da27ba": "está disponible. Haga clic en \"Instalar actualización\" para descargarla e instalarla.", + "8311da27ba": "está disponible. Haz clic en \"Instalar actualización\" para descargarla e instalarla.", "f44299636f": "Reiniciar para actualizar (", "42717918f4": "Instalar actualización (", "02dc082e70": "No se pudo iniciar la descarga de la actualización.", "e1a647adc5": "Buscar actualizaciones", - "ceb579abaf": "Busque actualizaciones de la aplicación e instale una versión más reciente de Orca.", + "ceb579abaf": "Busca actualizaciones de la app e instala una versión más reciente de Orca.", "d91ebfb87e": "Versión actual: {{value0}}", "f2b1ccc12a": "Actualizaciones", - "bd79d412f0": "La verificación de actualización falló. {{value0}}", - "b9ad70c30d": "Error de actualización. {{value0}}", + "bd79d412f0": "No se pudo buscar actualizaciones. {{value0}}", + "b9ad70c30d": "Error al actualizar. {{value0}}", "6405510b92": "error", "a0832ccdb1": "descargado", "2a48034c4c": "Descargando v{{value0}}... {{value1}}%", @@ -5225,20 +5280,20 @@ "7173352632": "idle" }, "GeneralWorkspaceSettingsSection": { - "3d538a98f7": "Elija las aplicaciones disponibles en el menú Abrir en de un espacio de trabajo.", + "3d538a98f7": "Elige las apps disponibles en el menú Abrir en de un espacio de trabajo.", "008f92085f": "Abrir en aplicaciones", "824b98a0d9": "Muestra una confirmación antes de eliminar automatizaciones y su historial de ejecución.", - "ea98373cd8": "Pregunte antes de eliminar automatizaciones", + "ea98373cd8": "Preguntar antes de eliminar automatizaciones", "d2dd2ca2e3": "Muestra un cuadro de diálogo de confirmación antes de eliminar una automatización y su historial de ejecución.", - "28bc3d085e": "Muestra una confirmación antes de eliminar un espacio de trabajo del menú contextual. Las eliminaciones fallidas aún muestran un recurso de Forzar eliminación.", + "28bc3d085e": "Muestra una confirmación antes de eliminar un espacio de trabajo desde el menú contextual. Las eliminaciones fallidas aún muestran la opción Forzar eliminación.", "9f380934cf": "Preguntar antes de eliminar espacios de trabajo", "5734db82af": "Muestra un cuadro de diálogo de confirmación antes de eliminar un espacio de trabajo.", - "4fbf910ded": "Cree espacios de trabajo dentro de una subcarpeta con nombre de repo.", + "4fbf910ded": "Crea espacios de trabajo dentro de una subcarpeta con el nombre del repositorio.", "ba3480642f": "Espacios de trabajo anidados", "a246f5ce6f": "Directorio raíz donde se crean las carpetas del espacio de trabajo.", - "5567191a6e": "Navegar", + "5567191a6e": "Examinar", "0e9fc0eadc": "Directorio de espacio de trabajo", - "e2955d9ccb": "Configure dónde se crean nuevos espacios de trabajo.", + "e2955d9ccb": "Configura dónde se crean nuevos espacios de trabajo.", "7511097c5d": "Espacio de trabajo" }, "GhosttyImportModal": { @@ -5247,54 +5302,54 @@ "b7ddae600c": "Hecho", "e4bda7ce6f": "No se encontró ninguna configuración de Ghostty en este sistema.", "b58d4c9051": "Claves no compatibles", - "674b5ccd6b": "No hay nuevas configuraciones para importar: su configuración actual ya coincide.", - "a4c5dec640": "Configuraciones para actualizar", + "674b5ccd6b": "No hay nuevos ajustes que importar; tus ajustes actuales ya coinciden.", + "a4c5dec640": "Ajustes por actualizar", "4466f4cdaa": "Importación completa", "023a52c1f7": "Cargando vista previa…", - "2763b0c045": "Revise la configuración que se importará desde su configuración de Ghostty.", + "2763b0c045": "Revisa los ajustes que se importarán desde tu configuración de Ghostty.", "d2f33670a9": "Importar desde Ghostty", "273e7e81fe": "Configuraciones", "1f744a72f4": "configuración" }, "GitPane": { - "d2eede4c54": "Agregue la atribución de Orca a commits, PR y problemas.", + "d2eede4c54": "Agrega la atribución de Orca a commits, PR e issues.", "e02ea23a32": "Atribución de Orca", "e71ce09c42": "orca", "b9b5771bb1": "atribución", "b5f534717a": "coautor", "9838c921ed": "coautor", - "afada55042": "asunto", + "afada55042": "issue", "b4ef5428a7": "pr", "895d3f70b8": "gh", "32dca11189": "github", - "c4f610d057": "Encabezados de límite de velocidad REST de GitLab CLI actuales cuando estén disponibles.", + "c4f610d057": "Encabezados actuales de límite de tasa REST de GitLab CLI cuando estén disponibles.", "0de4ae556c": "Presupuesto de la API de GitLab", "cdd793134e": "presupuesto API", "b9c011fbc2": "límite de tasa", - "3072428ac7": "glabro", + "3072428ac7": "glab", "8a527d48e3": "gitlab", "aa204f185f": "Límites de velocidad actuales de GitHub CLI REST, Search y GraphQL.", "612a440e57": "Presupuesto de la API de GitHub", - "2cde9044a8": "graficoql", - "36e3de3619": "de comparar con la historia obsoleta. Orca omite la actualización si esa rama tiene cambios no confirmados o commits solo locales.", - "d072a12995": "git diff principal...CABEZA", + "2cde9044a8": "graphql", + "36e3de3619": "comparen con historial obsoleto. Orca omite la actualización si esa rama tiene cambios sin commit o commits solo locales.", + "d072a12995": "git diff main...HEAD", "db3a127eb1": ". Esto mantiene comandos como", - "3ae3de8898": "maestro", + "3ae3de8898": "master", "5bf885be48": "o", - "ffba483bae": "principal", - "976afc6b3e": "Cuando crea un espacio de trabajo, Orca actualiza la base remota y adelanta de forma segura su sucursal local coincidente, como", - "1ec5c91e1d": "Elija si los nombres de las ramas usan su nombre de usuario de Git, un prefijo personalizado o ningún prefijo.", + "ffba483bae": "main", + "976afc6b3e": "Cuando creas un espacio de trabajo, Orca actualiza la base remota y adelanta de forma segura tu rama local correspondiente, como", + "1ec5c91e1d": "Elige si los nombres de rama usan tu usuario de Git, un prefijo personalizado o ningún prefijo.", "330f584b50": "Prefijo de rama", - "1ffaadf0a0": "Prefijo agregado a los nombres de las ramas al crear árboles de trabajo.", - "813e15b346": "costumbre", - "2351aa5a31": "nombre de usuario git", - "cc63fce906": "denominación de sucursales", - "b559bf9899": "p.ej. característica", - "aefa1ecb59": "No hay ningún nombre de usuario de git configurado", + "1ffaadf0a0": "Prefijo que se añade a los nombres de rama al crear worktrees.", + "813e15b346": "personalizado", + "2351aa5a31": "usuario de Git", + "cc63fce906": "nombres de rama", + "b559bf9899": "p. ej. feature", + "aefa1ecb59": "No hay ningún usuario de Git configurado", "f35007e6e8": "nombre de usuario git", "3d172725cc": "Ninguno", - "1f32ba27a6": "Costumbre", - "a182c5125e": "Nombre de usuario", + "1f32ba27a6": "Personalizado", + "a182c5125e": "Usuario de Git", "sourceControlGroupOrderTitle": "Orden de grupos de control de código fuente", "sourceControlGroupOrderDescription": "Elige si Cambios, Cambios preparados o Archivos sin seguimiento aparecen primero en Control de código fuente.", "changesFirst": "Cambios primero", @@ -5313,7 +5368,7 @@ }, "InputPane": { "db15068196": "Habilitado de forma predeterminada en Linux y macOS. Linux usa el portapapeles de selección del sistema; otras plataformas utilizan un búfer privado.", - "ad31c3c5fb": "Haga clic con el botón central en Pegar desde la selección" + "ad31c3c5fb": "Pegar desde la selección con clic central" }, "IntegrationsPane": { "2122e15517": "Cada espacio de trabajo Linear conectado tiene una clave almacenada por el tiempo de ejecución activo. Las claves de acceso total pueden cubrir todos los equipos a los que puede acceder el propietario de la clave; Las claves restringidas se pueden reemplazar en cualquier momento.", @@ -5387,26 +5442,26 @@ "a565377c38": "no instalado", "15cf990798": "No autenticado", "f7eb5f0b24": "No instalado", - "3ba07f933b": "Connect issue trackers Orca can use to browse tasks and start workspaces with linked context.", - "70e885705b": "Task providers", - "1683acbac4": "Connect the source hosts Orca can use for pull requests, merge requests, checks, and review status.", - "298c65ecac": "Review providers" + "3ba07f933b": "Conecta issue trackers que Orca puede usar para explorar tareas e iniciar espacios de trabajo con contexto vinculado.", + "70e885705b": "Proveedores de tareas", + "1683acbac4": "Conecta los hosts de código que Orca puede usar para pull requests, merge requests, checks y estado de revisión.", + "298c65ecac": "Proveedores de revisión" }, "KagiSessionLinkForm": { - "92f0b4e472": "Claro", + "92f0b4e472": "Borrar", "9f741627a7": "Se borró el enlace de la sesión de Kagi.", - "d5c8b94c5b": "Ahorrar", + "d5c8b94c5b": "Guardar", "ff450194cd": "Enlace de sesión privada de Kagi", "e383683485": "https://kagi.com/search?token=...", "81409d9362": "Enlace de sesión privada opcional para la autenticación Kagi.", "3e5b7c6c25": "Enlace de sesión de Kagi guardado.", - "0911d5fa4c": "Ingrese un enlace de sesión privada de Kagi desde https://kagi.com/search?token=..." + "0911d5fa4c": "Introduce un enlace de sesión privada de Kagi desde https://kagi.com/search?token=..." }, "KeybindingsFileActions": { "abc49853fb": "Recargar desde el disco", - "a8a8d6b9d3": "Revelar en el Administrador de archivos", + "a8a8d6b9d3": "Mostrar en el administrador de archivos", "9e24c0e858": "Abrir en Cursor", - "1637f64033": "Abrir en código VS", + "1637f64033": "Abrir en VS Code", "98f1a23e1c": "Abrir con la aplicación predeterminada", "400397a10d": "Abrir menú de archivo de combinaciones de teclas", "1c2be2b2c6": "Editar archivo en Orca", @@ -5417,44 +5472,44 @@ "ManageSessionKillDialog": { "6bf4627168": "Cancelar", "ad9832aa26": ". Cualquier trabajo no guardado en ese panel se perderá. Esto no se puede deshacer.", - "8401328fed": "Renuncias forzadas", - "87dcafc85c": "¿Matar esta sesión?", - "0b0db4c68c": "Matar sesión", - "d3dba51b15": "Asesinato…" + "8401328fed": "Fuerza el cierre de ", + "87dcafc85c": "¿Finalizar esta sesión?", + "0b0db4c68c": "Finalizar sesión", + "d3dba51b15": "Finalizando…" }, "ManageSessionsSection": { - "33c2a1e1b4": "Matar sesión {{value0}}", + "33c2a1e1b4": "Finalizar sesión {{value0}}", "2896a50f50": "Ir a la terminal {{value0}}", "e26a60d9eb": "Sin sesiones.", "39c53d6d74": "Cargando…", - "5ed15e778c": "Reiniciar demonio", - "3282db098c": "Matar todas las sesiones", - "b3b1cc5708": "Refrescar", + "5ed15e778c": "Reiniciar servicio", + "3282db098c": "Finalizar todas las sesiones", + "b3b1cc5708": "Actualizar", "a795a9552a": "Sesiones", - "7c4889a724": "Recupérese de una terminal congelada o que se comporta mal cancelando sesiones o reiniciando el demonio subyacente.", + "7c4889a724": "Recupérate de una terminal congelada o que se comporta mal finalizando sesiones o reiniciando el servicio subyacente.", "d1b80fd5cd": "Administrar sesiones", - "9c940434af": "Vuelva al tiempo de ejecución local para reiniciar o finalizar las sesiones del demonio local.", + "9c940434af": "Vuelve al runtime local para reiniciar o finalizar las sesiones del servicio local.", "ad467eaadc": "La administración de sesiones no está disponible mientras un servidor de ejecución remoto está activo.", "8dbd96b463": "No se pudo finalizar la sesión.", "0735b7a586": "No se pudo cerrar la sesión; es posible que ya haya desaparecido.", - "bfba05dccd": "Sesión asesinada.", + "bfba05dccd": "Sesión finalizada.", "c535cbdd09": "No se pudieron cargar las sesiones.", "e3d1fbe008": "Reanudar", "a06ababda0": "matar a todos" }, "McpConfigFileRow": { "b145eb6009": "entorno:", - "e720c139cd": "Abierto", + "e720c139cd": "Abrir", "845ae248e8": "válido" }, "McpConfigSection": { "4d16a0d9ac": "Comprobado", - "b900cd6282": "No se encontró ninguna configuración de MCP. Agregue una configuración de espacio de trabajo vacío cuando desee que este repo defina sus propios servidores MCP.", - "3b224167ff": "servidor", + "b900cd6282": "No se encontró ninguna configuración de MCP. Agrega una configuración vacía de workspace cuando quieras que este repositorio defina sus propios servidores MCP.", + "3b224167ff": "server", "251b96564a": "detectado ·", "f34c152dc0": "Actualizar configuraciones de MCP", - "6bac9ddfc6": "Los repos SSH se leen a través del sistema de archivos remoto. La creación inicial se limita a la configuración raíz del espacio de trabajo.", - "96f5609b04": "Inspeccione las definiciones del servidor MCP que los agents pueden usar mientras trabajan en este repo.", + "6bac9ddfc6": "Los repositorios SSH se leen a través del sistema de archivos remoto. La creación inicial se limita a la configuración raíz del espacio de trabajo.", + "96f5609b04": "Inspecciona las definiciones de servidores MCP que los agentes pueden usar mientras trabajan en este repositorio.", "55eea3ef47": "Configuraciones de MCP", "9ee215caf6": ".mcp.json", "1f3665e35a": "Configuración de MCP creada", @@ -5463,56 +5518,56 @@ }, "MobileEmulatorAgentControlRow": { "1861982430": "No se pudo cargar el estado de CLI.", - "8af7a8bc38": "Los comandos apuntan al emulador activo del árbol de trabajo actual. Las coordenadas están normalizadas de 0 a 1.", + "8af7a8bc38": "Los comandos apuntan al emulador activo del worktree actual. Las coordenadas están normalizadas de 0 a 1.", "c7f3fe0a6e": "Comandos comunes del emulador", - "d94ca6a623": "Permite a los agents utilizar comandos CLI de Orca, incluido el control del emulador móvil.", - "67e19ee03c": "Habilidad CLI de Orca", + "d94ca6a623": "Permite a los agentes usar comandos de la CLI de Orca, incluido el control del emulador móvil.", + "67e19ee03c": "Skill Orca CLI", "aaf62a3dd2": "Instalado en", - "2fef055608": "Registra el comando CLI de Orca para que los agents puedan controlar el emulador activo desde su shell.", + "2fef055608": "Registra el comando de la CLI de Orca para que los agentes puedan controlar el emulador activo desde su shell.", "4f2205f3b6": "Habilitar CLI de Orca", - "ff4b7e65d6": "Deje que los agents de codificación controlen el emulador móvil activo con los comandos CLI de Orca.", - "2a674aa810": "Control del emulador móvil del Agent", + "ff4b7e65d6": "Permite que los agentes de codificación controlen el emulador móvil activo con comandos de la CLI de Orca.", + "2a674aa810": "Control del emulador móvil para agentes", "cdeaed9e37": "Registré Orca CLI en PATH.", "3d34423e88": "Registrando la CLI de Orca", - "3be27641c9": "para que los comandos del emulador se puedan ejecutar desde shells de agents.", + "3be27641c9": "para que los comandos del emulador se puedan ejecutar desde shells de agentes.", "3941719a56": "Comprobando la CLI de Orca antes de abrir la configuración de la skill." }, "MobileEmulatorExamples": { "edf13dd03b": "Copiar", - "c12b253997": "Copiar mensaje de ejemplo", + "c12b253997": "Copiar prompt de ejemplo", "d151e25078": "\"", "b525ff2b12": "\"", - "4daa95f25a": "Pegue cualquiera de estos en Claude Code, Codex u otro agente en un proyecto donde esté instalada la habilidad Orca CLI.", - "0820b3f84f": "Pruébelo: indicaciones de ejemplo", - "1f608e7d60": "No se pudo copiar el mensaje.", - "2b077b5544": "Mensaje copiado." + "4daa95f25a": "Pega cualquiera de estos en Claude Code, Codex u otro agente en un proyecto donde esté instalado el skill Orca CLI.", + "0820b3f84f": "Pruébalo: prompts de ejemplo", + "1f608e7d60": "No se pudo copiar el prompt.", + "2b077b5544": "Prompt copiado." }, "MobileEmulatorSettingsPane": { - "19d39113b6": "Deje que los agents de codificación controlen el emulador móvil activo con los comandos CLI de Orca.", - "f2f8d97bb6": "Control del emulador móvil del Agent", + "19d39113b6": "Permite que los agentes de codificación controlen el emulador móvil activo con comandos de la CLI de Orca.", + "f2f8d97bb6": "Control del emulador móvil para agentes", "143961d031": "Dispositivo predeterminado", "8aec2f99a0": "Actualizar la disponibilidad del emulador", "ae1612c58c": "Disponibilidad", - "f9af91ea26": "Muestra la acción Nuevo emulador móvil y permite que los agents se conecten al emulador activo.", + "f9af91ea26": "Muestra la acción Nuevo emulador móvil y permite que los agentes se conecten al emulador activo.", "700ddbf9b1": "Habilitar emulador móvil", - "bc39d0f115": "Configure la compatibilidad con el emulador móvil para Orca y agents de codificación.", + "bc39d0f115": "Configura la compatibilidad con el emulador móvil para Orca y agentes de codificación.", "6593c9ddd3": "Emulador móvil", "a4f1c82d90": "Desactivado", - "b5e2d93e01": "De cheques...", + "b5e2d93e01": "Comprobando...", "c6f3ea4f12": "Listo", "d704fb5023": "Necesita configuración", "06b06429c6": "Comprobando la compatibilidad con el SDK de Android y el simulador de iOS.", "6d1483d4a0": "1 dispositivo emulador detectado.", "0a452d4d3b": "{{value0}} dispositivos emuladores detectados.", "f62a1bb759": "Orca seleccionará automáticamente un dispositivo emulador después de que se detecten los dispositivos.", - "b2fd62ea75": "Dispositivo predeterminado para nuevas pestañas del emulador y comandos de conexión de agents. La selección automática prefiere un dispositivo que ya esté en ejecución." + "b2fd62ea75": "Dispositivo predeterminado para nuevas pestañas del emulador y comandos de conexión de agentes. La selección automática prefiere un dispositivo que ya esté en ejecución." }, "MobileNetworkInterfaceSection": { "63d5e4ae1e": "Regenera el código QR y escanéalo desde la aplicación móvil de Orca.", "87985ba6f5": "En este menú de Interfaz de red, elija la dirección Tailscale, generalmente una IP 100.x.y.z.", "1f7c26d36a": "Inicie sesión en la misma tailnet en ambos dispositivos.", "668016be7a": "en su computadora y teléfono.", - "1dc87a7fbc": "Escala de cola", + "1dc87a7fbc": "Tailscale", "51d29927eb": "Instalar", "9fc5d203ff": "Orca Mobile se conecta directamente a esta computadora. Para usarlo fuera de la misma red local, coloque su computadora y teléfono en la misma red superpuesta privada, luego genere el código QR con esa dirección de red seleccionada.", "39fad211d9": "Conéctate fuera de tu Wi-Fi con una tailnet", @@ -5521,81 +5576,81 @@ "d536b5e20d": "Elija qué dirección de red anunciar en el código QR. Utilice su dirección LAN para el emparejamiento de la misma red o una dirección de red superpuesta (Tailscale, ZeroTier) para el acceso entre redes.", "406a35121c": "Interfaz de red", "c541f67790": "Generar código QR", - "1e64659126": "Regenerado" + "1e64659126": "Regenerar" }, "MobilePane": { "dd3cd78d04": "Escanear con Orca Móvil", - "35100bca5d": "Mientras usas un terminal en tu teléfono, Orca lo reduce para que se ajuste a la pantalla de tu teléfono. Cuando cierra la aplicación o la abandona, esto controla si permanece en el tamaño del teléfono (para que las herramientas CLI interactivas no vuelvan a fluir) o cambia de tamaño a su escritorio. Siempre puedes usar Restaurar este terminal o Restaurar todos los terminales en el banner para cambiar el tamaño manualmente.", + "35100bca5d": "Mientras usas un terminal en tu teléfono, Orca lo reduce para que se ajuste a la pantalla del teléfono. Cuando cierras la app o cambias a otra, esto controla si se queda en tamaño de teléfono (para que las herramientas CLI interactivas no se reacomoden) o vuelve al tamaño del escritorio. Siempre puedes usar Restaurar este terminal o Restaurar todos los terminales en el banner para cambiar el tamaño manualmente.", "ee56f1c7e4": "Cuando sales de la aplicación móvil", "3939fd062c": "Revocar un dispositivo lo desconecta inmediatamente.", - "254a6d09e4": "emparejado", + "254a6d09e4": "Emparejado", "d7ce676270": "Dispositivos emparejados", - "e778ecb209": "O pegue este código en la aplicación móvil:", + "e778ecb209": "O pega este código en la app móvil:", "310924ad2c": "Escanea este código con la aplicación móvil de Orca. Cada código crea un token de dispositivo único.", "870e1b5ca5": "No se pudo revocar el dispositivo", "2e3dd0bc29": "Dispositivo revocado", "711231348f": "No se pudo copiar el código de emparejamiento", "e3c427e020": "No se pudo generar el código QR", - "cb9067c1c1": "El transporte de WebSocket no se está ejecutando", + "cb9067c1c1": "El transporte WebSocket no está en ejecución", "d714614dbf": "No se pudieron actualizar las interfaces de red", "ff865419dc": "Después de 30 minutos", "d4ba07d914": "Después de 5 minutos", "c474aa09d8": "Después de 1 minuto", - "aa1263e881": "Mantener en el tamaño del teléfono (predeterminado)", - "6436e56546": "Código QR para emparejamiento de móviles", + "aa1263e881": "Mantener en tamaño de teléfono (predeterminado)", + "6436e56546": "Código QR para emparejamiento móvil", "1b1b70279a": "Aún no hay dispositivos emparejados.", "1592afcc7a": "Aún no hay dispositivos emparejados. Escanea el código QR con la aplicación móvil de Orca." }, "MobileSettingsPane": { - "9a3c280e49": "Lanzamientos de GitHub", + "9a3c280e49": "GitHub Releases", "b0088412a1": "o el APK de Android desde", - "b5a2ed83ff": "Tienda de aplicaciones", - "c8491c17ef": "Controla Orca desde tu teléfono escaneando un código QR. Beta/vista previa anticipada: espere errores y cambios importantes. Obtenga la aplicación para iOS desde", + "b5a2ed83ff": "App Store", + "c8491c17ef": "Controla Orca desde tu teléfono escaneando un código QR. Beta/vista previa temprana: espera errores y cambios importantes. Obtén la app para iOS en", "e7a3ae8c4e": "Móvil", - "174f4a3c6d": "Controla terminales y agents desde tu teléfono.", + "174f4a3c6d": "Controla terminales y agentes desde tu teléfono.", "1de96ec8a6": "Mostrar botón de Orca Mobile", "682293cadf": "Mostrar el botón de Orca Mobile en la parte superior de la barra lateral izquierda.", "d4f2b65f30": "Mostrar el acceso directo de Orca Mobile en la barra lateral." }, "NotificationsPane": { "906b4afebf": "Enviar notificación de prueba", - "2772d2f257": "Omita las notificaciones cuando el árbol de trabajo desencadenante ya esté visible.", - "00cd406dbb": "Suprimir mientras estás concentrado", + "2772d2f257": "Omitir notificaciones cuando el worktree que las activó ya esté visible.", + "00cd406dbb": "Silenciar mientras está en foco", "2a42dd8d6f": "Volumen del sonido de notificación", - "4aa5085cd7": "Costumbre:", + "4aa5085cd7": "Personalizado:", "c258cb96dc": "Elige sonido de notificación", - "2a2033c388": "Elija la alerta que Orca reproduce cuando se entrega una notificación de escritorio.", + "2a2033c388": "Elige la alerta que Orca reproduce cuando se entrega una notificación de escritorio.", "88686e6ca8": "Sonido de notificación", "b6fc369244": "Un terminal en segundo plano emite un carácter de campana.", - "591fe605b9": "Campana terminal", - "55f901a59b": "Un agente de codificación finaliza y queda inactivo.", - "ca76d06fd2": "Tarea del Agent completada", + "591fe605b9": "Campana del terminal", + "55f901a59b": "Un agente de programación termina y queda inactivo.", + "ca76d06fd2": "Tarea del agente completada", "deff6d30da": "Notificaciones nativas del sistema para eventos en segundo plano.", "841c8c549f": "Habilitar notificaciones", "0fadad17ce": "No se pudo reproducir el sonido de notificación", - "406feb0aa6": "La notificación de prueba no fue entregada", + "406feb0aa6": "La notificación de prueba no se entregó", "6fc3781729": "Las notificaciones están deshabilitadas", - "4676a95bc3": "Verifique la configuración de notificaciones de su escritorio para Orca.", - "0cb93240b8": "El sistema no mostró la notificación.", + "4676a95bc3": "Revisa la configuración de notificaciones de tu escritorio para Orca.", + "0cb93240b8": "El sistema no mostró la notificación", "145227ca2b": "Abrir configuración", "d3d54e0915": "Notificación de prueba enviada", - "115437bc35": "Si no apareció ningún banner de macOS, habilite Permitir notificaciones para Orca.", + "115437bc35": "Si no apareció ningún banner de macOS, activa Permitir notificaciones para Orca.", "7f45542625": "Notificación de prueba solicitada", "98d70fb261": "No se pudo reproducir el sonido de notificación personalizado", - "c83b05a055": "Las notificaciones no son compatibles con este sistema.", + "c83b05a055": "Este sistema no admite notificaciones", "274af61bc0": "sistema", - "6e6df3a09a": "Elija un archivo personalizado", + "6e6df3a09a": "Elegir archivo personalizado", "76e02467b8": "Cambiar archivo personalizado", "d3756cf5bc": "desactivado" }, "OpenAiTranscriptionKeyDialog": { "fa83512e48": "Guardar clave", "07b26f2742": "Borrar clave", - "d246b2bdb3": "Las claves de tiempo de ejecución local se almacenan en ~/.orca utilizando el almacenamiento cifrado Electron cuando esté disponible.", + "d246b2bdb3": "Las claves del runtime local se almacenan en ~/.orca mediante el almacenamiento cifrado de Electron cuando está disponible.", "c3380e4ca5": "sk-...", "2f797018f0": "Clave API configurada", "16015322f9": "Clave API", - "07ed3e512e": "El audio se envía a OpenAI solo cuando se selecciona un modelo de voz OpenAI.", + "07ed3e512e": "El audio se envía a OpenAI solo cuando se selecciona un modelo de voz de OpenAI.", "439e91879e": "Transcripción OpenAI" }, "OpenAiTranscriptionSettingsRow": { @@ -5604,17 +5659,17 @@ "a622bc3b37": "Reemplazar clave", "3b0ab3fc0b": "Conectado", "27e0cb656d": "Transcripción OpenAI", - "893790e13b": "Agregue una clave API de OpenAI antes de seleccionar modelos de voz a texto en la nube.", - "b59b9b2b51": "Clave API configurada para modelos de voz a texto en la nube." + "893790e13b": "Agrega una clave API de OpenAI antes de seleccionar modelos de conversión de voz a texto en la nube.", + "b59b9b2b51": "Clave API configurada para modelos de conversión de voz a texto en la nube." }, "OpenInMenuSetting": { "03b00b1f64": "Aplicación personalizada", - "c1d817e027": "Agregado", + "c1d817e027": "Añadida", "e4064916aa": "Agregar aplicación", - "9d0413817d": "Elija las aplicaciones disponibles en el menú Abrir en de un espacio de trabajo.", + "9d0413817d": "Elige las aplicaciones disponibles en el menú Abrir en de un workspace.", "6ed52fe71e": "Abrir en aplicaciones", "eb55b87570": "El comando que escribirías en Terminal para abrir esta aplicación.", - "ba1422ee07": "comando terminal", + "ba1422ee07": "Comando de terminal", "e1fc0085c6": "Etiqueta de menú", "a261931d29": "Quitar aplicación", "af7d1c3656": "Editar aplicación", @@ -5625,37 +5680,37 @@ "f79084947b": "Nueva aplicación" }, "OrchestrationExamplesDialog": { - "3d1aa105e3": "Copiar mensaje", + "3d1aa105e3": "Copiar prompt", "9b4c004998": "Hecho", - "969cec9739": "Copiar mensaje de ejemplo {{value0}}", - "4e46da1889": "No se pudo copiar el mensaje.", - "80c6f2feb8": "Mensaje de ejemplo copiado." + "969cec9739": "Copiar prompt de ejemplo {{value0}}", + "4e46da1889": "No se pudo copiar el prompt.", + "80c6f2feb8": "Prompt de ejemplo copiado." }, "OrchestrationPane": { - "52e0634e2c": "Solicite a un agente coordinador que utilice la orquestación para traspasos, traspasos de árboles de trabajo y agents secundarios secuenciales o paralelos.", - "ae79504732": "como usarlo", + "52e0634e2c": "Pídele a un agente coordinador que use la orquestación para handoffs, handoffs de worktree y agentes secundarios secuenciales o paralelos.", + "ae79504732": "Cómo usarlo", "7bc082f4de": "Copiar comando de instalación", "832f1f3ee6": "¿Prefieres tu propia terminal?", - "9bedd2a6e5": "Permite a los agents traspasar contexto y coordinar el trabajo a través de Orca.", - "07641b9768": "Habilidad de orquestación", - "2aacdb0517": "Coordine a los agents de codificación en traspasos, traspasos de árboles de trabajo y trabajo de agents secundarios.", - "191ac34567": "Orquestación de Agents" + "9bedd2a6e5": "Permite a los agentes transferir contexto y coordinar el trabajo a través de Orca.", + "07641b9768": "Skill de orquestación", + "2aacdb0517": "Coordina agentes de programación en handoffs, handoffs de worktree y trabajo de agentes secundarios.", + "191ac34567": "Orquestación de agentes" }, "OrchestrationSetupCard": { - "e7d2a5146c": "Permite a los agents traspasar contexto y coordinar el trabajo a través de Orca.", - "2777ff0fdc": "Habilidad de orquestación" + "e7d2a5146c": "Permite a los agentes transferir contexto y coordinar el trabajo a través de Orca.", + "2777ff0fdc": "Skill de orquestación" }, "OrchestrationSkillAgentCoverage": { - "6dec5ce2d2": "Cobertura del Agent", - "ffe13e36fb": "Desaparecido", + "6dec5ce2d2": "Cobertura de agentes", + "ffe13e36fb": "Falta", "1e8f8d8fae": "Listo" }, "OrchestrationSkillPromptDialog": { - "f08d45293d": "comando copiar", + "f08d45293d": "Copiar comando", "35550f3b3b": "Hecho", - "1bdce1911e": "Copiar comando de instalación de habilidad de orquestación", - "b99f375eb2": "Ejecute este comando en una terminal para instalar la habilidad de orquestación para sus agents.", - "2914abcfa2": "Instalar habilidad de orquestación", + "1bdce1911e": "Copiar comando de instalación del skill de orquestación", + "b99f375eb2": "Ejecuta este comando en una terminal para instalar el skill de orquestación para tus agentes.", + "2914abcfa2": "Instalar skill de orquestación", "d3dc559225": "No se pudo copiar el comando de instalación.", "239bf9132b": "Comando de instalación copiado." }, @@ -5663,33 +5718,33 @@ "dc8404a930": "Crear archivo de diagnóstico", "a5acaffdb6": "Desechar", "aca2c8a367": "Enviar a soporte", - "798b6f0be5": "Abrir archivo para revisar", + "798b6f0be5": "Abrir archivo de revisión", "2ae9a6b63e": "Hecho", "7f14a1733c": "Eliminar archivo enviado", "2801d4ce22": "Copiar ID de referencia", - "d8be621237": "Abre primero el archivo para revisar.", + "d8be621237": "Abre primero el archivo de revisión.", "61676df223": "Diagnósticos enviados. Comparte este ID de referencia con soporte: {{value0}}.", - "fd7b3891af": "Abriste el archivo para revisar ({{value0}}). Envía ese archivo a soporte o descártalo.", - "62340d4439": "Tu archivo para revisar está listo ({{value0}}). Ábrelo para ver qué se enviaría y luego elige si quieres enviarlo a soporte.", - "19ec5e29b3": "Recopila actividad reciente y errores de la app en un archivo redactado que puedes revisar antes de enviarlo. Nada se sube hasta que elijas enviarlo." + "fd7b3891af": "Abriste el archivo de revisión ({{value0}}). Envía ese archivo a soporte o descártalo.", + "62340d4439": "Tu archivo de revisión está listo ({{value0}}). Ábrelo para ver qué se enviaría y luego elige si quieres enviarlo a soporte.", + "19ec5e29b3": "Recopila actividad reciente y errores de la app en un archivo depurado que puedes revisar antes de enviarlo. Nada se sube hasta que elijas enviarlo." }, "PrivacyDiagnosticsSection": { "af2fc82cde": "Enviar diagnósticos de la app a soporte", - "c18cbe45df": "Diagnósticos enviados eliminados", + "c18cbe45df": "Se eliminaron los diagnósticos enviados", "7a4944595b": "No se pudo copiar el ID de referencia", "13eb2c65a1": "ID de referencia copiado", - "860bca9ec9": "Archivo para revisar descartado", + "860bca9ec9": "Archivo de revisión descartado", "49fc6c80e8": "Diagnósticos enviados", - "db3228e01a": "Archivo para revisar abierto", - "a2b3505c77": "Archivo para revisar creado" + "db3228e01a": "Archivo de revisión abierto", + "a2b3505c77": "Archivo de revisión creado" }, "PrivacyPane": { - "36e0e2e63b": "variable de entorno. Desconfigúrelo y reinicie para volver a habilitarlo.", + "36e0e2e63b": "variable de entorno. Quítala y reinicia para volver a habilitarla.", "79a0f3c16c": "La telemetría está desactivada por el", - "e3970bbbf5": "La telemetría está deshabilitada porque se establece una variable de entorno de CI. Desactívalo y reinicia.", + "e3970bbbf5": "La telemetría está desactivada porque hay una variable de entorno de CI configurada. Quítala y reinicia.", "fe904ac984": "Compartir datos de uso anónimos", "77410e0566": "Política de privacidad", - "8bfdd23a88": "Ayúdanos a descubrir qué construir a continuación. Orca envía recuentos anónimos de qué funciones utiliza y dónde fallan las cosas.", + "8bfdd23a88": "Ayúdanos a descubrir qué crear a continuación. Orca envía recuentos anónimos de qué funciones usas y dónde fallan las cosas.", "afec8b03be": "ci" }, "QuickCommandsPane": { @@ -5698,146 +5753,146 @@ "8c877dec41": "Global", "c6b155911b": "Todos los comandos", "5aacc8f7dc": "Agregar comando", - "c36912efd5": "Ejecútelos desde el botón Comandos rápidos en la barra de pestañas, o haga clic derecho dentro de cualquier terminal.", + "c36912efd5": "Ejecútalos desde el botón Comandos rápidos en la barra de pestañas o haz clic derecho dentro de cualquier terminal.", "f91b649324": "Comandos guardados", - "3d9dc558e8": "Este comando rápido se eliminará de su lista guardada.", + "3d9dc558e8": "Este comando rápido se eliminará de tu lista guardada.", "3edf3deaf8": "¿Eliminar \"{{value0}}\"?", "9fcfc29519": "Insertar", - "9b3e338d62": "Ingresar", + "9b3e338d62": "Enter", "4ccc63da87": "Agente", "0252ddd578": "Sin texto de comando", - "7784912ed6": "repo", - "2bb9e38e93": "Intitulado", + "7784912ed6": "repositorio", + "2bb9e38e93": "Sin título", "3eb9897ab0": "No hay comandos en los ámbitos seleccionados.", - "38d61927e6": "No se guardaron comandos rápidos.", + "38d61927e6": "No hay comandos rápidos guardados.", "44923dd982": "destructivo", - "ec1ed99e70": "Borrar", + "ec1ed99e70": "Eliminar", "d1d0976320": "Ninguno" }, "RecentTabOrderControl": { - "3b17c81ede": "Orden de tiras de pestañas", + "3b17c81ede": "Orden de la barra de pestañas", "6e6a3fcc61": "Más reciente", - "7a546f2309": "Orden de tabulación", - "a867a0889f": "Tira reciente o de pestañas." + "7a546f2309": "Orden de pestañas", + "a867a0889f": "Reciente o por barra de pestañas." }, "RepositoryHooksSection": { "af49e2a19e": "El archivo está presente, pero Orca no pudo encontrar definiciones válidas de `scripts` o `issueCommand`.", - "3397879bee": "y existen comandos locales, elija cuál ejecutar.", + "3397879bee": "y hay comandos locales, elige cuáles se ejecutan.", "39da2ae12f": "orca.yaml", - "ac9038d2cc": "cuando ambos", - "32fec28f5b": "Fuente de comando", - "bbbd6e0bc4": "Fuente de comando y orca.yaml", + "ac9038d2cc": "Cuando ambos", + "32fec28f5b": "Origen de comandos", + "bbbd6e0bc4": "Origen de comandos y orca.yaml", "c9bc1bfd8f": "Avanzado", - "610d90fdbd": "Fuente del comando y detalles de orca.yaml.", - "52aef29e69": "Déjelo en blanco para usar el repo predeterminado desde", - "4084720f47": "Completo {{artifact_url}}", - "13394103bd": "Comando de problema personalizado de GitHub", - "70ad20f883": "para el problema vinculado o la URL de PR.", - "b997331366": "Anulación opcional. Usar", - "2cc27dc12b": "Anulación opcional por usuario para el comando de problema vinculado.", - "b91a0f297d": "Scripts locales y compartidos que se ejecutan antes de archivar un árbol de trabajo.", - "9a100323ff": "Guión de archivo", - "21fb607a87": "Comportamiento predeterminado cuando se crea un nuevo árbol de trabajo.", - "793dcee97d": "cuando correr", - "63e1783173": "Elija el comportamiento predeterminado cuando haya un script de configuración disponible.", + "610d90fdbd": "Origen de comandos y detalles de orca.yaml.", + "52aef29e69": "Déjalo en blanco para usar el valor predeterminado del repositorio desde", + "4084720f47": "Completar {{artifact_url}}", + "13394103bd": "Comando personalizado de Issue de GitHub", + "70ad20f883": "para la URL del Issue o PR vinculado.", + "b997331366": "Anulación opcional. Usa", + "2cc27dc12b": "Anulación opcional por usuario para el comando de Issue vinculado.", + "b91a0f297d": "Scripts locales y compartidos que se ejecutan antes de archivar un worktree.", + "9a100323ff": "Script de archivado", + "21fb607a87": "Comportamiento predeterminado cuando se crea un nuevo worktree.", + "793dcee97d": "Cuándo ejecutar", + "63e1783173": "Elige el comportamiento predeterminado cuando haya un script de configuración disponible.", "fb6bebcf7e": "Cuándo ejecutar la configuración", - "30d555acd2": "Scripts locales y compartidos que se ejecutan después de crear un nuevo árbol de trabajo.", - "52b31baf02": "Guión de configuración", - "8567127a40": "Scripts que se ejecutan cuando se crean o archivan árboles de trabajo. Los scripts locales se almacenan en esta máquina; Los scripts `orca.yaml` se comparten con tu equipo.", - "ff082fe7c6": "Ganchos para árbol de trabajo", - "5d940bde5c": "Agregar secuencia de comandos local", + "30d555acd2": "Scripts locales y compartidos que se ejecutan después de crear un nuevo worktree.", + "52b31baf02": "Script de configuración", + "8567127a40": "Scripts que se ejecutan cuando se crean o archivan worktrees. Los scripts locales se almacenan en esta máquina; los scripts de `orca.yaml` se comparten con tu equipo.", + "ff082fe7c6": "Hooks de worktree", + "5d940bde5c": "Agregar script local", "8c2893fae0": "Se ejecuta como un script de shell único. Guardado en esta máquina.", - "40a446ae16": "- sólo para ti, en esta máquina", + "40a446ae16": "- solo para ti, en esta máquina", "2d03a514db": "local", "7e4427b4a2": "para cambiar.", "b113344b6a": "Editar", "f828e1de19": "- compartido con tu equipo", - "673a7fd10e": "De cheques...", + "673a7fd10e": "Comprobando...", "5426ecbdcb": "Los scripts locales no se ejecutarán", - "b2b06c7ce8": "Variables de entorno disponibles (pase el cursor para obtener más detalles):", + "b2b06c7ce8": "Variables de entorno disponibles (pasa el cursor para ver más detalles):", "95a0411b3e": "plantilla", "175daba180": "Ejemplo", - "b20c5df6ca": "Agregue un archivo `orca.yaml` para habilitar los valores predeterminados de configuración, archivo o automatización de problemas compartidos para este repo. Plantilla de ejemplo:", - "56f9a4a1d0": "Using `orca.yaml`", - "623e0c9f31": "`orca.yaml` could not be parsed", - "5a67e4793d": "No `orca.yaml` detected", - "07ba35bc68": "Check the indentation under `scripts:`. Hook keys should use two spaces, and command lines should use four.", - "787ca433ef": "Define only the supported keys: `scripts`, `setup`, `archive`, and `issueCommand`.", - "ecc73d9125": "Compare your file against the working template below and copy that shape if needed.", + "b20c5df6ca": "Agrega un archivo `orca.yaml` para habilitar valores predeterminados compartidos de configuración, archivado o automatización de Issues para este repositorio. Plantilla de ejemplo:", + "56f9a4a1d0": "Usando `orca.yaml`", + "623e0c9f31": "No se pudo analizar `orca.yaml`", + "5a67e4793d": "No se detectó `orca.yaml`", + "07ba35bc68": "Revisa la indentación bajo `scripts:`. Las claves de hooks deben usar dos espacios y las líneas de comandos deben usar cuatro.", + "787ca433ef": "Define solo las claves admitidas: `scripts`, `setup`, `archive` e `issueCommand`.", + "ecc73d9125": "Compara tu archivo con la plantilla funcional de abajo y copia esa estructura si hace falta.", "925f9e0dc4": "texto-primer plano", - "0cc712b823": "El archivo de configuración principal existe en la raíz del repo, pero Orca aún no pudo analizar las definiciones de gancho admitidas.", + "0cc712b823": "El archivo de configuración principal existe en la raíz del repositorio, pero Orca aún no pudo analizar las definiciones de hooks admitidas.", "c90b858573": "texto-ámbar-700 oscuro:texto-ámbar-300", "aba825233f": "El archivo contiene claves de configuración que esta versión de Orca no reconoce. Es posible que necesites actualizar Orca o revisar el archivo en busca de errores tipográficos.", - "ca424ff135": "Los valores predeterminados de automatización de problemas y gancho compartido se definen en el repo y están disponibles para todos los que lo usan.", + "ca424ff135": "Los valores predeterminados compartidos de hooks y automatización de Issues están definidos en el repositorio y disponibles para todos los que lo usan.", "32f417fe17": "texto-esmeralda-700 oscuro:texto-esmeralda-300", "8bfe65fc60": "Usar comandos locales", - "8d6c56bff8": "Ejecute ambos", - "0fa21e19ec": "Nombre del espacio de trabajo, normalmente basado en el nombre de la sucursal.", - "54c73d88d0": "Ruta al árbol de trabajo que se está creando. Los comandos de configuración se ejecutan desde este directorio.", - "30952c4aa4": "Ruta al pago del repo principal. Útil para copiar archivos compartidos, como .env, en un árbol de trabajo.", + "8d6c56bff8": "Ejecutar ambos", + "0fa21e19ec": "Nombre del workspace, normalmente basado en el nombre de la rama.", + "54c73d88d0": "Ruta al worktree que se está creando. Los comandos de configuración se ejecutan desde este directorio.", + "30952c4aa4": "Ruta al checkout principal del repositorio. Útil para copiar archivos compartidos, como .env, a un worktree.", "9b821fa19d": "# p.ej. echo \"Limpiando $ORCA_WORKSPACE_NAME\"", - "6f90ebe3fd": "Se ejecuta antes de archivar o eliminar un árbol de trabajo.", - "a3fc966677": "# p.ej. pnpm instalar cp \"$ORCA_ROOT_PATH/.env\" \"$ORCA_WORKTREE_PATH/.env\"", - "f0710e1c83": "Se ejecuta después de que se crea un nuevo árbol de trabajo; instalar departamentos, copiar archivos env, ejecutar migraciones.", + "6f90ebe3fd": "Se ejecuta antes de archivar o eliminar un worktree.", + "a3fc966677": "# p. ej. pnpm install cp \"$ORCA_ROOT_PATH/.env\" \"$ORCA_WORKTREE_PATH/.env\"", + "f0710e1c83": "Se ejecuta después de que se crea un nuevo worktree; instalar dependencias, copiar archivos de entorno, ejecutar migraciones.", "8561b0665f": "orca.yaml primero, luego tus comandos locales.", - "0e8b2a520d": "Ignorar orca.yaml; ejecute solo sus comandos locales.", - "83dc78202a": "Sólo locales", - "29397e8bbc": "Ejecute solo comandos de repo comprometidos; ignorar los comandos locales.", + "0e8b2a520d": "Ignorar orca.yaml; ejecutar solo tus comandos locales.", + "83dc78202a": "Solo local", + "29397e8bbc": "Ejecutar solo comandos confirmados en el repositorio; ignorar comandos locales.", "d88b6ff88f": "orca.yaml solamente", - "99e3264a49": "Ejecute la configuración solo cuando lo elija.", + "99e3264a49": "Ejecutar la configuración solo cuando lo elijas.", "15debc1fd9": "Saltar por defecto", - "022ba10cf2": "Ejecute la configuración automáticamente.", + "022ba10cf2": "Ejecutar la configuración automáticamente.", "d3ef1ab247": "Ejecutar por defecto", "90b1f50137": "Preguntar antes de ejecutar la instalación.", - "e03d9a8f38": "pregunta cada vez", + "e03d9a8f38": "Preguntar cada vez", "8dbe6bedf5": "inválido", "0e0dd5b9a5": "cargado", "9b12f15b1e": "cuando uno existe.", "c85c2c88a2": "{{artifact_url}}", - "fac13f8c1e": "autorizado", + "fac13f8c1e": "autoritativo", "0518758f38": "ambos", "d2b3016c20": "compartido", "4611b78617": "fuente de comando", "c5a55a2d2e": "avanzado", "b5e3e77e89": "acción", - "0ce113fd7b": "Los scripts locales se guardan, pero el origen del script está configurado únicamente en orca.yaml.", - "7f78e5eea6": "Se guardan los scripts locales. Orca todavía está revisando orca.yaml antes de poder recomendar qué fuente de script usar.", + "0ce113fd7b": "Los scripts locales están guardados, pero el origen de scripts está configurado solo en orca.yaml.", + "7f78e5eea6": "Los scripts locales están guardados. Orca sigue revisando orca.yaml antes de poder recomendar qué origen de scripts usar.", "2b6356e744": "Guardado", - "81057d5f71": "Ahorro...", + "81057d5f71": "Guardando...", "da37d6f10e": "Copiar", - "3149964b66": "copiado", - "waitForSetupBeforeAgent": "Espere a que se complete la configuración antes de iniciar el agent", - "waitForSetupBeforeAgentHelp": "Active esto cuando el programa de instalación instale dependencias, servidores MCP o archivos de configuración que el agent necesite durante el inicio." + "3149964b66": "Copiado", + "waitForSetupBeforeAgent": "Espera a que se complete la configuración antes de iniciar el agente", + "waitForSetupBeforeAgentHelp": "Activa esto cuando la configuración instale dependencias, servidores MCP o archivos de configuración que el agente necesite durante el inicio." }, "RepositoryIconPicker": { - "2b7d27b93c": "Utilice el color del repo {{value0}}", + "2b7d27b93c": "Usar {{value0}} como color del repositorio", "fde066a63b": "Las cargas PNG deben tener un tamaño de 256 KB o menos.", - "cc1286e263": "favicon", + "cc1286e263": "Favicon", "03ca1a4e9b": "ejemplo.com", "381b4844fd": "Subir PNG", - "7da623abcc": "Se utiliza de forma predeterminada: GitHub siempre proporciona una, incluso cuando el propietario no ha configurado una imagen personalizada.", + "7da623abcc": "Se usa de forma predeterminada: GitHub siempre proporciona uno, incluso cuando el propietario no configuró una imagen personalizada.", "39da8a10bf": "Usar avatar de GitHub", - "c490787d24": "emojis", + "c490787d24": "Emoji", "b2d7fd2116": "Icono", "2d8bd302fa": "Avatar", - "913c55833d": "Color de repo personalizado {{value0}}", - "0e5f0693c1": "Elija un color de repo personalizado", + "913c55833d": "Color de repositorio personalizado {{value0}}", + "0e5f0693c1": "Elige un color de repositorio personalizado", "642dc29c6d": "Color", - "549d126081": "Reiniciar", - "4e2a14f967": "Icono de Repo", - "d71df44587": "No se pudo resolver el repo de GitHub.", - "f79972271a": "No se encontró ningún control remoto de GitHub para este repo.", - "4d039317f4": "favicon del sitio web", - "acf31559a0": "Ingrese una URL de sitio web válida.", - "868c5c9b56": "No se pudo importar el icono del repo" + "549d126081": "Restablecer", + "4e2a14f967": "Icono del repositorio", + "d71df44587": "No se pudo resolver el repositorio de GitHub.", + "f79972271a": "No se encontró ningún control remoto de GitHub para este repositorio.", + "4d039317f4": "Favicon del sitio web", + "acf31559a0": "Ingresa una URL de sitio web válida.", + "868c5c9b56": "No se pudo importar el icono del repositorio" }, "RepositoryPane": { "15a99d9b9f": "Las rutas relativas se resuelven desde la raíz de este proyecto.", "8ccacbeb5a": "Usar global", - "e9bd57a336": "Ubicación del árbol de trabajo", - "e63bb96a9b": "Directorio específico del proyecto para nuevos árboles de trabajo.", - "f88db4fece": "Base de árbol de trabajo predeterminada", - "8984d06520": "Rama base o referencia predeterminada al crear árboles de trabajo.", + "e9bd57a336": "Ubicación del worktree", + "e63bb96a9b": "Directorio específico del proyecto para nuevos worktrees.", + "f88db4fece": "Base de worktree predeterminada", + "8984d06520": "Rama o ref base predeterminada al crear worktrees.", "e641c359de": "Ícono y color del proyecto utilizados en la barra lateral y las pestañas.", "26fef02bf3": "Icono de proyecto", "c7ef4415de": "Nombre para mostrar", @@ -5847,56 +5902,56 @@ "ee5a290616": "Abierto como carpeta. Las funciones de Git no están disponibles para este espacio de trabajo.", "323debba71": "Tipo:", "499a437335": "Identidad", - "availableHosts": "Available Hosts", - "availableHostsDescription": "Hosts where this project is set up.", - "availableHostsHelp": "Project paths and worktree settings are host-specific; creating a workspace can target any ready setup.", - "viewingHost": "Viewing host", - "currentSetup": "Current", - "hostSetupStateReady": "Ready", - "hostSetupStateNotSetUp": "Not set up", - "hostSetupStateSettingUp": "Setting up", + "availableHosts": "Hosts disponibles", + "availableHostsDescription": "Hosts donde está configurado este proyecto.", + "availableHostsHelp": "Las rutas del proyecto y los ajustes de worktree son específicos de cada host; al crear un workspace puedes usar cualquier configuración lista.", + "viewingHost": "Viendo host", + "currentSetup": "Actual", + "hostSetupStateReady": "Listo", + "hostSetupStateNotSetUp": "Sin configurar", + "hostSetupStateSettingUp": "Configurando", "hostSetupStateError": "Error", - "hostSetupStateUnsupported": "Unsupported", - "setupPathPending": "Path pending", + "hostSetupStateUnsupported": "No compatible", + "setupPathPending": "Ruta pendiente", "openSetup": "Abrir", - "removeSetup": "Remove", - "hostSetupBlockedVersion": "Orca server version is incompatible", - "hostSetupMissingCapability": "Update Orca on this host to set up projects", - "setupProjectOnHost": "Set up on another host", - "setupProjectOnHostHelp": "Choose a host, then import an existing checkout, clone the repository there, or track a setup that will be provisioned later.", + "removeSetup": "Eliminar", + "hostSetupBlockedVersion": "La versión del servidor Orca no es compatible", + "hostSetupMissingCapability": "Actualiza Orca en este host para configurar proyectos", + "setupProjectOnHost": "Configurar en otro host", + "setupProjectOnHostHelp": "Elige un host y luego importa un checkout existente, clona el repositorio allí o registra una configuración que se aprovisionará más tarde.", "setupExistingFolder": "Importar carpeta existente", - "setupExistingFolderHelp": "Make this project available on another host by linking a checkout that already exists there.", - "setupExistingFolderPathPlaceholder": "/path/to/project/on/host", - "cloneUrlPlaceholder": "Repository URL", - "cloneDestinationPlaceholder": "/destination/on/host", + "setupExistingFolderHelp": "Haz que este proyecto esté disponible en otro host vinculando un checkout que ya existe allí.", + "setupExistingFolderPathPlaceholder": "/ruta/al/proyecto/en/host", + "cloneUrlPlaceholder": "URL del repositorio", + "cloneDestinationPlaceholder": "/destino/en/host", "setupKindGit": "Repositorio Git", "setupKindFolder": "Carpeta", "settingUpHost": "Importando...", "setupHost": "Importar", "cloningHost": "Clonando...", "cloneHost": "Clonar", - "creatingPendingSetup": "Creating...", - "createPendingSetup": "Track setup", - "hostSetupCheckingCapability": "Checking host capabilities", - "hostAvailability": "Host availability", - "hostAvailabilityHelp": "Add this same project on another connected host.", - "addToAnotherHost": "Add to another host", - "addProjectHost": "Add project to host", - "addProjectHostHelp": "Choose where this project should also be available.", + "creatingPendingSetup": "Creando...", + "createPendingSetup": "Registrar configuración", + "hostSetupCheckingCapability": "Comprobando capacidades del host", + "hostAvailability": "Disponibilidad del host", + "hostAvailabilityHelp": "Agrega este mismo proyecto en otro host conectado.", + "addToAnotherHost": "Agregar a otro host", + "addProjectHost": "Agregar proyecto al host", + "addProjectHostHelp": "Elige dónde también debería estar disponible este proyecto.", "closeHostSetup": "Cerrar", "setupHostLabel": "Host", - "browseFolder": "Browse folder", - "browseFolderHelp": "Use an existing checkout or folder on this host.", - "otherWaysToAdd": "Other ways to add", - "cloneFromUrl": "Clone from URL", - "cloneFromUrlHelp": "Clone this repository onto the selected host.", - "addPlannedHost": "Add host placeholder", - "addPlannedHostHelp": "Remember this host and finish adding the project later.", - "existingFolder": "Existing folder", - "addPlannedHostToHost": "Add {{host}}", - "addPlannedHostConfirm": "This only records that the project should be available on this host. You can add the folder or clone later.", - "projectRuntime": "Project Runtime", - "projectRuntimeDescription": "Choose whether this project runs on Windows or WSL." + "browseFolder": "Explorar carpeta", + "browseFolderHelp": "Usa un checkout o una carpeta existente en este host.", + "otherWaysToAdd": "Otras formas de agregar", + "cloneFromUrl": "Clonar desde URL", + "cloneFromUrlHelp": "Clona este repositorio en el host seleccionado.", + "addPlannedHost": "Agregar placeholder de host", + "addPlannedHostHelp": "Recuerda este host y termina de agregar el proyecto más tarde.", + "existingFolder": "Carpeta existente", + "addPlannedHostToHost": "Agregar {{host}}", + "addPlannedHostConfirm": "Esto solo registra que el proyecto debería estar disponible en este host. Puedes agregar la carpeta o clonar más tarde.", + "projectRuntime": "Runtime del proyecto", + "projectRuntimeDescription": "Elige si este proyecto se ejecuta en Windows o WSL." }, "RepositorySourceControlAiActionRows": { "548a6e1281": "Plantilla de comando", @@ -5911,38 +5966,38 @@ "RepositorySourceControlAiCustomCommand": { "0704dd55cd": "Comando de repositorio", "e56668c291": "Usar global", - "fbb77e122a": "Reserva de Repo para acciones de texto que seleccionan el comando personalizado.", + "fbb77e122a": "Fallback del repositorio para acciones de texto que seleccionan Comando personalizado.", "ebffc5a28c": "Comando personalizado", - "f9941f0caf": "p.ej. ollama ejecuta llama3.1 {rápido}" + "f9941f0caf": "p. ej., ollama run llama3.1 {prompt}" }, "RepositorySourceControlAiEnablement": { - "84233d1bb3": "Apagado", - "bea897eec2": "En", + "84233d1bb3": "Desactivado", + "bea897eec2": "Activado", "62511a575d": "Usar global", "30ae6dcce8": "El valor predeterminado global es", - "cf5959c834": "Control de fuente AI habilitado", + "cf5959c834": "Source Control AI activado", "show": "Mostrar", "hide": "Ocultar", - "showActionsLabel": "Mostrar acciones de IA de control de código fuente", - "visibilityHelper": "Controla si los botones de IA de control de código fuente se muestran para este repositorio. La generación usada por funciones separadas sigue la configuración de esas funciones. El valor predeterminado global es {{value0}}." + "showActionsLabel": "Mostrar acciones de Source Control AI", + "visibilityHelper": "Controla si se muestran los botones de Source Control AI para este repositorio. La generación usada por funciones separadas sigue los ajustes de esas funciones. El valor predeterminado global es {{value0}}." }, "RepositorySourceControlAiHostedReviewDefaults": { - "053ccfbf52": "Apagado", - "777443bf89": "En", + "053ccfbf52": "Desactivado", + "777443bf89": "Activado", "ffc3b26b26": "Usar global", "a68849a859": "El valor predeterminado global es", - "aa6ee4b7d6": "Valores predeterminados de creación de reseñas alojadas", - "629ed8a9d3": "Abrir revisión alojada después de la creación", + "aa6ee4b7d6": "Valores predeterminados para crear revisiones alojadas", + "629ed8a9d3": "Abrir la revisión alojada después de crearla", "14f1eb99d0": "Generar detalles al abrir Crear PR", - "d32b87e754": "Utilice la plantilla de revisión cuando esté disponible", - "981eae7e14": "Borrador por defecto" + "d32b87e754": "Usar la plantilla de revisión cuando esté disponible", + "981eae7e14": "Borrador de forma predeterminada" }, "RepositorySourceControlAiSection": { - "67b3ff5467": "Desechar", - "8b8bc5913a": "Recetas de acción del repositorio. La configuración global se utiliza hasta que este repositorio la personalice.", - "71b003b62b": "IA de control de fuente", - "152268c295": "Ahorrar", - "57e6e9d4b1": "Ahorro...", + "67b3ff5467": "Descartar", + "8b8bc5913a": "Recetas de acción del repositorio. Se usa la configuración global hasta que este repositorio las personaliza.", + "71b003b62b": "Source Control AI", + "152268c295": "Guardar", + "57e6e9d4b1": "Guardando...", "ccb07dd027": "Guardado", "e57dde9d93": "Cambios no guardados" }, @@ -5950,12 +6005,12 @@ "8b82879581": "Cualquier persona con una concesión activa puede conectarse hasta que la revoques. Revocar el acceso compartido desconecta a los clientes activos inmediatamente.", "68ec21309f": "Revocar acceso", "6f6d5188ed": "Revocar {{value0}}", - "87b16cd11d": "Creado", + "87b16cd11d": "Creado ", "434e4a6af6": "Enlace actual", - "fd83b94095": "Aún no hay acceso al servidor compartido.", + "fd83b94095": "Aún no hay acceso compartido al servidor.", "27cf8507ad": "Actualizar acceso compartido", - "f031182867": "Acceso al servidor compartido", - "df142657a5": "Aún no usado", + "f031182867": "Acceso compartido al servidor", + "df142657a5": "Aún no se ha usado", "b18d1764ef": "Usado por última vez {{value0}}" }, "RuntimeEnvironmentsPane": { @@ -5984,20 +6039,20 @@ "64b6bea541": "Servidor activo", "99ac81fb43": "Cambiado a {{value0}}.", "b5b5114cb0": "Se eliminó {{value0}}.", - "6cb6eae14f": "No se pudo guardar el entorno de ejecución.", + "6cb6eae14f": "No se pudo guardar el runtime.", "7b5986c8df": "Se guardó {{value0}}. Usa Servidor activo para cambiar cuando esté listo.", "a5b58465b6": "Conectado a {{value0}}.", "5ef712f407": "Ya existe un servidor llamado \"{{value0}}\".", "0c55a47480": "Se requieren nombre y código de emparejamiento.", - "e6410d72c3": "No se pudieron cargar los entornos de ejecución.", + "e6410d72c3": "No se pudieron cargar los runtimes.", "6ef71985da": "Sin punto final", "ed3e3f069d": "Esto elimina el servidor guardado de Orca. No cambia el servidor activo.", "b2fda48c39": "Al quitar el servidor activo, este navegador se desconecta de ese host. Las sesiones existentes del host se conservan.", "9f7665a01b": "Al quitar el servidor activo, Orca primero vuelve al Escritorio local. Las sesiones existentes del host se conservan.", "3595fd1948": "Nuevo enlace", "54dee18f5c": "Ocultar formulario", - "8cf8790697": "Los servidores guardados enrutan este navegador a través de un entorno de ejecución de Orca emparejado.", - "f75ce1c7a5": "Local mantiene el comportamiento actual del escritorio. Los servidores guardados enrutan las llamadas de cliente compatibles a través del entorno de ejecución remoto.", + "8cf8790697": "Los servidores guardados enrutan este navegador a través de un runtime de Orca emparejado.", + "f75ce1c7a5": "Local mantiene el comportamiento actual del escritorio. Los servidores guardados enrutan las llamadas de cliente compatibles a través del runtime remoto.", "d25f0688b1": "Eliminar", "f3a3d6d834": "Capacidades de {{value0}}", "0ef838094a": "Protocolo {{value0}}", @@ -6007,47 +6062,47 @@ "c8791efc45": "Estado no disponible", "5120beaac6": "Comprobando…", "4b5c6d7e8f": "No se informaron capacidades", - "hostModelCapabilityUnknown": "Compatibilidad con el modelo del host: comprobando las capacidades del servidor", - "hostModelCapabilitySupported": "Compatibilidad con el modelo del host: lista", - "hostModelCapabilityMissing": "Compatibilidad con el modelo del host: actualiza el servidor para {{value0}}", + "hostModelCapabilityUnknown": "Soporte de modelo de host: comprobando capacidades del servidor", + "hostModelCapabilitySupported": "Soporte de modelo de host: listo", + "hostModelCapabilityMissing": "Soporte de modelo de host: actualiza el servidor para {{value0}}", "hostModelCapabilityProjectSetup": "configuración del proyecto", - "hostModelCapabilityTaskSourceContext": "contexto de origen de la tarea", - "hostModelCapabilityWorkspaceRunContext": "contexto de ejecución del espacio de trabajo", - "3f67e8078a": "Usa este ordenador de forma predeterminada. Elige un servidor guardado solo cuando quieras que los proyectos, archivos, terminales, comprobaciones de proveedores y traspaso entre navegador/móvil compatibles pasen por ese servidor.", - "2c85efb3e8": "Al seleccionar un servidor guardado, este navegador usará ese entorno de ejecución de Orca emparejado como host predeterminado.", + "hostModelCapabilityTaskSourceContext": "contexto de origen de tareas", + "hostModelCapabilityWorkspaceRunContext": "contexto de ejecución del workspace", + "3f67e8078a": "Usa esta computadora de forma predeterminada. Elige un servidor guardado solo cuando quieras que los proyectos, archivos, terminales, checks de proveedores y traspaso entre navegador/móvil compatibles pasen por ese servidor.", + "2c85efb3e8": "Al seleccionar un servidor guardado, este navegador usará ese runtime de Orca emparejado como host predeterminado.", "serverConnected": "Conectado", "serverChecking": "Comprobando…", "serverDisconnected": "Desconectado", "disconnectedServer": "Desconectado de {{value0}}.", "connectToRemoteServers": "Conectarse a servidores remotos", - "connectToRemoteServersHelp": "Empareja otro entorno de ejecución de Orca y luego conéctalo o desconéctalo aquí. Usa Avanzado > Servidor activo solo cuando quieras cambiar el host predeterminado.", - "activeServerRowHelp": "Servidor activo para proyectos, terminales y comprobaciones de proveedores enrutados por servidor.", + "connectToRemoteServersHelp": "Empareja otro runtime de Orca y luego conéctalo o desconéctalo aquí. Usa Avanzado > Servidor activo solo cuando quieras cambiar el host predeterminado.", + "activeServerRowHelp": "Servidor activo para proyectos, terminales y checks de proveedores enrutados por servidor.", "disconnect": "Desconectar", "connect": "Conectar", "advanced": "Avanzado", "serverDetails": "Detalles del servidor", "advertiseThisApp": "Anunciar esta app como servidor", - "advertiseThisAppHelp": "Crea enlaces de acceso para que navegadores, clientes móviles u otro cliente de Orca se conecten de vuelta a esta app en ejecución.", - "runtimeReachable": "{{value0}} está disponible." + "advertiseThisAppHelp": "Crea enlaces de acceso para que navegadores, clientes móviles u otro cliente de Orca se conecten a esta app en ejecución.", + "runtimeReachable": "{{value0}} está accesible." }, "RuntimePairingGeneratedUrlRows": { "0495f68959": "Copiar {{value0}}" }, "RuntimePairingUrlGenerator": { - "849825e829": "Pegue esta URL de emparejamiento en otro cliente Orca.", - "2e5c4e3c93": "Emparejar otro cliente Orca", + "849825e829": "Pega esta URL de emparejamiento en otro cliente de Orca.", + "2e5c4e3c93": "Emparejar otro cliente de Orca", "f7cafdc9f3": "El enlace del navegador no está disponible en esta compilación. La URL de emparejamiento todavía funciona para los clientes de Orca.", "6b9ca3e69b": "Abrir en el navegador", - "1ca2e5194d": "Utilice esta URL desde un navegador que pueda llegar a la dirección seleccionada.", + "1ca2e5194d": "Usa esta URL desde un navegador que pueda llegar a la dirección seleccionada.", "8de0f84fff": "Generar enlace de acceso", - "279e0dcb57": "127.0.0.1 solo funciona en esta computadora. Utilice una LAN, Tailscale o una dirección personalizada para otro dispositivo.", + "279e0dcb57": "127.0.0.1 solo funciona en esta computadora. Usa una LAN, Tailscale o una dirección personalizada para otro dispositivo.", "45cf476df3": "host, host:puerto o wss://host/ruta", "4531ea3158": "Dirección personalizada", "360c548cf3": "Actualizar direcciones de conexión", "de6d5cff95": "Esta computadora (", "de77eb1b65": "Dirección de conexión", - "ff80904fc4": "Cree una concesión de acceso revocable para clientes de navegador o de escritorio.", - "f8500e134a": "Comparte este servidor Orca", + "ff80904fc4": "Crea una concesión de acceso revocable para clientes de navegador o escritorio.", + "f8500e134a": "Compartir este servidor Orca", "d6c081adf4": "No se pudo copiar la URL.", "df0aa45a86": "URL de emparejamiento copiada.", "13704d635e": "URL del cliente web copiada.", @@ -6057,7 +6112,7 @@ "2ed55c841a": "No se pudo generar la URL de emparejamiento.", "11d5248e62": "URL de emparejamiento generada.", "6dd594a507": "URL del cliente web generada.", - "2752126f3e": "El emparejamiento en tiempo de ejecución no está disponible.", + "2752126f3e": "El emparejamiento de runtime no está disponible.", "95b8be4cea": "No se pudieron actualizar las interfaces de red.", "1b4e0bbcc5": "No se pudieron cargar las concesiones de acceso compartido.", "b91e36a986": "web", @@ -6071,20 +6126,20 @@ }, "Settings": { "3bf149e873": "Configuración del proyecto > {{value0}}", - "075341c763": "Nuevas características que aún están tomando forma. Pruébalos.", + "075341c763": "Nuevas funciones que aún están tomando forma. Pruébalas.", "8b017f2506": "Experimental", - "499c1cd7f9": "Configuraciones de compatibilidad de bajo nivel para solucionar problemas.", + "499c1cd7f9": "Ajustes de compatibilidad de bajo nivel para solucionar problemas.", "1c87f8d024": "Avanzado", "c1b43dc4e2": "Datos de uso anónimos y controles de telemetría.", "d7e3f62d70": "Privacidad y telemetría", "9b83cc62c2": "Acceso a la privacidad de macOS para herramientas de desarrollo lanzadas en terminales.", "65660d4548": "Permisos de macOS", - "c6c01ac209": "Controla terminales y agents desde tu teléfono.", + "c6c01ac209": "Controla terminales y agentes desde tu teléfono.", "c40dadaac8": "Móvil", "c2ee313198": "Usa máquinas existentes por SSH para archivos, terminales, Git y espacios de trabajo.", - "9b02492d1f": "Anfitriones SSH", - "b5ee17826b": "Pair remote Orca runtimes for persistent sessions, richer remote state, and web or mobile handoff.", - "7686cb5c36": "Conecte este navegador a un servidor Orca guardado.", + "9b02492d1f": "Hosts SSH", + "b5ee17826b": "Empareja runtimes remotos de Orca para sesiones persistentes, estado remoto más completo y traspaso web o móvil.", + "7686cb5c36": "Conecta este navegador a un servidor Orca guardado.", "bd0181eeca": "Servidores remotos de Orca", "8acf3f22e0": "Estadísticas de Orca más análisis de uso de Claude, Codex y OpenCode.", "954a8f5aef": "Estadísticas y uso", @@ -6096,9 +6151,9 @@ "d7a3e635b6": "Entrada y edición", "6d1a27e193": "Tema, zoom, apariencia de la aplicación y del terminal, barras laterales y barra de estado.", "2b4474780a": "Apariencia", - "3d9adfe6a5": "Terminal global, navegador y pestañas de markdowns.", + "3d9adfe6a5": "Pestañas globales de terminal, navegador y Markdown.", "3eb22a3ada": "Espacio de trabajo flotante", - "01f9d36292": "Configure la compatibilidad con el emulador móvil para Orca y agents de codificación.", + "01f9d36292": "Configura la compatibilidad con el emulador móvil para Orca y agentes de codificación.", "f75daf1002": "Emulador móvil", "ad9788036f": "Página de inicio, enrutamiento de enlaces y cookies de sesión.", "c46215ea03": "Navegador", @@ -6106,81 +6161,81 @@ "13d4fe30ad": "Comandos rápidos", "b79b5b31e9": "Shells, renderizador, sesiones y comportamiento del terminal.", "3de4bbb841": "Terminal", - "dd72ed437a": "Elija qué proveedores de tareas aparecen en la página Tareas y en la barra lateral.", + "dd72ed437a": "Elige qué proveedores de tareas aparecen en la página Tareas y en la barra lateral.", "11faa2f7dd": "Fuentes de tareas", - "cfa34f4465": "Nomenclatura de sucursales, referencias base, atribución y autor de Git AI.", + "cfa34f4465": "Nombres de rama, refs base, atribución y Git AI Author.", "70100f94c7": "Git y control de código fuente", - "b07041697f": "Conecte GitHub, GitLab, Linear y servicios de alojamiento de origen.", + "b07041697f": "Conecta GitHub, GitLab, Linear y servicios de alojamiento de código fuente.", "c9ca101a3b": "Integraciones", - "f9b77539fd": "Valores predeterminados del espacio de trabajo, configuración y mantenimiento de aplicaciones.", + "f9b77539fd": "Valores predeterminados del workspace, configuración de la app y mantenimiento.", "7807c11c4d": "General", - "6855b0f77d": "Termine los flujos de trabajo principales que hacen que Orca sea útil para el trabajo de agentes paralelos.", + "6855b0f77d": "Termina los flujos de trabajo principales que hacen que Orca sea útil para el trabajo de agentes en paralelo.", "6d119427ef": "Lista de verificación de incorporación", "eb1176a14e": "Dictado local de voz a texto con modelos en el dispositivo.", "5063bb47a5": "Voz", - "7118953f14": "Permita que los agents controlen cualquier aplicación en su computadora.", - "c9841721cb": "Uso de la computadora", - "475980f53d": "Coordine múltiples agents de codificación a través de Orca.", + "7118953f14": "Permite que los agentes controlen cualquier app en tu computadora.", + "c9841721cb": "Computer Use", + "475980f53d": "Coordina varios agentes de codificación a través de Orca.", "00c3a7950d": "Orquestación", - "21f09426ea": "Opcional. Orca trabaja con los inicios de sesión de su proveedor existente; agregue cuentas solo si desea que Orca le ayude a cambiar entre ellas.", + "21f09426ea": "Opcional. Orca funciona con tus inicios de sesión de proveedores existentes; agrega cuentas solo si quieres que Orca te ayude a cambiar entre ellas.", "ad6c529693": "Cuentas de proveedores de IA", - "ec1ba547f7": "Administre agents de IA, establezca un valor predeterminado y personalice comandos.", + "ec1ba547f7": "Administra agentes de IA, define uno predeterminado y personaliza comandos.", "8afa676615": "Agentes", "add3b97ee6": "\"", "3c88ec55d6": "No se encontraron configuraciones para \"", - "c7ad095d96": "Cargando configuración...", - "acc7bbdefd": "Presione ESC nuevamente para salir de la configuración", - "43b68e10f0": "No has guardado los cambios de autor de Git AI. Irse los descartará.", - "17bdee4ff1": "¿Descartar los cambios de autor de Git AI no guardados?", + "c7ad095d96": "Cargando ajustes...", + "acc7bbdefd": "Presiona ESC de nuevo para salir de ajustes", + "43b68e10f0": "Tienes cambios no guardados de Git AI Author. Al salir se descartarán.", + "17bdee4ff1": "¿Descartar cambios no guardados de Git AI Author?", "084d8fac5b": "Privacidad y seguridad", "23931df7e8": "Hosts remotos", - "mobile_group": "Mobile", + "mobile_group": "Móvil", "8bd117d669": "Interfaz", "e1578cd4bc": "Flujos de trabajo", - "9abb9be3bc": "Configuración", + "9abb9be3bc": "Configuración inicial", "23c6874fdf": "Capacidades de IA", "2309068a6f": "destructivo", "65358016ea": "Desechar", - "dev": "Dev Tools", - "devDescription": "Dev-only tools for exercising UI states." + "dev": "Herramientas de desarrollo", + "devDescription": "Herramientas solo de desarrollo para probar estados de la UI." }, "SettingsFormControls": { "42a4d15a30": "No hay fuentes coincidentes.", "b55371ea18": "Fuentes", - "c766f8ac75": "Alternar sugerencias de fuentes", - "74bcecd5ec": "Claro", + "c766f8ac75": "Mostrar u ocultar sugerencias de fuentes", + "74bcecd5ec": "Borrar", "a4ff6143f8": "Borrar selección de fuente", "b661b034ec": "· Por defecto:", "ceefb9d7f1": "No se encontraron temas.", "9119fb2268": "Actual", - "4e11f87ca6": "Demostración", + "4e11f87ca6": "Mostrando", "fbb428db98": "Seleccionado:", "fac59213fc": "Buscar temas integrados", - "cb330ef7f8": "de {{value0}}", - "c822571b2e": "coincidente con \"{{value0}}\"", + "cb330ef7f8": " de {{value0}}", + "c822571b2e": " que coinciden con \"{{value0}}\"", "3119c012a5": "cadena", "builtin_themes": "Integrados", - "imported_from": "Imported from {{value0}}", + "imported_from": "Importado desde {{value0}}", "imported_themes": "Importados", - "search_terminal_themes": "Search terminal themes" + "search_terminal_themes": "Buscar temas de terminal" }, "SettingsSidebar": { "e0900f83e7": "SSH", "5c9669ff9c": "Proyectos", - "dbceaa8840": "Configuración de búsqueda", - "60f8a673a7": "Volver a la aplicación", - "6503182299": "Lista de verificación de incorporación", - "82db1b7de4": "Lista de verificación de incorporación, {{value0}} de {{value1}} realizada. Mostrar guía de configuración.", + "dbceaa8840": "Buscar ajustes", + "60f8a673a7": "Volver a la app", + "6503182299": "Checklist de onboarding", + "82db1b7de4": "Checklist de onboarding, {{value0}} de {{value1}} completados. Mostrar guía de configuración.", "df38d612b7": "Aún no se han agregado proyectos.", - "3e483e256b": "No hay configuraciones de proyecto coincidentes." + "3e483e256b": "No hay ajustes de proyecto coincidentes." }, "ShortcutFilterRail": { "28b63545bf": "Estado", - "8a1e78c14b": "Filtros de estado de acceso directo", - "df8466f3fc": "Borrar búsqueda de accesos directos", - "f733c4b89f": "Comando o teclas de búsqueda", - "02dc7d4251": "encontrar atajos", - "1d5634ba31": "{{value0}} atajo" + "8a1e78c14b": "Filtros de estado de atajos", + "df8466f3fc": "Borrar búsqueda de atajos", + "f733c4b89f": "Buscar comando o teclas", + "02dc7d4251": "Buscar atajos", + "1d5634ba31": "Atajo de {{value0}}" }, "ShortcutRowsList": { "4ce3cd24d9": "Ningún atajo coincide con esos filtros." @@ -6189,20 +6244,20 @@ "0762983d13": "Terminal primero", "63308571d8": "Orca primero", "c43c7ff5f9": "Decide quién intercepta primero los atajos", - "c3a554288e": "Atajos en la Terminal", - "0f55c6f15c": "Elija si Orca o el terminal enfocado gana cuando los atajos se superponen." + "c3a554288e": "Atajos en la terminal", + "0f55c6f15c": "Elige si Orca o la terminal enfocada gana cuando los atajos se superponen." }, "ShortcutsPane": { "4b7ae34062": "directamente.", "38e86e206a": "Personaliza los atajos visualmente o edítalos", "47f8f7aef9": "Atajos de teclado", - "f0b35b0b2e": "Deshabilitado mientras un terminal o TUI tiene el foco del teclado.", + "f0b35b0b2e": "Deshabilitado mientras una terminal o TUI tiene el foco del teclado.", "5c65d5db9d": "Terminal primero", "dfa8ff612f": "También se ejecuta mientras una terminal o TUI tiene el foco del teclado.", "2a0e8aeccf": "Orca primero", "3c0fac059a": "Todavía se ejecuta mientras una terminal tiene el foco del teclado.", - "25b0004fbf": "Terminal activo", - "781cb74d22": "Se ejecuta desde paneles de terminales.", + "25b0004fbf": "Terminal activa", + "781cb74d22": "Se ejecuta desde paneles de terminal.", "cb02e00202": "Terminal", "d8c988dab4": "~/.orca/keybindings.json" }, @@ -6213,54 +6268,54 @@ "2cb4bb7e5d": "Argumentos CLI", "0740d30915": "Comando personalizado", "ee0e5c2a48": "Usar agente predeterminado", - "bf84dea6af": "Utilice variables sólo cuando desee que Orca inyecte contexto. Deje al agente como predeterminado para seguir su preferencia normal de agente.", - "a79c567194": "Recetas de acción", - "cf01d41bce": "Agente, argumentos CLI y plantilla de comando utilizados por cada botón AI de control de código fuente.", + "bf84dea6af": "Usa variables solo cuando quieras que Orca inyecte contexto. Deja el agente predeterminado para seguir tu preferencia normal de agente.", + "a79c567194": "Recetas de acciones", + "cf01d41bce": "Agente, argumentos CLI y plantilla de comando usados por cada botón de IA de control de código fuente.", "a9359c8aa9": "Error desconocido", - "b5f46664d3": "No se pudo guardar la acción predeterminada de AI de control de código fuente: {{value0}}", - "d18d665e12": "Ahorrar", - "4f549a5fa8": "Ahorro...", + "b5f46664d3": "No se pudo guardar el valor predeterminado de la acción de IA de control de código fuente: {{value0}}", + "d18d665e12": "Guardar", + "4f549a5fa8": "Guardando...", "9d3cc627f8": "Guardado", "817128d94e": "Cambios no guardados", - "7ab1437a12": "solicitud de extracción", + "7ab1437a12": "pull request", "e5b24893ba": "commit", - "06a9dab64d": "cheques", + "06a9dab64d": "checks", "cb67b938c5": "arreglar", "2037c78a6f": "plantilla", "eb7e8f3b39": "modelo", - "d74fdc776c": "dominio", - "673369fe0c": "cli", + "d74fdc776c": "comando", + "673369fe0c": "CLI", "db9bd75d10": "argumentos", - "926d58e87f": "agent" + "926d58e87f": "agente" }, "SparsePresetSettingsSection": { - "6fa754d20f": "Borrar", + "6fa754d20f": "Eliminar", "fe1f2c6572": "Editar {{value0}}", - "88bfbf1a9c": "No hay pocos ajustes preestablecidos guardados para este repositorio.", - "d7565029a9": "Nuevo preestablecido", - "17f8c4ce10": "Administre conjuntos de directorios guardados para la creación de árboles de trabajo dispersos.", - "388513be2d": "Ajustes preestablecidos de pago dispersos", - "a05bc9183f": "Guardar preestablecido", + "88bfbf1a9c": "No hay presets sparse guardados para este repositorio.", + "d7565029a9": "Nuevo preset", + "17f8c4ce10": "Gestiona conjuntos de directorios guardados para crear worktrees sparse.", + "388513be2d": "Presets de sparse checkout", + "a05bc9183f": "Guardar preset", "2d7d45e991": "Cancelar", - "c240a16f25": "Utilice rutas relativas al repo como paquetes/web o aplicaciones/api.", - "fde7ff2cc3": "paquetes/web compartida/ui", + "c240a16f25": "Usa rutas relativas al repositorio como packages/web o apps/api.", + "fde7ff2cc3": "packages/web shared/ui", "caf33029cc": "Directorios", - "3b6f1abd3e": "p.ej. solo web", + "3b6f1abd3e": "p. ej., web-only", "a6fcdd9e3c": "Nombre", - "b9922ec194": "Cancelar edición preestablecida", - "694cc55ecb": "Los directorios guardados se utilizan al crear árboles de trabajo dispersos para este repositorio.", + "b9922ec194": "Cancelar edición del preset", + "694cc55ecb": "Los directorios guardados se usan al crear worktrees sparse para este repositorio.", "8b64731aaf": "+{{value0}} más", "755c6a1a0d": "Confirmar", "a7bcf206b1": "Eliminando", "ba9ad2d4cd": "Fecha de actualización desconocida", "568d7e1e49": "Actualizado {{value0}}", - "d7b3f0bdc3": "directorios {{value0}}", + "d7b3f0bdc3": "{{value0}} directorios", "9d3c087fc0": "1 directorio", - "8deb7024ab": "Cargando ajustes preestablecidos escasos...", - "92c08ccae3": "No se pudieron cargar los ajustes preestablecidos escasos.", + "8deb7024ab": "Cargando presets sparse...", + "92c08ccae3": "No se pudieron cargar los presets sparse.", "3dfa765ca7": "Se guardarán {{value0}} directorios.", "b532b9c17d": "Se guardará 1 directorio.", - "623b4cf910": "Editar preajuste", + "623b4cf910": "Editar preset", "68bbcd864a": "nuevo", "2ef2b2674b": "Eliminar {{value0}}" }, @@ -6271,38 +6326,38 @@ "c0f1c80166": "No hay destinos SSH configurados.", "639ceb3698": "Agregar destino", "51d7dba44d": "Importar", - "a7d28dff81": "Agregue un host remoto para conectarse a él en Orca.", - "94c5284560": "Objetivos", - "f495689b82": "Importación fallida", - "f8050f6307": "Servidor {{value0}} sincronizado {{value1}}", + "a7d28dff81": "Agrega un host remoto para conectarte a él en Orca.", + "94c5284560": "Destinos", + "f495689b82": "Error al importar", + "f8050f6307": "Sincronización completada: {{value0}} host{{value1}}", "68c13b4589": "Prueba fallida", "81d08bcddf": "Conexión exitosa", "2c4ee7332b": "No se pudo restablecer el relé remoto", - "db2e48975e": "Restablecimiento remoto del relé", + "db2e48975e": "Relé remoto restablecido", "025e107643": "No se pudieron finalizar los terminales remotos", - "90e308c98b": "Terminales remotas terminadas", - "a43de1d3ee": "Falló la desconexión", - "e95d5ae10e": "La conexión falló", - "c2a69510e3": "No se pudo eliminar el objetivo", - "a0237eb1ca": "Objetivo eliminado", - "2227ce47b6": "No se pudo guardar el objetivo", - "f602009125": "Objetivo agregado", - "b4ba0ce33d": "Objetivo actualizado", - "3879cbaa52": "El tiempo de espera del terminal debe estar entre 60 y {{value0}} segundos, o mantener los terminales activos hasta que se reinicien.", + "90e308c98b": "Terminales remotos finalizados", + "a43de1d3ee": "Error al desconectar", + "e95d5ae10e": "Error de conexión", + "c2a69510e3": "No se pudo eliminar el destino", + "a0237eb1ca": "Destino eliminado", + "2227ce47b6": "No se pudo guardar el destino", + "f602009125": "Destino agregado", + "b4ba0ce33d": "Destino actualizado", + "3879cbaa52": "El tiempo de espera del terminal debe estar entre 60 y {{value0}} segundos, o mantener los terminales activos hasta restablecer.", "4db9afce1c": "El puerto debe estar entre 1 y 65535", - "0e5aa04161": "Se requiere un alias de configuración de host o SSH", + "0e5aa04161": "Se requiere un host o alias de configuración SSH", "f1fc50dad2": "No se pudieron cargar destinos SSH", "0cda732f43": "La prueba de conexión falló" }, "SshPassphraseDialog": { "d5a234456f": "Cancelar", - "c3ce71aad6": "Introduzca la frase de contraseña", + "c3ce71aad6": "Introduce la frase de contraseña", "abaa0dc653": "Introduce la contraseña", - "ce4fdf7914": "Ingrese la contraseña para", - "dbf9b6f2d0": "Introduzca la contraseña para", + "ce4fdf7914": "Introduce la frase de contraseña para ", + "dbf9b6f2d0": "Introduce la contraseña para ", "c55f105262": "No se pudo cancelar la solicitud de credencial SSH", "b8e88fd0de": "No se pudo enviar la credencial SSH", - "405066423c": "Descubrir", + "405066423c": "Desbloquear", "bec2c1318f": "Conectar", "8a349e3fac": "Frase de contraseña para {{value0}}", "cab3d5f5a5": "Contraseña para {{value0}}", @@ -6311,16 +6366,16 @@ }, "SshTargetCard": { "ec6543cee9": "Conectar", - "0e53e9f8e8": "Prueba", + "0e53e9f8e8": "Probar", "1810b51482": "Conectando", "4c86f30877": "Desconectar", - "7f7b3d7ab4": "Eliminar objetivo", - "3d21a22d0e": "Eliminando objetivo", - "3d8af2949f": "Editar objetivo", + "7f7b3d7ab4": "Eliminar destino", + "3d21a22d0e": "Eliminando destino", + "3d8af2949f": "Editar destino", "762a48c662": "Restablecer el relé remoto", - "97dea4e8cf": "Restablecer el relé remoto", - "da16e108e6": "Terminales remotos finales", - "c77f1abfe3": "Terminar con terminales remotos", + "97dea4e8cf": "Restableciendo el relé remoto", + "da16e108e6": "Finalizar terminales remotos", + "c77f1abfe3": "Finalizando terminales remotos", "18968ede9e": "Error", "f0871e6bfb": "conectar", "47e94bd6ba": "conectado", @@ -6328,11 +6383,11 @@ "8ce71262f4": "terminales hasta el reinicio" }, "SshTargetDestructiveActions": { - "7e66942808": "Esto detendrá las sesiones de terminal activas en este destino SSH. Volver a conectarlos no los restaurará.", - "accf177a03": "¿Acabar con los terminales remotos?", - "26be00392d": "Esto fuerza la detención de la retransmisión remota para este destino SSH. Los terminales remotos activos y los reenvíos de puertos para este destino finalizarán.", - "570a7a0574": "¿Reiniciar el relé remoto?", - "3bb0cf0ee4": "Esto eliminará el objetivo y finalizará cualquier terminal remoto activo.", + "7e66942808": "Esto detendrá las sesiones de terminal activas en este destino SSH. Volver a conectarse no las restaurará.", + "accf177a03": "¿Finalizar terminales remotos?", + "26be00392d": "Esto fuerza la detención del relé remoto para este destino SSH. Los terminales remotos activos y los reenvíos de puertos de este destino finalizarán.", + "570a7a0574": "¿Restablecer relé remoto?", + "3bb0cf0ee4": "Esto eliminará el destino y finalizará todos los terminales remotos activos.", "4808966c41": "Eliminar destino SSH" }, "SshTargetForm": { @@ -6342,80 +6397,84 @@ "b574994adc": "Usa Finalizar terminales remotos o Restablecer relé cuando quieras detenerlos.", "71fc546097": "Mantener los terminales activos hasta restablecer", "92f80edbfd": "Persistencia de terminales remotos", - "feae1d1e69": "Opcional. Equivalente a ProxyJump/ssh -J.", - "11bcb4507a": "bastion.ejemplo.com", - "b2ab248ded": "Anfitrión de salto", - "3b01ca44a0": "Opcional. Se utiliza para hacer túneles (por ejemplo, Cloudflare Access, ProxyCommand).", - "f42d844544": "p.ej. acceso a la nube ssh --hostname %h", + "feae1d1e69": "Opcional. Equivale a ProxyJump / ssh -J.", + "11bcb4507a": "bastion.example.com", + "b2ab248ded": "Host de salto", + "3b01ca44a0": "Opcional. Se usa para túneles (p. ej., Cloudflare Access, ProxyCommand).", + "f42d844544": "p. ej., cloudflared access ssh --hostname %h", "c7d0e18ecb": "Comando proxy", - "cb91f6375c": "Opcional. El agente SSH se utiliza de forma predeterminada.", + "cb91f6375c": "Opcional. Se usa el agente SSH de forma predeterminada.", "d6a5f2ee5c": "~/.ssh/id_ed25519 (dejar vacío para el agente SSH)", "63c0c145c1": "Archivo de identidad", "c94cfa634c": "Puerto", - "47e082bc17": "desplegar", + "47e082bc17": "deploy", "dc1dc52aaa": "Nombre de usuario", - "2ee9bcd2e8": "servidor, implementar@servidor:2222, ssh://servidor", - "ce370ce674": "Anfitrión o alias *", - "b8dab0aa7b": "mi servidor", + "2ee9bcd2e8": "server, deploy@server:2222, ssh://server", + "ce370ce674": "Host o alias *", + "b8dab0aa7b": "Mi servidor", "298de87a88": "Etiqueta", "9518545cb6": "Agregar destino", "a62b4cb39a": "Guardar cambios", "29af933cd5": "Nuevo destino SSH", "f2331ce599": "Editar destino SSH", "7c13f58c91": "Hasta restablecer", - "55c56cf2c7": "Tiempo de espera tras desconectar (segundos)" + "55c56cf2c7": "Tiempo de espera después de desconectar (segundos)", + "4a342f44c1": "Conexión avanzada", + "e9609ddca6": "Proxy, host de salto y reutilización de conexión", + "8c922dffba": "Reutilizar la conexión SSH para una configuración más rápida", + "53e9aabfc0": "Usa multiplexación de OpenSSH cuando está disponible. Desactívalo para hosts con restricciones SSH personalizadas." }, "TasksPane": { - "3a72b9745e": "Elija qué proveedores de tareas aparecen en la página Tareas y en los accesos directos de la barra lateral.", + "3a72b9745e": "Elige qué proveedores de tareas aparecen en la página Tareas y en los accesos directos de la barra lateral.", "f71d8a9dd3": "Proveedores de tareas", - "71644aba56": "Elija qué proveedores de tareas aparecen en el selector de fuente de la página Tareas y en los accesos directos de la barra lateral. Al menos un proveedor debe permanecer visible.", + "71644aba56": "Elige qué proveedores de tareas aparecen en el selector de fuentes de la página Tareas y en los accesos directos de la barra lateral. Al menos un proveedor debe permanecer visible.", "93e72ef659": "Fuentes de tareas", - "8e1305fcc6": "Muestre Jira en el selector de fuente de Tareas y en los accesos directos de la barra lateral.", + "8e1305fcc6": "Muestra Jira en el selector de fuentes de Tareas y en los accesos directos de la barra lateral.", "6b23a34f6d": "Jira", - "e4170c9615": "Muestre Linear en el selector de fuente de Tareas y en los accesos directos de la barra lateral.", + "e4170c9615": "Muestra Linear en el selector de fuentes de Tareas y en los accesos directos de la barra lateral.", "09ae2d7c51": "Linear", - "dd67a1b6e1": "Muestre GitLab en el selector de fuente de Tareas y en los accesos directos de la barra lateral.", + "dd67a1b6e1": "Muestra GitLab en el selector de fuentes de Tareas y en los accesos directos de la barra lateral.", "7c5d7fdc20": "GitLab", - "1db47236cd": "Muestre GitHub en el selector de fuente de Tareas y en los accesos directos de la barra lateral.", + "1db47236cd": "Muestra GitHub en el selector de fuentes de Tareas y en los accesos directos de la barra lateral.", "e14063e727": "GitHub" }, "TerminalAppearanceSection": { "a14a427ae4": "Grosor de la línea divisoria del panel.", "f27a99978d": "Grosor del divisor", - "db632cb50e": "Opacidad aplicada a paneles que no están actualmente activos.", + "db632cb50e": "Opacidad aplicada a los paneles que no están activos actualmente.", "a6fdd6a3b1": "Opacidad del panel inactivo", - "1b79379d4f": "Controle la atenuación del panel inactivo y divida el espesor del divisor.", - "e1a5c25555": "Paneles terminales", - "04cdf85dec": "Opacidad del cursor terminal.", + "1b79379d4f": "Controla la atenuación de paneles inactivos y el grosor del divisor de splits.", + "e1a5c25555": "Paneles de terminal", + "04cdf85dec": "Opacidad del cursor del terminal.", "b9f1804422": "Opacidad del cursor", "2de6b5a699": "Utiliza la variante parpadeante de la forma del cursor seleccionada.", "74736cc9b1": "Cursor parpadeante", - "2e5aec3cf6": "Subrayar", - "52854a5608": "Bloquear", - "e070e8aeba": "Bar", + "2e5aec3cf6": "Subrayado", + "52854a5608": "Bloque", + "e070e8aeba": "Barra", "db270cc9a9": "Forma del cursor", - "d455f2ef4f": "Apariencia predeterminada del cursor para los paneles del terminal Orca.", - "abcb4dd019": "Cursor de terminales", - "70beb1bbc7": "Avance", - "31f6e61085": "Actualmente las ligaduras", - "870377082f": "Apagado", - "84bd22f2cd": "En", + "d455f2ef4f": "Apariencia predeterminada del cursor para los paneles de terminal de Orca.", + "abcb4dd019": "Cursor del terminal", + "70beb1bbc7": "Vista previa", + "31f6e61085": "Las ligaduras están", + "870377082f": "Desactivadas", + "84bd22f2cd": "Activadas", "bc9ff84d61": "Auto", - "be8da35e7f": "Ligaduras de fuentes", + "be8da35e7f": "Ligaduras de fuente", "4b1f29598e": "Automático: deshabilitado para \"{{value0}}\".", "400e950ca5": "Automático: habilitado para \"{{value0}}\".", - "04569feb07": "Siempre desactivado, incluso para las fuentes que los incluyen.", - "7234abcd08": "Siempre encendido. Las fuentes sin ligaduras simplemente se muestran tal cual.", - "7233d594bf": "Renderice ligaduras de programación (por ejemplo, =>, !=, ===) para las fuentes que las incluyen. \"Auto\" habilita ligaduras solo para fuentes de ligadura conocidas (Fira Code, JetBrains Mono, Cascadia Code, Iosevka, etc.).", - "bafc80efbc": "Controla el multiplicador de altura de la línea terminal.", + "04569feb07": "Siempre desactivadas, incluso en fuentes que las incluyen.", + "7234abcd08": "Siempre activadas. Las fuentes sin ligaduras simplemente se renderizan tal cual.", + "7233d594bf": "Renderiza ligaduras de programación (p. ej., =>, !=, ===) en las fuentes que las incluyen. \"Auto\" activa ligaduras solo para fuentes de ligaduras conocidas (Fira Code, JetBrains Mono, Cascadia Code, Iosevka, etc.).", + "bafc80efbc": "Controla el multiplicador de altura de línea del terminal.", "c084eb7d4c": "Altura de línea", - "36af8ad94c": "Controla el peso de la fuente del texto del terminal.", + "36af8ad94c": "Controla el peso de fuente del texto del terminal.", "4aae5db258": "Peso de fuente", - "f04b17a50e": "Familia de fuentes de terminal predeterminada para nuevos paneles y actualizaciones en vivo.", + "f04b17a50e": "Familia de fuentes predeterminada del terminal para paneles nuevos y actualizaciones en vivo.", "a408266e67": "Familia de fuentes", "855a76343a": "Importar desde Ghostty", - "711e589f18": "Tipografía de terminal predeterminada para nuevos paneles y actualizaciones en vivo.", - "048aac8a64": "Tipografía de terminales", + "711e589f18": "Tipografía predeterminada del terminal para paneles nuevos y actualizaciones en vivo.", + "048aac8a64": "Tipografía del terminal", "4415beb958": "desactivado", "4e7d41a9f0": "activado", "e90afcc44f": "apagado", @@ -6424,41 +6483,41 @@ "dimUnfocusedPanes": "Atenuar paneles sin foco." }, "TerminalFontSizeSetting": { - "9b5252c85a": "píxeles", - "0f4c92e595": "Tamaño de fuente de terminal predeterminado para nuevos paneles y actualizaciones en vivo.", + "9b5252c85a": "px", + "0f4c92e595": "Tamaño de fuente predeterminado del terminal para paneles nuevos y actualizaciones en vivo.", "a4a352b1e9": "Tamaño de fuente" }, "TerminalPane": { "4263e940e0": "Al presionar la tecla JIS Yen (¥), se envía una barra invertida (\\\\).", - "19f4935159": "Yen JIS (¥) a Barra invertida (\\\\)", + "19f4935159": "Yen JIS (¥) a barra invertida (\\)", "1c337bef4a": "Controla si al presionar la tecla JIS Yen (¥) se envía una barra invertida (\\\\).", - "3fe1c5bfe0": "Apagado", - "c73d510938": "Bien", + "3fe1c5bfe0": "Desactivado", + "c73d510938": "Derecha", "e7aec1fd60": "Izquierda", "badb1219fc": "Ambos", "43c2ff7b0e": "Auto", "0a10420e1a": "Opción como Alt", - "ce3aadf0b2": "La tecla Opción {{value0}} envía Alt/Esc; el otro compone caracteres especiales.", + "ce3aadf0b2": "La tecla Opción {{value0}} envía Alt/Esc; la otra compone caracteres especiales.", "b62373091a": "Ambas teclas de opción envían secuencias Alt/Esc.", - "d8998bb328": "La opción compone caracteres especiales para la distribución de su teclado.", + "d8998bb328": "La tecla Opción compone caracteres especiales para la distribución de tu teclado.", "d21c493808": "Automático: detectado: {{value0}}.", "2561d3fc1b": "Controla si la tecla Opción de macOS envía secuencias Alt/Esc o compone caracteres.", "96be03b8eb": "PowerShell 7+", - "d26174e1dd": "WindowsPowerShell", + "d26174e1dd": "Windows PowerShell", "fe20f79dd1": "Versión de PowerShell", "822f62ddcd": "Descargar PowerShell 7+", "a016ffbeed": "Auto usa Windows PowerShell ahora y cambia a PowerShell 7+ cuando está instalado.", - "5ed5c95344": "Elija entre Windows PowerShell y PowerShell 7+ para obtener nuevos paneles de terminal.", - "3d88af864d": "Elija si la opción de shell de PowerShell inicia Windows PowerShell o PowerShell 7+ para nuevos paneles de terminal.", + "5ed5c95344": "Elige entre Windows PowerShell y PowerShell 7+ para nuevos paneles de terminal.", + "3d88af864d": "Elige si la opción de shell PowerShell inicia Windows PowerShell o PowerShell 7+ para nuevos paneles de terminal.", "8a956cc91e": "Caracteres tratados como límites de palabras para la selección con doble clic.", "4bebcc2b2c": "Separadores de palabras", "12e06178fa": "filas", - "907b0b9d3e": "Costumbre", + "907b0b9d3e": "Personalizado", "5336c096af": "{{value0}} filas", - "81d86b2dd2": "Filas de terminal de escritorio retenidas para paneles nuevos y abiertos.", - "9df53f7c14": "Filas de desplazamiento hacia atrás", - "c3810b2b42": "Filas de terminal de escritorio retenidas.", - "267d020745": "Desplazamiento hacia atrás, límites de palabras y comportamientos de terminal específicos de la plataforma.", + "81d86b2dd2": "Filas de scrollback conservadas para paneles de terminal nuevos y abiertos.", + "9df53f7c14": "Filas de scrollback", + "c3810b2b42": "Filas de scrollback conservadas.", + "267d020745": "Scrollback, límites de palabras y comportamientos de terminal específicos de la plataforma.", "5e5f06c82c": "Avanzado", "003df129fe": "Dividir horizontalmente", "623e62df99": "Dividir horizontalmente", @@ -6470,45 +6529,45 @@ "d23b43c5be": "Ubicación del script de configuración", "34a0dfa06e": "Dónde se ejecuta el script de configuración del repositorio cuando se crea un nuevo espacio de trabajo.", "21f8da2078": "Script de configuración del espacio de trabajo", - "6e6480a7df": "Deje que los programas en la terminal (tmux, Neovim, fzf, SSH) se copie al portapapeles de su sistema.", + "6e6480a7df": "Permite que los programas del terminal (tmux, Neovim, fzf, SSH) copien al portapapeles del sistema.", "3338dcf8c1": "Permitir escrituras en el portapapeles TUI (OSC 52)", - "69c64a479c": "Deje que tmux, Neovim y fzf copie al portapapeles del sistema a través de PTY (incluso a través de SSH).", - "4729c645fc": "Copie automáticamente las selecciones de terminal al portapapeles.", + "69c64a479c": "Permite que tmux, Neovim y fzf copien al portapapeles del sistema a través de PTY (incluso por SSH).", + "4729c645fc": "Copia automáticamente las selecciones del terminal al portapapeles.", "902f5dee1f": "Copiar al seleccionar", "9129b7e805": "Al pasar el cursor sobre un panel de terminal, se activa sin necesidad de hacer clic.", "8eefeaa3da": "El foco sigue al ratón", - "96fe15def8": "Comportamiento del mouse y del portapapeles para paneles de terminales.", - "45721f3e67": "Interacción terminal", - "9c0b1c1792": "En", + "96fe15def8": "Comportamiento del mouse y del portapapeles en los paneles de terminal.", + "45721f3e67": "Interacción del terminal", + "9c0b1c1792": "Activado", "c1fc9e9444": "Aceleración de GPU", "e0996d141a": "Auto prueba WebGL, con respaldo DOM para renderizadores riesgosos o no compatibles.", "7eaccc1424": "Siempre se intenta WebGL para paneles de terminales.", - "fe4acf36c6": "WebGL deshabilitado; Renderizador DOM para máxima compatibilidad.", - "f07dfb4466": "Controla si el terminal utiliza la representación WebGL de xterm.js. Auto prueba WebGL cuando el renderizador es compatible, con una alternativa conservadora de Linux para software o renderizadores de GPU desconocidos.", - "72bc9334a0": "Comportamiento del renderizador de terminal para paneles activos y paneles nuevos.", - "2fba319f21": "Representación", - "cc8c5ca224": "predeterminado de Windows", + "fe4acf36c6": "WebGL desactivado; renderizador DOM para máxima compatibilidad.", + "f07dfb4466": "Controla si el terminal usa el renderizado WebGL de xterm.js. Auto prueba WebGL cuando el renderizador es compatible, con una alternativa conservadora en Linux para renderizadores de GPU por software o desconocidos.", + "72bc9334a0": "Comportamiento del renderizador del terminal para paneles activos y paneles nuevos.", + "2fba319f21": "Renderizado", + "cc8c5ca224": "Predeterminado de Windows", "d78fc4fdef": "Cargando distribuciones", "219aaa59f4": "Distribución WSL", "2503f1e86b": "Se utiliza para nuevos paneles de terminal WSL y detección de agentes locales cuando el espacio de trabajo activo aún no está dentro de WSL.", "5fe79a5e56": "Elija qué distribución WSL utilizarán los nuevos terminales WSL y los análisis de agentes locales.", "b637dd57a7": "WSL", - "f61ac77f16": "Git bash", + "f61ac77f16": "Git Bash", "0f1b8669e6": "Símbolo del sistema", "eb7fc4d98a": "PowerShell", - "27e301f22c": "Shell predeterminado", - "09bf02de9a": "Shell utilizado al abrir un nuevo panel de terminal. Entra en vigor para nuevos terminales.", - "bd68f3170d": "Elija el shell predeterminado para los nuevos paneles de terminal en Windows.", - "a55eee649f": "Shell predeterminado para nuevos paneles de terminal en Windows.", - "87e678a8af": "Cáscara de Windows", + "27e301f22c": "Shell predeterminada", + "09bf02de9a": "Shell usada al abrir un nuevo panel de terminal. Se aplica a terminales nuevos.", + "bd68f3170d": "Elige la shell predeterminada para nuevos paneles de terminal en Windows.", + "a55eee649f": "Shell predeterminada para nuevos paneles de terminal en Windows.", + "87e678a8af": "Shell de Windows", "05efc0bada": "verdadero", "348246b06f": "FALSO", "5936387ddd": "auto", - "adbafefe56": "costumbre", - "16753eea48": "En Windows, haga clic derecho y pegue el portapapeles. Ctrl+clic derecho abre el menú contextual.", - "9c178cf8aa": "Haga clic derecho para pegar", - "af0c3b6e39": "En Windows, haga clic derecho y pegue el portapapeles en la terminal. Utilice Ctrl+clic derecho para abrir el menú contextual.", - "29154326bb": "en", + "adbafefe56": "personalizado", + "16753eea48": "En Windows, hacer clic derecho pega el portapapeles. Ctrl+clic derecho abre el menú contextual.", + "9c178cf8aa": "Clic derecho para pegar", + "af0c3b6e39": "En Windows, hacer clic derecho pega el portapapeles en el terminal. Usa Ctrl+clic derecho para abrir el menú contextual.", + "29154326bb": "activado", "ab20575a8a": "apagado", "ab3a1f9068": "wsl.exe", "ask_before_closing_running_terminals_title": "Ask Before Closing Running Terminals", @@ -6528,94 +6587,94 @@ }, "TerminalSettingsPreview": { "a63953a48a": "Vista previa del tema {{value0}}", - "2c248fcc27": "Tema de vista previa", - "f8931d407d": "Mostrar divisor de panel en vista previa", + "2c248fcc27": "Vista previa del tema", + "f8931d407d": "Mostrar divisor de panel en la vista previa", "50419052fe": "Divisor de panel", "d06664e889": "oscuro" }, "TerminalThemeSections": { - "db210115c5": "Vista previa del modo de luz", - "5e0c24b5c8": "Controla la línea divisoria dividida entre paneles en modo claro.", - "ec2e33ad80": "Color del divisor de luz", - "d56af60e6f": "Elige el tema utilizado cuando Orca está en modo claro.", - "8273bc75d7": "Tema ligero", + "db210115c5": "Vista previa del modo claro", + "5e0c24b5c8": "Controla la línea divisoria entre paneles en modo claro.", + "ec2e33ad80": "Color del divisor claro", + "d56af60e6f": "Elige el tema usado cuando Orca está en modo claro.", + "8273bc75d7": "Tema claro", "bc8e8a251a": "Vista previa del modo oscuro", "cbe56a0f79": "Controla la línea divisoria entre paneles en modo oscuro.", - "b739d2abfe": "Color divisor oscuro", - "7add204bd5": "Elija el tema del terminal utilizado en modo oscuro.", + "b739d2abfe": "Color del divisor oscuro", + "7add204bd5": "Elige el tema del terminal usado en modo oscuro.", "9499ad1dc4": "Tema oscuro", - "f012172e21": "Elija el tema utilizado para los paneles de terminal en modo oscuro.", + "f012172e21": "Elige el tema usado para los paneles de terminal en modo oscuro.", "catalog_title": "Temas de terminal", "catalog_description": "Elige temas de terminal y colores de divisor para los modos oscuro y claro.", "target_title": "Modo del tema", "target_label": "Modo del tema", - "target_aria": "Modo del tema de terminal", + "target_aria": "Modo de tema del terminal", "target_dark": "Oscuro", "target_light": "Claro", - "match_dark_mode": "Coincidir con modo oscuro", + "match_dark_mode": "Coincidir con el modo oscuro", "match_dark_mode_description": "Comparte el tema de terminal oscuro y el color del divisor en modo claro.", - "light_preview_description": "Muestra la apariencia efectiva del terminal claro.", - "dark_preview_description": "Muestra la apariencia efectiva del terminal oscuro." + "light_preview_description": "Muestra la apariencia clara efectiva del terminal.", + "dark_preview_description": "Muestra la apariencia oscura efectiva del terminal." }, "TerminalWindowSection": { - "1705318506": "color magenta ANSI", + "1705318506": "Color magenta ANSI", "03c855d15f": "Restablecer todas las anulaciones de color", "63f8d9336e": "Anulaciones de color", - "e86e09b5c7": "Anula los colores de terminales individuales.", - "1d1920dc8a": "Oculte el cursor del mouse al escribir en la terminal.", - "3530908ef9": "Ocultar el mouse mientras escribe", - "1846f6ee6a": "Relleno vertical alrededor de la cuadrícula del terminal en píxeles.", - "1afcc1d973": "Acolchado vertical", - "25e2f8e8e1": "Relleno horizontal alrededor de la cuadrícula del terminal en píxeles.", - "36b8402015": "Acolchado horizontal", + "e86e09b5c7": "Anula colores individuales de la terminal.", + "1d1920dc8a": "Oculta el cursor del mouse al escribir en la terminal.", + "3530908ef9": "Ocultar cursor al escribir", + "1846f6ee6a": "Relleno vertical alrededor de la cuadrícula de la terminal, en píxeles.", + "1afcc1d973": "Relleno vertical", + "25e2f8e8e1": "Relleno horizontal alrededor de la cuadrícula de la terminal, en píxeles.", + "36b8402015": "Relleno horizontal", "53ce336e15": "Reinicie Orca para aplicar el cambio de desenfoque de ventana.", - "c65bb9ce63": "Reiniciar requerido", - "97950bb087": "Aplique desenfoque de fondo a la ventana del terminal. Requiere reinicio.", + "c65bb9ce63": "Reinicio requerido", + "97950bb087": "Aplica desenfoque de fondo a la ventana de la terminal. Requiere reiniciar.", "2b82242f43": "Desenfoque de ventana", "809f37738d": "Controla la transparencia del fondo del terminal. 1 es completamente opaco, 0 es completamente transparente.", "ea7b1a158e": "Opacidad del fondo", "03acb60aa0": "Controla la transparencia del fondo del terminal.", - "00eaa6b881": "Configuración de apariencia y fondo de la ventana.", + "00eaa6b881": "Ajustes de apariencia y fondo de la ventana.", "b96ba13ed1": "Ventana", "42e01a6055": "Color blanco brillante ANSI", "16948119cb": "Blanco brillante", "1601140f03": "Color cian brillante ANSI", "f94adc4113": "Cian brillante", "fe4d89ef85": "Color magenta brillante ANSI", - "e56e7d6ea0": "magenta brillante", - "bef6c0f6bf": "ANSI color azul brillante", - "66820332fa": "azul brillante", + "e56e7d6ea0": "Magenta brillante", + "bef6c0f6bf": "Color azul brillante ANSI", + "66820332fa": "Azul brillante", "e2ef5f4ab7": "Color amarillo brillante ANSI", "936a326be3": "Amarillo brillante", "0ffb02f921": "Color verde brillante ANSI", - "7dafd57730": "verde brillante", + "7dafd57730": "Verde brillante", "667de68863": "Color rojo brillante ANSI", - "32b1b6acd7": "Lacre", - "f30c492769": "ANSI color negro brillante", + "32b1b6acd7": "Rojo brillante", + "f30c492769": "Color negro brillante ANSI", "260d69ce9a": "Negro brillante", "1be593d3e8": "ANSI brillante", - "28846b1ca6": "color blanco ANSI", + "28846b1ca6": "Color blanco ANSI", "0cb4459fb8": "Blanco", "bd4c759327": "Color cian ANSI", - "fb8bb4eb1f": "cian", + "fb8bb4eb1f": "Cian", "d5e92fcd94": "Magenta", "9635a71c51": "Color azul ANSI", "292a4c7316": "Azul", "09c1c6b096": "Color amarillo ANSI", "bb516de873": "Amarillo", - "8a673d4206": "color verde ANSI", + "8a673d4206": "Color verde ANSI", "8f2092b315": "Verde", "b41270f5ca": "Color rojo ANSI", "3a78f30b50": "Rojo", - "cf4437a2f7": "Color negro ANSI.", + "cf4437a2f7": "Color negro ANSI", "adfdee23cb": "Negro", - "68e9f07de0": "Norma ANSI", + "68e9f07de0": "ANSI normal", "fb8c6f1967": "Color para texto en negrita. Vuelve al color normal si no se establece.", "862e463f7f": "Texto en negrita", - "b2c0857c49": "Color del texto del texto seleccionado", - "8b450b5305": "Selección en primer plano", + "b2c0857c49": "Color del texto seleccionado", + "8b450b5305": "Primer plano de selección", "74d8555f85": "Color de fondo del texto seleccionado", - "40c3cfd30a": "Antecedentes de selección", + "40c3cfd30a": "Fondo de selección", "7f4063076c": "Color del texto debajo del cursor (cursor de bloque)", "a2d9f095a7": "Texto del cursor", "cd0700762b": "Color del cursor", @@ -6629,103 +6688,103 @@ "907131d741": "Reiniciando…" }, "UIZoomControl": { - "c2c64b24d0": "Reiniciar" + "c2c64b24d0": "Restablecer" }, "VoicePane": { "68de13f72c": "No se pudo eliminar el modelo.", "1ba81c0ff0": "recomendado", "cfde55c7b0": "No se pudo descargar el modelo.", - "43fd4f454b": "Modelo de habla", - "7cf715f891": "se lleva a cabo.", - "295d84b849": "una vez para empezar, otra vez para parar. Mantener: dictar mientras", - "ff9a680010": "Alternar: presionar", + "43fd4f454b": "Modelo de voz", + "7cf715f891": "se mantiene presionado.", + "295d84b849": "una vez para empezar y otra para parar. Mantener: dicta mientras", + "ff9a680010": "Alternar: pulsa", "ba4a900d1d": "Modo de dictado", "0121960365": "Habilitar dictado de voz", - "366e1b4f36": "para dictar texto en cualquier panel enfocado.", - "4465596675": "Prensa", + "366e1b4f36": "para dictar texto en cualquier panel con foco.", + "4465596675": "Pulsa", "62d2a84d31": "No se pudo borrar la clave API de OpenAI", "37aba8bb63": "Clave API de OpenAI borrada", "8572bbb537": "No se pudo guardar la clave API de OpenAI", "506df81ba6": "Clave API de OpenAI guardada", - "91980ce124": "{{value0}}MB", + "91980ce124": "{{value0}} MB", "61a16c8141": "Extrayendo...", "b6536a1d12": "extrayendo", - "8f4d2a51d7": "desconectado", - "d504ab05f0": "transmisión", + "8f4d2a51d7": "sin conexión", + "d504ab05f0": "streaming", "fbe5990716": "Seleccionar modelo", - "e24f7d43d2": "Seleccione un modelo de discurso. Los modelos locales funcionan sin conexión; Los modelos en la nube requieren una clave API.", - "174da92062": "Sostener", - "118b3c2dee": "Palanca", - "901985625d": "palanca", - "ad5d036ecc": "No se pudo solicitar permiso del micrófono. El dictado de voz no estaba habilitado.", + "e24f7d43d2": "Selecciona un modelo de voz. Los modelos locales funcionan sin conexión; los modelos en la nube requieren una clave API.", + "174da92062": "Mantener", + "118b3c2dee": "Alternar", + "901985625d": "alternar", + "ad5d036ecc": "No se pudo solicitar permiso para el micrófono. El dictado de voz no se habilitó.", "f9a9cf6928": "Se requiere permiso del micrófono antes de habilitar el dictado de voz.", - "1eac933202": "Se abrió Privacidad y seguridad de macOS. Habilite el dictado nuevamente después de otorgar acceso.", + "1eac933202": "Se abrió Privacidad y seguridad de macOS. Vuelve a habilitar el dictado después de otorgar acceso.", "cd9fe37556": "Permiso de micrófono concedido", "6fa734ed95": "Eliminar {{value0}}" }, "WorktreeSymlinksSection": { "1c1e35b219": "Quitar {{value0}}", - "b814c618e2": "Caminos vinculados", + "b814c618e2": "Rutas vinculadas", "31ebab5403": "No hay rutas compartidas configuradas para este repositorio.", - "ea06227efa": "agregado", + "ea06227efa": "agregada", "b2429aeb31": "Agregar", - "ab40b8a5f1": "Sin coincidencias. Sigue escribiendo para agregar una ruta personalizada.", - "4cd2a4c077": "Escriba una ruta (por ejemplo, .env o node_modules)…", + "ab40b8a5f1": "No hay coincidencias. Sigue escribiendo para agregar una ruta personalizada.", + "4cd2a4c077": "Escribe una ruta (por ejemplo, .env o node_modules)…", "241325302c": "Agregar ruta", - "7ff265071d": "Cuando se crea un nuevo árbol de trabajo, cada ruta enumerada aquí se copia con APFS clone-copy en macOS cuando es posible; de lo contrario, se enlaza simbólicamente desde el checkout principal.", - "4755f120b6": "Rutas compartidas del árbol de trabajo", - "b07ef5a8b6": "Rutas para materializar desde el checkout principal en árboles de trabajo recién creados.", - "d72ba8dc68": "{{value0}} caminos", - "9ea912d811": "1 camino" + "7ff265071d": "Cuando se crea un nuevo worktree, cada ruta listada aquí se copia como clon APFS en macOS cuando es posible; de lo contrario, se enlaza simbólicamente desde el checkout principal.", + "4755f120b6": "Rutas compartidas de worktree", + "b07ef5a8b6": "Rutas para materializar desde el checkout principal en worktrees recién creados.", + "d72ba8dc68": "{{value0}} rutas", + "9ea912d811": "1 ruta" }, "WslCliRegistration": { "c6f6f89d7c": "Cancelar", "119fef6cd2": "Ruta de destino:", - "1dbb0377d9": "Objetivo del lanzador existente:", - "554305956d": "Ruta de comando:", - "9b6627522c": "Refrescar", - "ab6b022a5c": "Actualizar el estado de la CLI de WSL", - "d9c6880dbd": "comando de shell WSL", + "1dbb0377d9": "Destino del lanzador existente:", + "554305956d": "Ruta del comando:", + "9b6627522c": "Actualizar", + "ab6b022a5c": "Actualizar estado de la CLI de WSL", + "d9c6880dbd": "Comando de shell de WSL", "52d990420e": "No se pudo eliminar `{{value0}}` de WSL.", "89c7414cf5": "Se eliminó `{{value0}}` de WSL.", "6f91ad1333": "No se pudo registrar `{{value0}}` en WSL.", - "951536dda5": "Registrado `{{value0}}` en WSL.", + "951536dda5": "Se registró `{{value0}}` en WSL.", "26b4b3b00f": "No se pudo cargar el estado de la CLI de WSL.", - "290bfff3ab": "Registro", + "290bfff3ab": "Registrar", "f951f85196": "Eliminar", "4c4a9178a3": "Registrando...", "41a1480d3e": "instalar", "4598b18464": "Eliminando...", "7c3bb36706": "eliminar", - "7ee4e52b99": "Orca registrará {{value0}} para que el comando funcione desde terminales WSL.", - "d8216eb22e": "Esto elimina el comando de shell WSL. La propia Orca permanece instalada en Windows.", + "7ee4e52b99": "Orca registrará {{value0}} para que el comando funcione desde terminales de WSL.", + "d8216eb22e": "Esto elimina el comando de shell de WSL. Orca permanece instalada en Windows.", "e49688f67f": "¿Registrar `{{value0}}` en WSL?", "61ac55278e": "¿Eliminar `{{value0}}` de WSL?", "e2b0ee267f": "duro", - "7aa456a460": "Registre `orca-ide` en ~/.local/bin dentro de WSL.", - "0307677bb9": "Comprobando el registro de WSL CLI..." + "7aa456a460": "Registra `orca-ide` en ~/.local/bin dentro de WSL.", + "0307677bb9": "Comprobando el registro de la CLI de WSL..." }, "accounts": { "search": { "86edc96bc9": "barra de estado", - "e949b08ffb": "límite de tasa", - "7e67d7d1b6": "trabajo", - "421c6be25e": "identificación", + "e949b08ffb": "límite de uso", + "7e67d7d1b6": "wrk", + "421c6be25e": "ID", "be8b621bdc": "espacio de trabajo", - "8dcbef1856": "código abierto", + "8dcbef1856": "opencode", "38d22ff8d6": "Anulación opcional del ID del espacio de trabajo si falla la búsqueda automática.", "4ee2029e9c": "ID del espacio de trabajo de OpenCode Go", "9c4e40cf6b": "sesión", - "61f7d1fcbe": "galleta", - "d1d2ae383c": "Pegue su cookie de sesión de opencode.ai para obtener el límite de velocidad.", + "61f7d1fcbe": "cookie", + "d1d2ae383c": "Pega tu cookie de sesión de opencode.ai para obtener límites de uso.", "6ed1401020": "Cookie de sesión de OpenCode Go", "b7c2cee442": "experimental", - "7118d2f908": "cartas credenciales", - "933deaf732": "juramento", - "8630464352": "cli", - "e8e1ff3887": "gemini", - "bada4a3218": "Extrae las credenciales de OAuth de su instalación local de CLI de Gemini para autenticarse con Google.", - "d819755b02": "Utilice las credenciales de la CLI de Gemini", + "7118d2f908": "credenciales", + "933deaf732": "OAuth", + "8630464352": "CLI", + "e8e1ff3887": "Gemini", + "bada4a3218": "Extrae las credenciales de OAuth de tu instalación local de la CLI de Gemini para autenticarte con Google.", + "d819755b02": "Usar credenciales de la CLI de Gemini", "35b461d817": "iniciar sesión", "f2d666a886": "opcional", "8b06729e0f": "activo", @@ -6733,24 +6792,26 @@ "06662af91e": "cuenta", "70d1b8def5": "codex", "87a4a8584e": "Elija qué cuenta Codex guardada opcional impulsa las lecturas de cuotas en vivo.", - "a4bcfd6f86": "Cuenta activa del Codex", - "042885c07c": "fuera de fecha", - "02c438bc7b": "venció", + "a4bcfd6f86": "Cuenta activa de Codex", + "042885c07c": "desactualizado", + "02c438bc7b": "caducado", "77e32a2ad3": "volver a autenticar", "c759741d77": "cuota", - "b40d5b6570": "Cambio de cuenta opcional para Codex y recuperación de límite de tasa en vivo.", - "17c5d244eb": "Cuentas del Codex", + "b40d5b6570": "Cambio de cuenta opcional para Codex y obtención de límites de uso en vivo.", + "17c5d244eb": "Cuentas de Codex", "e14049e1a8": "claude", "dd75a73991": "Cambio de cuenta opcional para Claude preservando el contexto de chat compartido.", - "75682e1b62": "Cuentas Claude", + "75682e1b62": "Cuentas de Claude", "e02c136ad0": "autenticación", "9f70aa706c": "proveedor", "488a7e9206": "Linux", - "0b4d948eb5": "wsl", - "bdbd1e668e": "ventanas", + "0b4d948eb5": "WSL", + "bdbd1e668e": "Windows", "593720c17f": "ubicación", "b84a5b0c8a": "Elija si las cuentas de proveedor se inspeccionan y agregan en este dispositivo o en WSL.", - "d09fb5ca92": "Ubicación de la cuenta" + "d09fb5ca92": "Ubicación de cuentas", + "733f9e2a93": "Uso de MiniMax", + "f8374c3151": "Pega tu cookie de sesión de platform.minimax.io para obtener límites de uso locales." } }, "advanced": { @@ -6758,9 +6819,9 @@ "a7002e1ac4": "actualizador", "e61ed8ab33": "actualizaciones", "6576fce4d2": "solución de problemas", - "79e0947e95": "apoyo", + "79e0947e95": "soporte", "4383251647": "VPN", - "f98a60af11": "apoderado", + "f98a60af11": "proxy", "65bf6af262": "compatibilidad", "621233008b": "http/1.1", "f8ff125ebe": "http1", @@ -6770,66 +6831,66 @@ "4d44352eea": "red", "2b4d26d11e": "redes", "e04e9db503": "avanzado", - "585f56fae0": "Utilice HTTP/1.1 para redes Electron cuando HTTP/2 falle detrás de un proxy.", + "585f56fae0": "Usar HTTP/1.1 para la red de Electron cuando HTTP/2 falle detrás de un proxy.", "11eea3da72": "Compatibilidad HTTP/1.1" } }, "agents": { "search": { "2814401339": "instalado", - "719f53350c": "camino", + "719f53350c": "ruta", "839e82c81f": "detectar", "f622b8eb2a": "Linux", - "d608654c03": "wsl", - "77c02fa3c3": "ventanas", + "d608654c03": "WSL", + "77c02fa3c3": "Windows", "d2952dfd74": "ubicación", - "96ba2373b6": "agent", - "cbdd7f3b9e": "Elija si los agents instalados se detectan en este dispositivo o en WSL.", - "ef804b7337": "Ubicación del Agent", - "01926b9d8c": "Configure agents de codificación de IA, agente predeterminado y anulaciones de comandos.", + "96ba2373b6": "agente", + "cbdd7f3b9e": "Elige si los agentes instalados se detectan en este dispositivo o en WSL.", + "ef804b7337": "Ubicación del agente", + "01926b9d8c": "Configura agentes de codificación de IA, el agente predeterminado y anulaciones de comandos.", "bb9ad95777": "Agentes", - "d8f3a8b8a0": "por defecto", - "167daeb5e9": "dominio", + "d8f3a8b8a0": "predeterminado", + "167daeb5e9": "comando", "be59907510": "anular", "a6d594c17d": "instalar", "f2932bf22b": "detectado", - "2afd3b5858": "permitir", + "2afd3b5858": "habilitar", "60393e1b17": "desactivar", - "2e188c771c": "esconder", - "87fffe6c20": "espectáculo", - "e2b7c0dcd7": "github", + "2e188c771c": "ocultar", + "87fffe6c20": "mostrar", + "e2b7c0dcd7": "GitHub", "66b6b82eb4": "despierto", "dbc8aca6b0": "dormir", - "845ad9128a": "fuerza", - "48f84d10f1": "correr", - "affbf130f6": "laboral", + "845ad9128a": "energía", + "48f84d10f1": "en ejecución", + "affbf130f6": "trabajando", "0d1c334987": "tapa", - "ff8de8a2ad": "mostrar", - "0d752916f8": "manos", + "ff8de8a2ad": "pantalla", + "0d752916f8": "hooks", "6984d4291a": "estado", - "13b20636a6": "espera", + "13b20636a6": "esperando", "8599603496": "hecho", "ea71995548": "eliminar", "c1317fe641": "restaurar", "5963143e00": "ajustes", "042c551bc5": "configuración", - "f412abbba5": "claude", - "5ded38b843": "codex", + "f412abbba5": "Claude", + "5ded38b843": "Codex", "be7ea3553b": "pestaña", "6956646a1e": "título", "32836788b0": "título generado", "966890236d": "nombre", "848dcae8d3": "generado", - "52115d0d7c": "auto", - "c64059f50d": "inmediato", - "5784ae8c43": "rebautizar", + "52115d0d7c": "automático", + "c64059f50d": "prompt", + "5784ae8c43": "renombrar", "8a17fd6026": "estable", "a79d266f71": "sesión", "afbf35be68": "sesión estable", - "agentPermissions": "Agent Permissions", - "agentPermissionsDescription": "Switch agent permission defaults between Yolo and Manual.", - "agentRuntime": "Tiempo de ejecución del Agent", - "agentRuntimeDescription": "Elija si los agents se detectan y ejecutan en Windows o en WSL de forma predeterminada." + "agentPermissions": "Permisos de agentes", + "agentPermissionsDescription": "Cambia los permisos predeterminados de los agentes entre Yolo y Manual.", + "agentRuntime": "Runtime del agente", + "agentRuntimeDescription": "Elige si los agentes se detectan y ejecutan en Windows o en WSL de forma predeterminada." } }, "appearance": { @@ -6838,30 +6899,30 @@ "f586abfa35": "azul", "651f35b2c6": "conmutador", "e5bc35d59e": "ventana", - "d18b54ca90": "muelle", + "d18b54ca90": "Dock", "1f2880a9d5": "orca", "2cfb3420c0": "icono de la aplicación", "e80c2af428": "Elija el ícono de la aplicación que se muestra en el Dock y en el selector de ventanas.", "2b313598c6": "Icono de aplicación", - "839fb1e3ed": "caja de instrumento", - "ac79fe4a04": "espectáculo", - "648eeada79": "esconder", + "839fb1e3ed": "herramientas", + "ac79fe4a04": "mostrar", + "648eeada79": "ocultar", "6cf5f54ce1": "botón", "5bff6a2ef0": "barra lateral", "5e5b8878bf": "teléfono", "74618577c7": "móvil", "682293cadf": "Muestra el botón de Orca Mobile en la parte superior de la barra lateral izquierda.", - "1de96ec8a6": "Mostrar botón móvil de Orca", + "1de96ec8a6": "Mostrar botón de Orca Mobile", "4c920ab2d1": "cronograma", "58f4e22fa2": "automatización", "b186f3cefb": "automatizaciones", "ae13a0d340": "Muestra el botón Automatizaciones en la parte superior de la barra lateral izquierda.", - "caa27e1a8e": "Botón Mostrar automatizaciones", + "caa27e1a8e": "Mostrar botón de Automatizaciones", "6b846424cc": "lineal", "2ee4810f38": "github", "0d5a74b606": "tareas", "9a248333c7": "Muestra el botón Tareas en la parte superior de la barra lateral izquierda.", - "155a1e7438": "Botón Mostrar tareas", + "155a1e7438": "Mostrar botón de Tareas", "a895d0f938": "marca", "51f957ce39": "nombre", "36e006efc1": "aplicación", @@ -6870,13 +6931,13 @@ "fdd31b00d0": "Nombre de la aplicación de la barra de título", "c1bca1885a": "explorador de archivos", "9f2df826ac": "ignorado", - "08c86bf58e": "gitignorar", + "08c86bf58e": ".gitignore", "bce3ac317a": "git", "7164edf71a": "Atenúe los archivos que coincidan con .gitignore en el explorador de archivos.", "f8129fb544": "Mostrar archivos ignorados por Git", "2f12e1aa3a": "interfaz de usuario", "5095258df2": "interfaz", - "fab91464dd": "idea", + "fab91464dd": "IDE", "8b36fb3f64": "tipografía", "a0e09aed9c": "tipo de letra", "24094af355": "fuente", @@ -6886,25 +6947,25 @@ "0952091186": "escala", "3ae5de6101": "zoom", "adddb91a3d": "Escale toda la interfaz de la aplicación.", - "c5e933970f": "Ampliación de la interfaz de usuario", + "c5e933970f": "Zoom de la interfaz", "3a9b69d734": "sistema", - "44d873fd18": "luz", + "44d873fd18": "claro", "262fe1d24f": "oscuro", "0709c794f7": "Elige cómo se ve Orca en la ventana de la aplicación.", "71e06350b4": "Tema", "dc02c8759d": "espacio de trabajo", "43cfba3b95": "servidor", - "46d21eef62": "servidor local", + "46d21eef62": "localhost", "006e67b279": "puertos", "896eb53fd4": "barra de estado", - "0ececfa190": "Muestre los puertos del espacio de trabajo en vivo en la barra de estado.", + "0ececfa190": "Muestra los puertos activos de los espacios de trabajo en la barra de estado.", "cf409b6c4d": "Puertos", "cb1cc62cf8": "espacio", "90bdc043ea": "disco", "96b4fb0064": "terminal", - "4ddbde4999": "UPC", + "4ddbde4999": "CPU", "4355f18ac6": "memoria", - "9c4d5f0894": "gerente", + "9c4d5f0894": "administrador", "c690a15849": "recurso", "81ef5abc2f": "Muestra el uso de CPU, memoria, sesiones de terminal y disco del espacio de trabajo en la barra de estado.", "7cf005b29f": "Administrador de recursos", @@ -6912,34 +6973,39 @@ "f4997e0f8a": "conexión", "a278406ed5": "remoto", "6ecad74eb3": "ssh", - "f17d66d0d2": "Show remote host connection status in the status bar.", + "f17d66d0d2": "Muestra el estado de conexión de hosts remotos en la barra de estado.", "57fb424c56": "Hosts remotos", - "35565867cb": "disparo a la luna", + "35565867cb": "moonshot", "de586def95": "suscripción", "00a028f25f": "uso", "40e5c3c285": "kimi", "c927a155d5": "Muestra el uso de la suscripción de Kimi en la barra de estado.", "3a6c028ea8": "Uso de Kimi", + "25e51b62ee": "rate limit", + "d9e7cef86f": "cookie", + "d16378a88f": "minimax", + "e46178eb1b": "Muestra el uso de la suscripción de MiniMax en la barra de estado.", + "0f08f6b483": "Uso de MiniMax", "edbf0f63a0": "costo", - "afbb6a3767": "fichas", - "d77537b580": "código abierto-ir", - "a9d56852eb": "código abierto", - "7f72de7cbe": "Muestra el token OpenCode Go y el uso de costos en la barra de estado.", + "afbb6a3767": "tokens", + "d77537b580": "opencode-go", + "a9d56852eb": "opencode", + "7f72de7cbe": "Muestra el consumo de tokens y el costo de OpenCode Go en la barra de estado.", "bc046e7899": "Uso de OpenCode Go", "51b0ccd6a2": "Google", "2804a920ad": "gemini", - "9660c5b2f1": "Muestra el token Gemini y el uso de costos en la barra de estado.", + "9660c5b2f1": "Muestra el consumo de tokens y el costo de Gemini en la barra de estado.", "5bfb874d05": "Uso de Gemini", - "97957e374e": "abierto", + "97957e374e": "openai", "8dfd676c28": "codex", - "e9e4412545": "Muestra el token Codex y el uso de costos en la barra de estado.", - "54b1acf24f": "Uso del Codex", + "e9e4412545": "Muestra el consumo de tokens y el costo de Codex en la barra de estado.", + "54b1acf24f": "Uso de Codex", "dea0a9a665": "anthropic", "c9fe3a7876": "claude", - "de50c6f516": "Muestra el token de Claude y el uso de costos en la barra de estado.", + "de50c6f516": "Muestra el consumo de tokens y el costo de Claude en la barra de estado.", "9dc15020d7": "Uso de Claude", "language": { - "locale": "lugar", + "locale": "locale", "i18n": "i18n", "translation": "traducción" }, @@ -6966,24 +7032,24 @@ "rename": { "branch": { "search": { - "0971762141": "estuche-kebab", - "a482f6a423": "babosa", + "0971762141": "kebab-case", + "a482f6a423": "slug", "7adefcdd94": "plantilla", - "10485c4fc5": "dominio", - "50139297e6": "aviso incorporado", + "10485c4fc5": "comando", + "50139297e6": "prompt integrado", "502aa57681": "instrucciones", - "40d21f2efc": "inmediato", - "672387fb77": "Plantilla de comando del Agent utilizada al generar nombres de sucursales.", - "722551c5b3": "Plantilla de comando de nombre de sucursal", + "40d21f2efc": "prompt", + "672387fb77": "Plantilla de comando del agente utilizada al generar nombres de rama.", + "722551c5b3": "Plantilla de comando para nombre de rama", "f41833025e": "generar", - "ed677944cc": "árbol de trabajo", + "ed677944cc": "worktree", "3ef3cbe98c": "agent", "f0acf64301": "nombre de la criatura", "7803423877": "auto", - "55a1860e47": "rebautizar", + "55a1860e47": "renombrar", "9319bd9827": "rama", - "ea94b9da8a": "Cambie el nombre de la rama generada automáticamente según el trabajo una vez que comience un agente.", - "427f2cd1eb": "Rama de cambio de nombre automático" + "ea94b9da8a": "Renombra automáticamente la rama generada según el trabajo cuando se inicia un agente.", + "427f2cd1eb": "Renombrar rama automáticamente" } } } @@ -6991,14 +7057,14 @@ "browser": { "search": { "7539f6336c": "perfil", - "1c1e097985": "arco", - "533a253deb": "borde", - "75a0d435b7": "cromo", - "854ef6ce83": "acceso", + "1c1e097985": "Arc", + "533a253deb": "Edge", + "75a0d435b7": "Chrome", + "854ef6ce83": "inicio de sesión", "3910a41f32": "autenticación", "2e7f951773": "importar", "66dd641a47": "sesión", - "29193a51d5": "galletas", + "29193a51d5": "cookies", "2d2d995c58": "navegador", "060ac1fcba": "Importe cookies de Chrome, Edge u otros navegadores para utilizar los inicios de sesión existentes dentro de Orca.", "96afedcb5c": "Sesión y cookies", @@ -7007,9 +7073,9 @@ "68d1db8929": "markdown", "90425d313c": "cambio", "72c58f7792": "vista web", - "82ba1c80ea": "servidor local", - "bea27bac4b": "campo de golf", - "44d14df30d": "avance", + "82ba1c80ea": "localhost", + "bea27bac4b": "enlaces", + "44d14df30d": "vista previa", "5cb082b3e3": "Enrutamiento de enlaces", "95944898e0": "porcentaje", "483a0eb5e0": "nueva pestaña", @@ -7021,25 +7087,25 @@ "072b7f5c1f": "Zoom predeterminado", "0bb34eacc9": "consulta", "8b8ed06e4b": "omnibox", - "3538b3aaeb": "simbólico", + "3538b3aaeb": "token", "0732ebe6fb": "privado", "e1c2a57f07": "kagi", "ad40e75d13": "bing", - "1f8153acfb": "patopatogo", + "1f8153acfb": "DuckDuckGo", "8a489aab8d": "Google", "72b4b89970": "motor", "16bd69cd82": "buscar", "0628d5943b": "Motor de búsqueda utilizado al escribir texto que no es una URL en la barra de direcciones.", "5e755920c9": "Motor de búsqueda predeterminado", "4596a52cf7": "aterrizaje", - "5164c47e31": "blanco", + "5164c47e31": "en blanco", "4fda4fb066": "URL", "0dbb1eaf4e": "página principal", "291f480a5e": "hogar", "a942905148": "URL abierta al crear una nueva pestaña del navegador. Déjelo vacío para abrir una pestaña en blanco.", "c3903322d2": "Página de inicio predeterminada", - "19ea5607cf": "Etiquetas del árbol de trabajo de localhost", - "4e0fdf0a3f": "Abra los puertos del espacio de trabajo como URL de host local de Orca específicas del árbol de trabajo para que las pestañas del navegador sean más fáciles de distinguir." + "19ea5607cf": "Etiquetas de localhost para worktrees", + "4e0fdf0a3f": "Abre los puertos del workspace como URL localhost de Orca específicas del worktree para que las pestañas del navegador sean más fáciles de distinguir." }, "use": { "search": { @@ -7050,16 +7116,16 @@ "62e2a790c0": "sesión existente", "63a66da648": "navegador del sistema", "20c1323d1e": "uso de la computadora", - "ab349a2dd0": "arco", - "2e1b09897b": "borde", - "088e7a9012": "cromo", + "ab349a2dd0": "Arc", + "2e1b09897b": "Edge", + "088e7a9012": "Chrome", "96ce3d2de2": "autenticación", - "48557f639c": "acceso", + "48557f639c": "inicio de sesión", "d5ad1f7aad": "importar", "02837ee497": "sesión", - "fb8178824f": "galletas", + "fb8178824f": "cookies", "ba4eb53b72": "uso del navegador", - "2fb24d17db": "Importe cookies de Chrome, Edge u otros navegadores para que los agents puedan reutilizar sus inicios de sesión.", + "2fb24d17db": "Importa cookies de Chrome, Edge u otros navegadores para que los agentes puedan reutilizar tus inicios de sesión.", "614c756ab1": "Importar cookies del navegador", "cee44fb442": "automatización", "a57c2172dc": "navegador-agente", @@ -7067,17 +7133,17 @@ "f5b8fdddf5": "orca-cli", "e5a784bc54": "instalar", "9d97446873": "agent", - "a2d489263e": "habilidad", - "a7e82445fa": "Instale la habilidad Uso del navegador para que los agents puedan operar el navegador de Orca.", - "a1414dcefb": "Instalar la habilidad de uso del navegador", + "a2d489263e": "skill", + "a7e82445fa": "Instala el skill Browser Use para que los agentes puedan controlar el navegador de Orca.", + "a1414dcefb": "Instalar skill Browser Use", "e56c7b55c9": "configuración", - "034c5e8d7f": "permitir", - "7e0dcb257a": "caparazón", - "3ffafc9b95": "dominio", - "30c74aaa1f": "camino", + "034c5e8d7f": "habilitar", + "7e0dcb257a": "shell", + "3ffafc9b95": "comando", + "30c74aaa1f": "PATH", "ff05cbc344": "orca", - "85fab5e12c": "cli", - "890ddf943d": "Registre la CLI de Orca para que los agents puedan controlar el navegador.", + "85fab5e12c": "CLI", + "890ddf943d": "Registra la CLI de Orca para que los agentes puedan controlar el navegador.", "50f0860e18": "Habilitar CLI de Orca" } } @@ -7091,32 +7157,32 @@ "8e9cc598d7": "generar", "b7d50da4d8": "plantilla", "7e264b926b": "borrador", - "b261c88609": "pr", - "110be48b81": "solicitud de extracción", + "b261c88609": "PR", + "110be48b81": "pull request", "001ca3f2af": "Valores predeterminados utilizados cuando se abre el compositor Crear PR.", "eefd33788c": "Valores predeterminados de creación de PR", "d32936bb2a": "rama", "127d512e75": "commit", "d22a6459e4": "conflictos", "53e8504fb2": "ci", - "c46e665f7e": "cheques", + "c46e665f7e": "checks", "37c65bbb44": "arreglar", - "402f101af8": "inmediato", + "402f101af8": "prompt", "8e0bcc5d99": "modelo", - "f4731b22bf": "dominio", - "57c851a68c": "cli", + "f4731b22bf": "comando", + "57c851a68c": "CLI", "61117e57f3": "argumentos", "0f29331fed": "argumentos", - "18b6d38835": "Agente, argumentos CLI y plantilla de comando utilizados por cada botón AI de control de código fuente.", + "18b6d38835": "Agente, argumentos de CLI y plantilla de comando usados por cada botón de IA de control de código fuente.", "3c4e5e5938": "Recetas de acción", "ee14a9e9f7": "activado", - "82109d627d": "control de fuente", + "82109d627d": "control de código fuente", "542e1a00a7": "codex", "f121bec167": "claude", "93e5210da8": "mensaje", - "c33cb1b982": "ai", - "0b946b2abe": "Agrega recetas de acciones para acciones de commit de control de código fuente, solicitud de extracción, nombre de rama y reparación.", - "24dbdfca78": "Mostrar acciones de IA de control de código fuente" + "c33cb1b982": "IA", + "0b946b2abe": "Agrega recetas de acciones para commit, pull request, nombre de rama y correcciones de control de código fuente.", + "24dbdfca78": "Mostrar acciones de IA para control de código fuente" } } } @@ -7124,13 +7190,13 @@ "computer": { "use": { "search": { - "6e88da3508": "habilidad", + "6e88da3508": "skill", "798be54d7e": "automatización", "e27f8bafbf": "captura de pantalla", "26c1290d83": "grabación de pantalla", "82f01c2d2c": "accesibilidad", "fefb452f5b": "uso de la computadora", - "9210db582b": "Permita que los agents inspeccionen capturas de pantalla y operen aplicaciones locales cuando usted lo solicite.", + "9210db582b": "Permite que los agentes inspeccionen capturas de pantalla y controlen aplicaciones locales cuando lo pidas.", "442bec10fe": "Uso de la computadora" } } @@ -7141,13 +7207,13 @@ "3363889768": "Red local, USB y Bluetooth", "6c82846f66": "dispositivo", "11653d3f42": "mdns", - "78a10b826f": "buen día", + "78a10b826f": "bonjour", "e3fbc48083": "bluetooth", "c4a4a02ea4": "USB", "fa3239cd42": "red local", "acad3d4743": "Permitir que las herramientas del dispositivo y de la red local se utilicen desde sesiones de terminal.", - "3e0131e45d": "nube", - "ce07159ff5": "de oficina", + "3e0131e45d": "iCloud", + "ce07159ff5": "escritorio", "a0c19119fb": "descargas", "4438f81bfa": "documentos", "c10e36cbd1": "acceso completo al disco", @@ -7162,7 +7228,7 @@ "39bb49e662": "Grabación de pantalla y accesibilidad", "00e954319e": "susurro", "1e6e27b202": "ffmpeg", - "f061f08b7b": "medias", + "f061f08b7b": "sox", "a765112513": "video", "b192432ef0": "audio", "af122938a3": "voz", @@ -7171,7 +7237,7 @@ "6eca1636b7": "Permitir herramientas de captura de voz, transcripción, cámara web y medios.", "302c0c42f9": "Micrófono y cámara", "4e225e7c56": "herramientas de desarrollo", - "6db4fca386": "macos", + "6db4fca386": "macOS", "0c13b249e3": "tcc", "2270ccff3f": "privacidad", "a98aa11a9c": "permisos", @@ -7182,41 +7248,41 @@ }, "experimental": { "search": { - "44c7f209d5": "módulos_nodo", - "4ad605f222": "ambiente", + "44c7f209d5": "node_modules", + "4ad605f222": "env", "3021571c30": "compartido", - "f082788cfe": "campo de golf", + "f082788cfe": "enlaces", "3028f0bd3a": "enlace", "bff1ff7768": "enlaces simbólicos", "c387565812": "enlace simbólico", - "10b52f79c1": "árboles de trabajo", - "d23ae13990": "árbol de trabajo", + "10b52f79c1": "worktrees", + "d23ae13990": "worktree", "0d24759f14": "experimental", - "603d29ed74": "Materializa automáticamente archivos o carpetas configurados en árboles de trabajo recién creados usando APFS clone-copy en macOS cuando es posible; de lo contrario, usa enlaces simbólicos.", - "78c2a8dc74": "Rutas compartidas en árboles de trabajo", + "603d29ed74": "Materializa automáticamente archivos o carpetas configurados en worktrees recién creados usando APFS clone-copy en macOS cuando sea posible; de lo contrario, usa symlinks.", + "78c2a8dc74": "Rutas compartidas en worktrees", "7b79081695": "no leído", - "f10d307468": "terminación", + "f10d307468": "finalización", "5f067ba0f9": "agent", "7695fd30e9": "notificación", "8facf10138": "campana", - "edc49480a1": "cristal", - "268e99d957": "destacar", + "edc49480a1": "panel", + "268e99d957": "resaltado", "01567f19ca": "atención", "9bb3bd5098": "terminal", - "11877246fc": "Resaltado de panel persistente para eventos de finalización de agente y timbre de terminal.", - "9e4ddf776d": "Atención terminal", + "11877246fc": "Resaltado persistente del panel para eventos de timbre de terminal y finalización de agentes.", + "9e4ddf776d": "Atención de terminal", "fe5688b761": "barra lateral", "ca5d1f3f46": "línea de tiempo", "d01b3882ba": "notificaciones", "244a0ecd3d": "actividad", - "92a9357d1f": "vista de agents", + "92a9357d1f": "vista de agentes", "fa72e71f05": "agentes", - "4d63251595": "Feed integrado en la barra lateral izquierda para completar agentes y estados de bloqueo.", - "ccc5548ac5": "Vista de Agents", + "4d63251595": "Feed en hilos en la barra lateral izquierda para finalizaciones de agentes y estados bloqueantes.", + "ccc5548ac5": "Vista de agentes", "9af7a518db": "personaje", "791fefc0b0": "esquina", "65df471ab2": "animado", - "9f5609bfb8": "cubrir", + "9f5609bfb8": "superposición", "2a33975d72": "mascota", "b54cea709b": "compañero", "051203d37c": "mascota", @@ -7243,8 +7309,8 @@ "worktrees": "worktrees" }, "nativeChat": { - "title": "chat nativo", - "description": "Obtenga una vista previa de la superficie de chat del escritorio para las sesiones de terminal de Claude y Codex." + "title": "Chat nativo", + "description": "Previsualiza la vista de chat de escritorio para sesiones de terminal de Claude y Codex." } } }, @@ -7252,8 +7318,8 @@ "workspace": { "search": { "94f4d013c8": "barra de estado", - "a452146574": "botón de alternancia", - "6765b85e48": "directorio de inicio", + "a452146574": "botón para alternar", + "6765b85e48": "directorio inicial", "a38bfc3f77": "panel rápido", "52db6e3baf": "notas", "156ffeee08": "nota", @@ -7264,43 +7330,43 @@ "ebeedb2f6a": "terminal rápido", "6f183fa1b9": "terminal flotante", "a08e482f6d": "espacio de trabajo flotante", - "b96b5ee6cf": "Habilite el espacio de trabajo flotante, elija dónde comienzan las nuevas pestañas y elija dónde aparece el botón de alternancia.", + "b96b5ee6cf": "Habilita el espacio de trabajo flotante, elige dónde comienzan las nuevas pestañas y dónde aparece el botón para alternar.", "b2b60e7163": "Espacio de trabajo flotante" } } }, "general": { "search": { - "bdfb6dc21b": "como", - "e6b01c8e30": "comentario", - "b65665703a": "apoyo", + "bdfb6dc21b": "me gusta", + "e6b01c8e30": "comentarios", + "b65665703a": "soporte", "06ea5a69a6": "github", "e4fb4516d0": "estrella", - "e0b8c8bc25": "Apoye el proyecto con una estrella de GitHub a través de la CLI de gh.", - "36a72f0d9e": "Orca estrella en GitHub", - "c61b14be7c": "asimilar", + "e0b8c8bc25": "Apoya el proyecto con una estrella de GitHub mediante la CLI gh.", + "36a72f0d9e": "Dar una estrella a Orca en GitHub", + "c61b14be7c": "grok", "5d9ba08673": "copiloto", - "f472e97440": "ayudante", + "f472e97440": "aider", "3c30fe2d51": "gemini", "5fdf1dc2d1": "omp", "9b0bc30160": "pi", - "882c4896fd": "código abierto", + "882c4896fd": "opencode", "27d9b996ba": "codex", - "5baf51c4d9": "claudio abierto", - "aea7d2cccb": "claudia abierta", + "5baf51c4d9": "open claude", + "aea7d2cccb": "openclaude", "95b63edde7": "claude", "41c2f9a025": "por defecto", "8ea37a05bc": "agent", "e2da948f59": "Preseleccione un agente de codificación de IA en el compositor del nuevo espacio de trabajo.", "db11502270": "Agente predeterminado", - "3462308bd3": "fichas", + "3462308bd3": "tokens", "660528b048": "costo", "585beac3f8": "ttl", - "0efc9d96ad": "inmediato", - "939b80f5fd": "minutero", + "0efc9d96ad": "prompt", + "939b80f5fd": "temporizador", "b2601a778c": "caché", - "40c9585e43": "Temporizador de cuenta regresiva que muestra el tiempo hasta que caduque el caché de mensajes (agents Claude).", - "1e0f28c6f1": "Temporizador de caché rápido", + "40c9585e43": "Temporizador de cuenta regresiva que muestra el tiempo hasta que caduque la caché de prompts (agentes Claude).", + "1e0f28c6f1": "Temporizador de caché de prompts", "e49e739a59": "descargar", "c9d8c1ce66": "notas de lanzamiento", "9e86ccd05c": "versión", @@ -7309,13 +7375,13 @@ "e15af4eb64": "Buscar actualizaciones", "6382fe9724": "npx", "baa263d6d8": "agentes", - "bda108e66c": "habilidad", - "244e3fb4c8": "Instale la habilidad Orca para que los agents sepan cómo usar la CLI de Orca.", - "2d9f7b42df": "Habilidad del Agent", + "bda108e66c": "skill", + "244e3fb4c8": "Instala el skill de Orca para que los agentes sepan usar la CLI de Orca.", + "2d9f7b42df": "Skill de agente", "0a00691c06": "comando de shell", - "dbeb1f348e": "dominio", + "dbeb1f348e": "comando", "88d3df9ce9": "terminal", - "fb4f338a3d": "camino", + "fb4f338a3d": "PATH", "924a660a78": "cli", "ca529079bf": "Registre o elimine el comando CLI de Orca.", "327e3fa70d": "CLI de Orca", @@ -7326,80 +7392,80 @@ "750420dd9a": "control", "fe62b3f09f": "control", "2a254b725e": "pestaña", - "ca812803ea": "orden de tabulación reciente", - "e53d585ed6": "Tira reciente o de pestañas.", - "256d92554d": "Orden de tabulación", + "ca812803ea": "orden de pestañas recientes", + "e53d585ed6": "Recientes o tira de pestañas.", + "256d92554d": "Orden de pestañas", "22572e99c1": "anotaciones", "1ff67ba40c": "notas", - "4dd5684836": "revisar", + "4dd5684836": "revisión", "d05f629d2c": "markdown", - "694613d47f": "Muestre los controles de notas de revisión de markdowns locales en el modo de editor enriquecido.", - "128bc09325": "Notas de revisión de Markdowns", - "a0014961ae": "voluta", + "694613d47f": "Muestra los controles de notas de revisión Markdown locales en el modo de editor enriquecido.", + "128bc09325": "Notas de revisión Markdown", + "a0014961ae": "desplazamiento", "3ca5ab78a5": "código", "e3919429c0": "descripción general", "9c72990db8": "minimapa", "716a4dfb1f": "Muestra la descripción general del minimapa al editar un archivo.", "6f584fcb48": "Minimapa", "19baae651b": "barra lateral", - "973ed6bfbf": "diferencia combinada", + "973ed6bfbf": "diff combinado", "0a02059549": "árbol de archivos", "2f42852568": "árbol", - "3b5733573e": "diferencia", - "dec71988f0": "Muestre u oculte el árbol de archivos al abrir vistas de diferencias combinadas.", - "adec13f2ef": "Árbol de archivos de diferencias predeterminado", + "3b5733573e": "diff", + "dec71988f0": "Muestra u oculta el árbol de archivos al abrir vistas de diff combinado.", + "adec13f2ef": "Árbol de archivos de diff predeterminado", "be24c7cd67": "dividir", "233f7e2f37": "lado a lado", "0a5fa65926": "en línea", "2b463f0bf9": "vista", - "ecb9415c80": "Formato de presentación preferido para mostrar diferencias de git de forma predeterminada.", - "2760c9933f": "Vista de diferencias predeterminada", + "ecb9415c80": "Formato de presentación preferido para mostrar diffs de git de forma predeterminada.", + "2760c9933f": "Vista de diff predeterminada", "b2799ba622": "milisegundos", "146728ac2c": "demora", "86f54575c7": "guardado automático", "8ea61ad55c": "Cuánto tiempo espera Orca después de su última edición antes de guardar automáticamente.", "14e46c745b": "Retraso de guardado automático", - "4469b6fa4e": "ahorrar", - "e9d948d3c3": "Guarde el editor y los cambios de diferencias editables automáticamente después de una breve pausa.", + "4469b6fa4e": "guardar", + "e9d948d3c3": "Guarda automáticamente los cambios del editor y de diffs editables después de una breve pausa.", "ae21e806ce": "Guardar archivos automáticamente", "c56cb6f1c2": "red", "3566fce83f": "servidor local", "91a46caafc": "no_proxy", "3a73054565": "derivación", - "20b711ac9e": "apoderado", - "eb8946b2c9": "Hosts que deberían omitir el proxy HTTP configurado.", + "20b711ac9e": "proxy", + "eb8946b2c9": "Hosts que deben omitir el proxy HTTP configurado.", "8436ff6f8e": "Reglas de omisión de proxy", - "e55d62dfa4": "plataforma de lanzamiento", - "9da6c875e5": "muelle", + "e55d62dfa4": "launchpad", + "9da6c875e5": "dock", "b9096a44cf": "https_proxy", "8f03d44672": "http_proxy", - "e3b1d42f95": "URL proxy para solicitudes de red Orca y terminales secundarios locales.", + "e3b1d42f95": "URL del proxy para solicitudes de red de Orca y procesos de terminal locales secundarios.", "c29f23ab57": "Proxy HTTP", "6c2ce8457c": "explorador de archivos", - "c9d9636f24": "descubridor", - "68d03d9980": "código vs", + "c9d9636f24": "finder", + "68d03d9980": "vscode", "ebf8f056b5": "zed", "0cb3d94f00": "cursor", - "8fb00fcd05": "lanzacohetes", + "8fb00fcd05": "lanzador", "e1ee631696": "editor", "5a9df5566f": "abrir menú", "b8093e9a93": "abrir en", - "a916662068": "Elija las aplicaciones disponibles en el menú Abrir en de un espacio de trabajo.", - "451d4af994": "Abrir en aplicaciones", + "a916662068": "Elige las apps disponibles en el menú Abrir en de un espacio de trabajo.", + "451d4af994": "Apps de Abrir en", "7e9b556873": "saltar", "ca86dd6e27": "diálogo", "9f8558233a": "confirmar", "7edf4f69e2": "automatización", "84c67d0108": "borrar", "a0c44061ee": "Muestra un cuadro de diálogo de confirmación antes de eliminar una automatización y su historial de ejecución.", - "d0a65b27fd": "Pregunte antes de eliminar automatizaciones", - "df10666259": "árbol de trabajo", + "d0a65b27fd": "Preguntar antes de eliminar automatizaciones", + "df10666259": "worktree", "ae98c9cf36": "Muestra un cuadro de diálogo de confirmación antes de eliminar un espacio de trabajo.", "913242091d": "Preguntar antes de eliminar espacios de trabajo", "93f6ec5e70": "directorio", "9bde064915": "subcarpeta", "ec5049e510": "anidado", - "b9cffd374d": "Cree espacios de trabajo dentro de una subcarpeta con nombre de repo.", + "b9cffd374d": "Crea espacios de trabajo dentro de una subcarpeta con el nombre del repositorio.", "141f71c69f": "Espacios de trabajo anidados", "7887a2c262": "carpeta", "7baf524b04": "espacio de trabajo", @@ -7407,8 +7473,10 @@ "4c95d08fa2": "Directorio de espacio de trabajo", "161a86a9da": "Confirmar antes de cerrar pestañas fijadas", "8e593f04fc": "Muestra un diálogo de confirmación antes de cerrar una pestaña fijada.", - "defaultProjectRuntime": "Default Project Runtime", - "defaultProjectRuntimeDescription": "Choose the runtime inherited by local Windows projects." + "defaultProjectRuntime": "Runtime de proyecto predeterminado", + "defaultProjectRuntimeDescription": "Elige el runtime que heredarán los proyectos locales de Windows.", + "d2d2d929c0": "Corrector ortográfico de Rich Markdown", + "4497e2e2bb": "Muestra subrayados y sugerencias ortográficas del navegador al editar Rich Markdown." } }, "git": { @@ -7417,46 +7485,46 @@ "1b93c1143c": "atribución", "8461c908ae": "coautor", "61f9f5d1fc": "coautor", - "af0a144bfb": "asunto", - "6bdea421bb": "pr", + "af0a144bfb": "issue", + "6bdea421bb": "PR", "16f53f7323": "gh", "d088806071": "github", - "118c23484b": "Agregue la atribución de Orca a commits, PR y problemas.", + "118c23484b": "Agrega la atribución de Orca a commits, PR e issues.", "bc7d9f69ce": "Atribución de Orca", "40f9b815fd": "presupuesto API", "b7e52124c7": "límite de tasa", - "ead733645f": "glabro", + "ead733645f": "glab", "4808f065b3": "gitlab", - "2b4a72885d": "Encabezados de límite de velocidad REST de GitLab CLI actuales cuando estén disponibles.", + "2b4a72885d": "Encabezados actuales de límite de tasa REST de la CLI de GitLab cuando estén disponibles.", "83ecb3f470": "Presupuesto de la API de GitLab", - "65b69d9f80": "graficoql", + "65b69d9f80": "graphql", "1139f61512": "Límites de velocidad actuales de GitHub CLI REST, Search y GraphQL.", "ff86e354c4": "Presupuesto de la API de GitHub", - "035134fcd9": "árbol de trabajo", - "0c75583ca9": "sin peligro", - "bae91effdd": "base fresca", + "035134fcd9": "worktree", + "0c75583ca9": "de forma segura", + "bae91effdd": "base actualizada", "de06e9d105": "referencia base", - "ab0e22c9f6": "actualizar local principal", - "d9f70d51a0": "principal obsoleto", - "0849b571fe": "A hoy", - "c41e345153": "detrás principal", - "6ee3cfff02": "diferencia git", - "564942ffc5": "origen/principal", - "28192e3a63": "maestro", - "e3e9adde59": "principal", - "0e993bf00f": "Cuando crea un espacio de trabajo, Orca actualiza la base remota y adelanta de forma segura su rama local coincidente, como principal o maestra. Esto evita que comandos como git diff main...HEAD se comparen con el historial obsoleto. Orca omite la actualización si esa rama tiene cambios no confirmados o commits solo locales.", - "f8bda25f29": "Mantenga actualizado el principal local", - "769ddd7f81": "costumbre", + "ab0e22c9f6": "actualizar main local", + "d9f70d51a0": "main obsoleta", + "0849b571fe": "actualizada", + "c41e345153": "por detrás de main", + "6ee3cfff02": "git diff", + "564942ffc5": "origin/main", + "28192e3a63": "master", + "e3e9adde59": "main", + "0e993bf00f": "Cuando creas un espacio de trabajo, Orca actualiza la base remota y adelanta de forma segura la rama local correspondiente, como main o master. Esto evita que comandos como git diff main...HEAD se comparen con un historial obsoleto. Orca omite la actualización si esa rama tiene cambios sin commit o commits solo locales.", + "f8bda25f29": "Mantener main local actualizada", + "769ddd7f81": "personalizado", "1d2fae1fa2": "nombre de usuario git", - "f83c8937c4": "denominación de sucursales", - "5ecd91c5ef": "Prefijo agregado a los nombres de las ramas al crear árboles de trabajo.", + "f83c8937c4": "nombres de ramas", + "5ecd91c5ef": "Prefijo agregado a los nombres de ramas al crear worktrees.", "68bd65fdb8": "Prefijo de rama", "sourceControlGroupOrderTitle": "Orden de grupos de control de código fuente", "sourceControlGroupOrderDescription": "Elige si Cambios, Cambios preparados o Archivos sin seguimiento aparecen primero en Control de código fuente.", "groupOrder": "orden de grupos", "changesFirst": "cambios primero", "stagedFirst": "cambios preparados primero", - "untrackedFirst": "sin seguimiento primero", + "untrackedFirst": "archivos sin seguimiento primero", "sourceControl": "control de código fuente", "gitChanges": "cambios de git", "compareAgainstUpstreamTitle": "Base de comparación predeterminada", @@ -7479,15 +7547,15 @@ "26c83b06c5": "Linux", "71905435dd": "x11", "7059cfb00a": "portapapeles", - "c4440c3986": "pasta", - "5fb84ba77f": "ratón del medio", + "c4440c3986": "pegar", + "5fb84ba77f": "botón central del mouse", "31ba58c8ae": "clic central", "de51e18ee9": "selección primaria", "e5cd0e7a46": "selección", "e25165320e": "edición", - "b51d47ceb7": "aporte", + "b51d47ceb7": "entrada", "874d88f4a6": "Habilitado de forma predeterminada en Linux y macOS. Linux usa el portapapeles de selección del sistema; otras plataformas utilizan un búfer privado.", - "d952ce9b46": "Haga clic con el botón central en Pegar desde la selección" + "d952ce9b46": "Pegar desde la selección con clic central" } }, "integrations": { @@ -7496,31 +7564,31 @@ "3c3d3d8ffa": "conectar", "faa0b5a0d9": "clave API", "c450244ad7": "integración", - "7319e3015b": "lineal", - "16a486a49d": "Conecte Linear para explorar y vincular problemas.", + "7319e3015b": "linear", + "16a486a49d": "Conecta Linear para explorar y vincular issues.", "b027b4b318": "Integración Linear", - "20540996ef": "cartas credenciales", + "20540996ef": "credenciales", "2ec2bd328c": "token de API", - "7345b7c3e6": "atlasiano", + "7345b7c3e6": "atlassian", "e1263dd748": "jira", - "76f6af7c57": "Conecte Jira Cloud o actualice las credenciales del token API de Jira.", + "76f6af7c57": "Conecta Jira Cloud o actualiza las credenciales del token de API de Jira.", "617603509b": "Integración de Jira", - "8c568d761c": "solicitud de extracción", - "33180e8c10": "autohospedado", + "8c568d761c": "pull request", + "33180e8c10": "self-hosted", "129fc59aa8": "gitea", "d0d019dc29": "Autenticación de Gitea a través de variables de entorno de token API.", "aab86d64e5": "Integración de Gitea", - "03a7b275be": "alharaca", - "ed63380247": "repos azules", - "b38b5d27f1": "devops azules", + "03a7b275be": "ado", + "ed63380247": "azure repos", + "b38b5d27f1": "azure devops", "7b1f3984bb": "Autenticación de Azure DevOps Repos mediante variables de entorno de token.", "af6611fa6e": "Integración de Azure DevOps", "50d20817f7": "bitbucket", "c97d58a0f3": "Autenticación de Bitbucket Cloud a través de variables de entorno de token API.", "67a2a0e868": "Integración de Bitbucket", - "371ee914d2": "solicitud de fusión", - "581844769a": "señor", - "b40cbe5de4": "glabro", + "371ee914d2": "merge request", + "581844769a": "MR", + "b40cbe5de4": "glab", "b939695c69": "gitlab", "6e2ab619c6": "Autenticación de GitLab a través de la CLI glab.", "b50b71ef9d": "Integración de GitLab", @@ -7533,10 +7601,10 @@ "jira": { "integration": { "card": { - "8ff73fef62": "Los tokens de Jira están cifrados por el tiempo de ejecución activo y almacenados localmente. Volver a ingresar la misma URL del sitio y el mismo correo electrónico reemplaza el token API de ese sitio.", + "8ff73fef62": "Los tokens de Jira están cifrados por el runtime activo y se almacenan localmente. Volver a ingresar la misma URL del sitio y el mismo correo electrónico reemplaza el token de API de ese sitio.", "9046a20d4c": "Desconectar {{value0}}", "eaffa454e9": "Actualizar", - "cec06a0f79": "Pruebas…", + "cec06a0f79": "Probando…", "ab350991b8": "Verificado", "d914d7ab70": "Verificando…", "5936977fcd": "Cancelar", @@ -7544,19 +7612,19 @@ "1ab7f551f3": "Token API de Atlassian", "09d310e42d": "tu@ejemplo.com", "27dae4ab60": "https://example.atlassian.net", - "a28f417220": "Conecta Jira", + "a28f417220": "Conectar Jira", "9bb34706ca": "Conectado", "efaab83c5d": "Agregar sitio", "09742875cd": "Jira", "255bfe98ec": "Prueba", "5fb1562315": "error", - "3df81cb0ac": "OK", + "3df81cb0ac": "ok", "2e8bb790fd": "Conectar", "33a8b261ee": "Actualizar credenciales", "d5c5b47bb9": "actualizar", "fb854902d9": "conectando", - "9a9f8d4910": "Conecte Jira Cloud para explorar, crear y vincular incidencias.", - "74f3063026": "{{value0}} sitio{{value1}} conectado" + "9a9f8d4910": "Conecta Jira Cloud para explorar, crear y vincular issues.", + "74f3063026": "{{value0}} sitio{{value1}} con conexión" } } }, @@ -7564,67 +7632,67 @@ "emulator": { "search": { "2348045036": "Elija qué dispositivo emulador abre Orca de forma predeterminada.", - "2bb2e09225": "habilidad móvil", + "2bb2e09225": "skill móvil", "bbe4267416": "tipo de emulador", "64494f03c3": "adjuntar emulador", - "6f728f1456": "grifo del emulador", - "f8b871d655": "agente cli", - "2e0b45b2ba": "Utilice los comandos de Orca CLI para enumerar, adjuntar, tocar y escribir en un emulador móvil.", - "ea3eac39bb": "Control CLI del Agent", - "8ef0f08d36": "tiempo de ejecución", - "27397fe8e9": "herramientas de línea de comando xcode", + "6f728f1456": "tap del emulador", + "f8b871d655": "CLI de agente", + "2e0b45b2ba": "Usa comandos de Orca CLI para listar, adjuntar, tocar y escribir en un emulador móvil.", + "ea3eac39bb": "Control por CLI de Agent", + "8ef0f08d36": "runtime", + "27397fe8e9": "herramientas de línea de comandos de Xcode", "7650063d17": "simctl", "3211e7acf9": "xcrun", "42bfab45d8": "disponibilidad", - "ea1f51b980": "Compruebe si los dispositivos Xcode, simctl, server-sim y emulador están listos.", + "ea1f51b980": "Comprueba si Xcode, simctl, serve-sim y los dispositivos emuladores están listos.", "0b95dfd5b3": "Disponibilidad del emulador", "04c5f5d901": "dispositivo", - "25d7bfbcd4": "udid", - "ec3c4043fd": "ipad predeterminado", + "25d7bfbcd4": "UDID", + "ec3c4043fd": "iPad predeterminado", "1dc8c52ffa": "iPhone predeterminado", "ab4814f3c5": "simulador predeterminado", "54184cb9c5": "Dispositivo emulador predeterminado", "b8ddd13195": "emulador de agente", "1ad6fb6230": "dispositivo predeterminado", - "ac0a985873": "habilidad del emulador", - "9353854ff3": "emulador de orcas", - "d4b7833894": "orca cli", - "84e5706975": "Simulación de servicio", - "7c5a8a2bee": "código x", + "ac0a985873": "skill del emulador", + "9353854ff3": "emulador de Orca", + "d4b7833894": "Orca CLI", + "84e5706975": "serve-sim", + "7c5a8a2bee": "Xcode", "bec7231663": "iPad", "49727355a3": "iPhone", "6b6407dc1f": "emulador", "2d67f708ce": "simulador", - "c5eca29310": "simulador de ios", + "c5eca29310": "simulador de iOS", "25159de808": "emulador móvil", - "9595354cff": "Configure la compatibilidad con el emulador móvil para Orca y agents de codificación.", + "9595354cff": "Configura la compatibilidad con el emulador móvil para Orca y agentes de codificación.", "cdd3c31918": "Emulador móvil" } }, "pane": { "search": { - "dbccde3a60": "cerca", - "9e16be01d6": "fondo", - "3a5e31e84b": "dejar", - "8015fd9523": "sostener", - "aa3f736042": "cambiar el tamaño", + "dbccde3a60": "cerrar", + "9e16be01d6": "segundo plano", + "3a5e31e84b": "salir", + "8015fd9523": "mantener", + "aa3f736042": "redimensionar", "356c31d6dc": "ancho", "fadcbfdd99": "adaptar", "ad08035c5f": "teléfono", "6cd2bfdb0e": "restaurar", "b34ad5b3a7": "terminal", "6db86f445f": "móvil", - "707fc78052": "Elija qué sucede con los terminales que estaba viendo en el móvil después de cerrar la aplicación o salir.", - "1e711aca11": "Cuando sales de la aplicación móvil", + "707fc78052": "Elige qué sucede con los terminales que estabas viendo en el móvil después de cerrar la aplicación o cambiar a otra.", + "1e711aca11": "Al salir de la aplicación móvil", "126afc5dbd": "remoto", "70f505f3c3": "LAN", "1802188b5d": "wifi", - "dd6e671aa9": "DIRECCIÓN", + "dd6e671aa9": "dirección", "1f70d63998": "IP", - "d0c89bc4a9": "cubrir", + "d0c89bc4a9": "overlay", "87711f4b8f": "VPN", - "16bff559a0": "red de cola", - "c690e3ee38": "escala de cola", + "16bff559a0": "tailnet", + "c690e3ee38": "Tailscale", "a023683767": "interfaz", "7b37c2e557": "red", "3190ef67a4": "Elija qué dirección de red usar para el emparejamiento móvil.", @@ -7636,9 +7704,9 @@ "13419718b3": "Administrar dispositivos móviles emparejados.", "9d3a9397ba": "Dispositivos conectados", "2128a21096": "escanear", - "e518cbd61c": "par", + "e518cbd61c": "emparejar", "4a0c826f3d": "código", - "3c1807a81a": "qr", + "3c1807a81a": "QR", "7fb728fb2b": "Empareje un dispositivo móvil escaneando un código QR.", "d49925710a": "Emparejamiento móvil" } @@ -7647,16 +7715,16 @@ "search": { "b730ff7049": "experimental", "8d4ba0ef09": "beta", - "6bfa001752": "apk", - "a7eececc1d": "androide", + "6bfa001752": "APK", + "a7eececc1d": "Android", "7e801801ac": "remoto", "0b7e585cb9": "escanear", "59b1d75fd1": "código", - "87816d1c59": "qr", - "cf2c93b479": "par", + "87816d1c59": "QR", + "cf2c93b479": "emparejar", "f4ed142753": "teléfono", "f213400800": "móvil", - "671eb4173c": "Controla terminales y agents desde tu teléfono.", + "671eb4173c": "Controla terminales y agentes desde tu teléfono.", "ffd52a96e4": "Móvil", "1de96ec8a6": "Mostrar botón de Orca Mobile", "682293cadf": "Mostrar el botón de Orca Mobile en la parte superior de la barra lateral izquierda." @@ -7667,17 +7735,17 @@ "search": { "aa288005c3": "prueba", "ca8faa40d7": "notificaciones", - "4e30b1925e": "Active una notificación de escritorio de muestra utilizando la ruta de entrega nativa.", + "4e30b1925e": "Activa una notificación de escritorio de muestra usando el mecanismo nativo de entrega.", "ef9b311346": "Enviar notificación de prueba", - "ecdeff4993": "volumen", + "ecdeff4993": "sonoridad", "d58b64dddf": "volumen", "dc7d7c07cd": "sonido", "eeb6f77322": "Volumen de reproducción para sonidos de notificación que no pertenecen al sistema.", "aace1a62c6": "Volumen de notificaciones", "ef86a782cc": "bong", - "3014ad1b8f": "timbre", - "079c29aeb5": "flaco", - "722face52f": "acac", + "3014ad1b8f": "ding", + "079c29aeb5": "flac", + "722face52f": "aac", "6ecb8418cb": "m4a", "d16ae23645": "ogg", "57e34a31cd": "wav", @@ -7685,48 +7753,48 @@ "6e08f78315": "audio", "c718793e95": "Elija el archivo de audio integrado, del sistema o local que Orca reproduce para las notificaciones de escritorio.", "ea8cb8d9ce": "Sonido de notificación", - "4ada6bfde9": "filtración", - "fa60d8e4ab": "reprimir", + "4ada6bfde9": "filtrado", + "fa60d8e4ab": "suprimir", "a4c3b29a3c": "enfocado", - "7247b97a31": "Evite notificar cuando Orca esté enfocado en el árbol de trabajo activo.", - "96562a72c6": "Suprimir mientras estás concentrado", + "7247b97a31": "Evita enviar notificaciones cuando Orca esté enfocado en el worktree activo.", + "96562a72c6": "Suprimir mientras está enfocado", "a2ab73b325": "atención", "ae0487f8fd": "campana", "c638ae989d": "terminal", - "d3f1c48677": "Notificar cuando un terminal en segundo plano emite un carácter de campana.", - "a5edee1d99": "Campana terminal", + "d3f1c48677": "Notifica cuando un terminal en segundo plano emita un carácter de campana.", + "a5edee1d99": "Campana del terminal", "193e1f107c": "tarea", "dd9d3e5f0f": "idle", - "5f7472d3fb": "completo", + "5f7472d3fb": "completado", "7fa07e9600": "agent", - "10d83ef8dc": "Notificar cuando un agente de codificación pasa de estar en funcionamiento a estar inactivo.", - "bdc1edaeb4": "Tarea del Agent completada", + "10d83ef8dc": "Notifica cuando un agente de codificación pase de estar trabajando a estar inactivo.", + "bdc1edaeb4": "Tarea de Agent completada", "adbc3a0fcf": "nativo", "72539aede4": "sistema", - "51ae2183e1": "de oficina", + "51ae2183e1": "escritorio", "0534c76311": "Interruptor maestro para notificaciones de escritorio de Orca.", "4a210b2f72": "Habilitar notificaciones" } }, "orchestration": { "search": { - "f5d39af41e": "agents infantiles", - "c766a01978": "manos libres", + "f5d39af41e": "agentes secundarios", + "c766a01978": "handoff", "08c65b12a2": "ejemplos", "f278fd04db": "codex", "32c5098e7b": "claude", "21c28ccdf7": "coordinador", - "741dfc03fa": "obrero", - "ca54c69806": "TROZO DE CUERO", + "741dfc03fa": "worker", + "ca54c69806": "DAG", "7ad948b714": "tarea", - "eee028ae14": "despacho", + "eee028ae14": "dispatch", "9a5ebdca31": "mensajería", "91fc8ab7e5": "coordinación", "13ba5c6cbd": "agentes", - "d86705ba77": "multiagente", + "d86705ba77": "multi-agent", "a7f76b4ca7": "orquestación", - "e05ff36753": "Coordine múltiples agents de codificación a través de mensajes, DAG de tareas, despacho y puertas de decisión.", - "c34045764e": "Orquestación de Agents" + "e05ff36753": "Coordina varios agentes de codificación mediante mensajería, DAG de tareas, dispatch y controles de decisión.", + "c34045764e": "Orquestación de agentes" } }, "privacy": { @@ -7734,28 +7802,28 @@ "3922051573": "datos", "e8bc614a18": "desactivar", "d8191ae5ca": "variable de entorno", - "94e04427f6": "ambiente", + "94e04427f6": "env", "664f1a8984": "integración continua", - "5854a5c752": "ci", - "69637f4dc4": "orca_telemetría_disabled", - "058550f6bc": "no_seguimiento", + "5854a5c752": "CI", + "69637f4dc4": "orca_telemetry_disabled", + "058550f6bc": "do_not_track", "83a6cd79b3": "no rastrear", "f7a2d9f137": "Variables de entorno que deshabilitan la transmisión de telemetría.", "e058a3c98d": "Variables de entorno de telemetría", - "1686c07fee": "apoyo", - "c0494ff48a": "diagnóstico", + "1686c07fee": "soporte", + "c0494ff48a": "diagnósticos", "8b08f32366": "Diagnósticos de la aplicación y controles para compartir con soporte.", - "6d258d2ed6": "Diagnóstico", + "6d258d2ed6": "Diagnósticos", "ead1deded2": "compartir", "27a27b2f63": "optar por no participar", - "4d4bb76bf4": "optar por", + "4d4bb76bf4": "optar por participar", "b021b9cb81": "anónimo", "79c319948b": "uso", - "77d3180def": "telemetria", + "77d3180def": "telemetría", "b707cc3981": "Ayude a mejorar Orca enviando eventos anónimos de uso de funciones.", "57b283461a": "Compartir datos de uso anónimos", "2b5a5c312f": "posthog", - "4104f6f0f3": "analítica", + "4104f6f0f3": "analíticas", "10124159f1": "privacidad", "aa3b794c17": "Datos anónimos de uso del producto, diagnósticos y controles de telemetría.", "5c508bad41": "Privacidad y telemetría" @@ -7764,20 +7832,20 @@ "quick": { "commands": { "search": { - "3c316e6ef8": "hilo", - "b86c727100": "mpn", + "3c316e6ef8": "yarn", + "b86c727100": "npm", "b949a7c0a0": "pnpm", - "0b78c4a165": "lanzamiento", - "2d8aff42be": "correr", + "0b78c4a165": "iniciar", + "2d8aff42be": "ejecutar", "1c5bdcd0f2": "repositorio", - "89d2a9ad9f": "repo", + "89d2a9ad9f": "repositorio", "f58b92a48f": "proyecto", "8bf43c2dad": "global", - "a26ecdb77b": "retazo", + "a26ecdb77b": "snippet", "d07d130849": "atajo", "0073cf8ce9": "terminal", "cfffa6cdb6": "comandos", - "fecb031823": "dominio", + "fecb031823": "comando", "236d4cfac8": "rápido", "d691c4e8d8": "Comandos de terminal guardados que se pueden ejecutar desde cualquier terminal, con alcance global o para un proyecto específico.", "4c8945952b": "Comandos rápidos" @@ -7786,21 +7854,21 @@ }, "repository": { "search": { - "bc7e504b8e": ".orca/comando-edición", + "bc7e504b8e": ".orca/issue-command", "603c68b68c": "orca.yaml", "9dc60d7f6d": "github", "ec70364df2": "flujo de trabajo", - "66b584bd6c": "emitir comando", - "2011a6a4f2": "comando de problema de github", - "d42d1e49c0": "Comando de problema vinculado basado en archivos configurado a través de orca.yaml y anulación local opcional.", - "d86ea12d16": "Comando de problema personalizado de GitHub", + "66b584bd6c": "comando de Issue", + "2011a6a4f2": "comando de Issue de GitHub", + "d42d1e49c0": "Comando de Issue vinculado basado en archivo configurado mediante orca.yaml y una anulación local opcional.", + "d86ea12d16": "Comando de Issue personalizado de GitHub", "c5e8bdbcbb": "omitir por defecto", "a69c5cbe90": "ejecutar por defecto", "80c490b012": "preguntar", "f9d84b7971": "política de ejecución de configuración", "c00a549e03": "Elija el comportamiento predeterminado cuando haya un script de configuración disponible.", "cdfe398068": "Cuándo ejecutar la configuración", - "5e9445bbfd": "autorizado", + "5e9445bbfd": "autoritativa", "f1e1bfa89f": "fuente", "1d90a6cfbb": "ambos", "fcb8fa8144": "compartido", @@ -7810,19 +7878,19 @@ "d141897c90": "Fuente del comando y detalles de orca.yaml.", "cc11699c3d": "Avanzado", "bf460fded8": "yaml", - "9cad92fe77": "ganchos orca.yaml", + "9cad92fe77": "hooks de orca.yaml", "6b80f7d3c8": "scripts de configuración local", - "a1a4c51d58": "comando de archivo", - "fbfd2386e8": "script de archivo", - "4c17787d7b": "archivo", - "8655e3387b": "manos", - "acd1157f0c": "Scripts locales y compartidos que se ejecutan antes de archivar un árbol de trabajo.", - "bce0ca23c6": "Guión de archivo", + "a1a4c51d58": "comando de archivado", + "fbfd2386e8": "script de archivado", + "4c17787d7b": "archivar", + "8655e3387b": "hooks", + "acd1157f0c": "Scripts locales y compartidos que se ejecutan antes de archivar un worktree.", + "bce0ca23c6": "Script de archivado", "491b05d6e6": "comando de configuración", - "a31b43a7f8": "guión de configuración", + "a31b43a7f8": "script de configuración", "5590388dfa": "configuración", - "baaf70bb37": "Scripts locales y compartidos que se ejecutan después de crear un nuevo árbol de trabajo.", - "b79df26937": "Guión de configuración", + "baaf70bb37": "Scripts locales y compartidos que se ejecutan después de crear un nuevo worktree.", + "b79df26937": "Script de configuración", "d73fb47b45": ".claude/mcp.json", "db11b337c4": ".claude.json", "26f42fe773": ".cursor/mcp.json", @@ -7831,23 +7899,23 @@ "343f0a508c": "mcp", "3c31801626": "Inspeccione los archivos de configuración del servidor MCP a nivel de proyecto.", "31bd0a2420": "Configuraciones de MCP", - "84da7fa2d7": "módulos_nodo", - "0a3a582794": "ambiente", + "84da7fa2d7": "node_modules", + "0a3a582794": "env", "3c180a251c": "enlace", - "f1c53f2820": "árbol de trabajo", - "7e228fc439": "enlaces simbólicos", - "c06adcf136": "enlace simbólico", - "ed885e589f": "Rutas para materializar desde el checkout principal en árboles de trabajo recién creados.", - "01b3377ebc": "Rutas compartidas del árbol de trabajo", - "fff8834983": "inmediato", + "f1c53f2820": "worktree", + "7e228fc439": "symlinks", + "c06adcf136": "symlink", + "ed885e589f": "Rutas para materializar desde el checkout principal en worktrees recién creados.", + "01b3377ebc": "Rutas compartidas del worktree", + "fff8834983": "prompt", "fa3131f223": "modelo", - "130d76dc16": "rebautizar", - "917dce844a": "nombre de la sucursal", - "8068d8d0f1": "pr", - "5ff7fe1ade": "solicitud de extracción", + "130d76dc16": "renombrar", + "917dce844a": "nombre de rama", + "8068d8d0f1": "PR", + "5ff7fe1ade": "pull request", "eec39b3de6": "mensaje de commit", - "cfad7ce5f3": "ai", - "a47f51127e": "control de fuente", + "cfad7ce5f3": "IA", + "a47f51127e": "control de código fuente", "6cc5c65e64": "Anulaciones de generación de git específicas del proyecto.", "eec3995dc6": "Autor de Git AI", "cc876ca5f2": "repositorio", @@ -7859,26 +7927,26 @@ "4e2529722c": "directorios", "1ff4f12c0c": "directorio", "9f5ae26ccd": "preajustes", - "095fca94fe": "programar", - "aa42616e3d": "verificar", - "4f3c0230c2": "escaso", - "90a331fd68": "Conjuntos de directorios guardados para la creación de árboles de trabajo dispersos.", - "1f0f20bbb6": "Ajustes preestablecidos de pago dispersos", - "4733ec2395": "../árboles de trabajo", + "095fca94fe": "preset", + "aa42616e3d": "checkout", + "4f3c0230c2": "sparse", + "90a331fd68": "Conjuntos de directorios guardados para crear worktrees sparse.", + "1f0f20bbb6": "Presets de sparse checkout", + "4733ec2395": "../worktrees", "58d8bca414": "relativo", "a325a89dff": "ruta del espacio de trabajo", - "f3e6dee5fe": "ruta del árbol de trabajo", - "cd33a5525e": "Directorio específico del proyecto para nuevos árboles de trabajo.", - "443d127b5a": "Ubicación del árbol de trabajo", + "f3e6dee5fe": "ruta del worktree", + "cd33a5525e": "Directorio específico del proyecto para nuevos worktrees.", + "443d127b5a": "Ubicación del worktree", "9811f3d152": "rama", - "f41cef5083": "referencia base", - "f571081ec4": "Rama base o referencia predeterminada al crear árboles de trabajo.", - "094adbe930": "Base de árbol de trabajo predeterminada", + "f41cef5083": "ref base", + "f571081ec4": "Rama base o ref predeterminada al crear worktrees.", + "094adbe930": "Base predeterminada del worktree", "27733eb6c1": "favicon", - "1e73e840ff": "emojis", + "1e73e840ff": "emoji", "cb4b4de666": "avatar", "c1075178cf": "insignia", - "6d8de2f090": "maleficio", + "6d8de2f090": "hex", "8d045419b1": "color", "b2546efab5": "icono del repositorio", "6438a94c63": "icono de proyecto", @@ -7888,16 +7956,16 @@ "92af66c7ce": "nombre del proyecto", "883aad2801": "Detalles de visualización específicos del proyecto para la barra lateral y las pestañas.", "7e1e456a95": "Nombre para mostrar", - "availableHosts": "Available Hosts", - "availableHostsDescription": "Hosts where this project is set up.", + "availableHosts": "Hosts disponibles", + "availableHostsDescription": "Hosts donde está configurado este proyecto.", "host": "host", "ssh": "ssh", "remote": "remote", "vm": "vm", "keepForkUpToDate": "Mantener el fork actualizado", "keepForkUpToDateDescription": "Avanza este fork de forma segura desde upstream con fast-forward.", - "projectRuntime": "Project Runtime", - "projectRuntimeDescription": "Choose whether this project runs on Windows or WSL.", + "projectRuntime": "Runtime del proyecto", + "projectRuntimeDescription": "Elige si este proyecto se ejecuta en Windows o WSL.", "copy": "copy", "clone": "clone", "apfs": "apfs" @@ -7912,15 +7980,15 @@ "5cd7dca3b8": "remoto", "d760866285": "cliente", "09568ccc65": "servidor", - "ebd5369acf": "ambiente", - "d198440ce3": "tiempo de ejecución", + "ebd5369acf": "entorno", + "d198440ce3": "runtime", "baec27aa8f": "Conecte este navegador a un servidor Orca guardado.", - "3517fb2ec0": "Servidor activo", - "c6e5a03aa0": "caja de desarrollo", + "3517fb2ec0": "Servidores Orca remotos", + "c6e5a03aa0": "dev box", "f1575f1e09": "cliente web", "81444c4102": "URL de emparejamiento", "104f4d7dbd": "emparejamiento", - "4575341c77": "Elija el escritorio local, agregue un servidor Orca remoto guardado o genere una URL de emparejamiento." + "4575341c77": "Agrega un servidor Orca remoto guardado, genera una URL de emparejamiento o ajusta el runtime predeterminado avanzado." } } }, @@ -7930,61 +7998,61 @@ "4811a8264a": "terminal primero", "afda131738": "orca primero", "0ecfc47434": "conflicto", - "0f8cb15582": "agent", - "f1adebbe8c": "caparazón", - "7f1b38f59a": "tui", + "0f8cb15582": "agente", + "f1adebbe8c": "shell", + "7f1b38f59a": "TUI", "7e3fc707aa": "terminal", "0ecba9aa5f": "teclado", - "ebd7d81e1d": "Elija si Orca o el terminal enfocado gana cuando los atajos se superponen.", - "f052906167": "Atajos en la Terminal" + "ebd7d81e1d": "Elige si Orca o el terminal con foco tiene prioridad cuando los atajos se superponen.", + "f052906167": "Atajos en terminal" } }, "ssh": { "search": { - "d41f296f64": "silbido", + "d41f296f64": "ping", "237b391f7c": "conexión", - "8cb870b109": "prueba", - "7efd17e816": "ssh", + "8cb870b109": "probar", + "7efd17e816": "SSH", "96ca5d9a0b": "Pruebe la conectividad con un destino SSH.", - "a3058f3605": "Conexión de prueba", - "2cd40ba0d0": "anfitriones", + "a3058f3605": "Probar conexión", + "2cd40ba0d0": "hosts", "5220501141": "configuración", "3b12e064a4": "importar", - "7f251a45a8": "Importe hosts desde ~/.ssh/config.", - "41a3127094": "Importar desde configuración SSH", + "7f251a45a8": "Importa hosts desde ~/.ssh/config.", + "41a3127094": "Importar desde SSH config", "f9493b80c0": "servidor", - "8fb1cc87cc": "anfitrión", + "8fb1cc87cc": "host", "09395490af": "objetivo", "00d1fda01a": "nuevo", "f7b6383aec": "agregar", - "62826efbe9": "Agregue un nuevo destino SSH remoto.", + "62826efbe9": "Agrega un nuevo host SSH.", "f5a691bb6c": "Agregar destino SSH", "d4bcd497c7": "remoto", - "74c6d90d78": "Administre objetivos SSH remotos.", + "74c6d90d78": "Administra hosts SSH.", "380a788da7": "Conexiones SSH" } }, "tasks": { "search": { - "58cda6f9c0": "esconder", + "58cda6f9c0": "ocultar", "44083ae418": "mostrar", - "604d8e4089": "atlasiano", + "604d8e4089": "Atlassian", "5430396e11": "jira", - "412ec3c702": "lineal", + "412ec3c702": "Linear", "11f001cdd4": "gitlab", "c10ac2125e": "github", "3d81c26d78": "fuente", "cf0e3e0c2f": "proveedor", "2ec54bee51": "tareas", - "765f0c544d": "Elija qué proveedores de tareas aparecen en la página Tareas y en los accesos directos de la barra lateral.", + "765f0c544d": "Elige qué proveedores de tareas aparecen en la página Tareas y en los accesos directos de la barra lateral.", "5b8e4aace5": "Proveedores de tareas" } }, "terminal": { "clipboard": { "search": { - "5fb3512e8c": "pasta", - "a38508c419": "Copiar", + "5fb3512e8c": "pegar", + "a38508c419": "copiar", "d106f44fb4": "remoto", "043b32faa1": "ssh", "9fda309db9": "fzf", @@ -7994,21 +8062,21 @@ "10d73e22d3": "portapapeles", "9dfc125cd3": "osc52", "62d1208b90": "osc 52", - "459fea094a": "Permita que los programas en la terminal se copie al portapapeles del sistema a través de OSC 52, incluso a través de SSH.", - "74db8721e4": "Permitir escrituras en el portapapeles TUI (OSC 52)", - "4043e294d2": "gnomo", + "459fea094a": "Permite que los programas del terminal copien al portapapeles del sistema mediante OSC 52, incluso por SSH.", + "74db8721e4": "Permitir escrituras TUI en el portapapeles (OSC 52)", + "4043e294d2": "GNOME", "cf83ac3dbd": "Linux", "737cef6de1": "x11", "e87c6d776d": "automático", "664789b73a": "auto", "c38c18be15": "selección", "797fdfe4ca": "seleccionar", - "603818e8d8": "Copie automáticamente las selecciones de terminal al portapapeles tan pronto como se realice una selección.", + "603818e8d8": "Copia automáticamente las selecciones del terminal al portapapeles en cuanto se hace una selección.", "3bdc84f059": "Copiar al seleccionar" } }, "search": { - "c047f398cc": "lanzamiento", + "c047f398cc": "iniciar", "b872de3926": "ubicación", "fd6c24313d": "nuevo", "f44643328e": "pestaña", @@ -8016,36 +8084,36 @@ "54a9b3725b": "horizontal", "de7bc1d5f5": "dividir", "7a48c7715b": "espacio de trabajo", - "6b659fff2a": "guion", + "6b659fff2a": "script", "4529806908": "configuración", - "2610ee3b56": "Dónde se ejecuta el script de configuración del repositorio cuando se crea un nuevo espacio de trabajo: una división vertical (predeterminada), una división horizontal o una pestaña de fondo titulada 'Configuración'.", + "2610ee3b56": "Dónde se ejecuta el script de configuración del repositorio cuando se crea un nuevo workspace: una división vertical (predeterminada), una división horizontal o una pestaña en segundo plano titulada 'Setup'.", "5be2d67678": "Ubicación del script de configuración", "0ce176909a": "tema", "4ba8623632": "paleta", "11fd3fbcf2": "ansi", "d8bd6182b8": "anular", "674b7c8436": "color", - "3023e01415": "Anula los colores de terminales individuales.", + "3023e01415": "Anula colores individuales del terminal.", "aed2a4b4eb": "Anulaciones de color", "6eaf7ee0e4": "cursor", - "34fe1af39d": "mecanografía", + "34fe1af39d": "escritura", "ee611ae238": "esconder", "ea364ce6e4": "ratón", - "77201c0bb2": "Oculte el cursor del mouse al escribir en la terminal.", - "d1fe5f99ff": "Ocultar el mouse mientras escribe", + "77201c0bb2": "Oculta el cursor del mouse al escribir en el terminal.", + "d1fe5f99ff": "Ocultar el mouse al escribir", "f25d948664": "margen", "b2f52cb96c": "espaciado", "e8baf0d12c": "relleno", "4655567c37": "Relleno vertical alrededor de la cuadrícula del terminal en píxeles.", - "692c4ad032": "Acolchado vertical", + "692c4ad032": "Relleno vertical", "75691e4911": "Relleno horizontal alrededor de la cuadrícula del terminal en píxeles.", - "b4f182f24d": "Acolchado horizontal", - "6c2f9f05c8": "vitalidad", + "b4f182f24d": "Relleno horizontal", + "6c2f9f05c8": "vibrancy", "4f7f8f28ca": "transparencia", "f6dd9ff606": "fondo", "71eb45e293": "difuminar", "0838b3717b": "ventana", - "bc2054657a": "Aplique desenfoque de fondo a la ventana del terminal. Requiere reinicio.", + "bc2054657a": "Aplica desenfoque de fondo a la ventana del terminal. Requiere reiniciar.", "72d0482137": "Desenfoque de ventana", "7db59c4738": "alfa", "46d99ef4bb": "opacidad", @@ -8055,23 +8123,23 @@ "88561b3499": "congelado", "0a05629060": "recuperar", "f66a7cf715": "terminal", - "6892fb1019": "Reanudar", - "cde233f5da": "desplazarse hacia atrás", + "6892fb1019": "reiniciar", + "cde233f5da": "scrollback", "3982d88725": "historia", - "456da64d4d": "claro", - "920573d65b": "matar a todos", - "a3e5297c10": "matar", + "456da64d4d": "borrar", + "920573d65b": "finalizar todo", + "a3e5297c10": "finalizar", "a8d2784214": "administrar", "d802a578bf": "sesiones", "9f2dda133c": "pty", - "f35400f7e8": "demonio", - "f72abc493c": "Recupérese de terminales congeladas finalizando sesiones, borrando el desplazamiento hacia atrás guardado o reiniciando el demonio.", + "f35400f7e8": "servicio", + "f72abc493c": "Recupérate de terminales congelados finalizando sesiones, borrando el scrollback guardado o reiniciando el servicio.", "6f5d486a68": "Administrar sesiones", "10f9fb6fea": "ajustes", "2ade3ea490": "configuración", "fd752b3cac": "importar", - "82b63d07fe": "fantasmal", - "73e9422f19": "Importación única de configuraciones de terminal Ghostty compatibles.", + "82b63d07fe": "ghostty", + "73e9422f19": "Importación única de ajustes de terminal compatibles de Ghostty.", "a979df0083": "Importar desde Ghostty", "4cec42dbf7": "internacional", "b495dc6a9f": "jis", @@ -8105,92 +8173,92 @@ "rows": "filas", "f7d56b6281": "Filas de terminal de escritorio retenidas.", "7674e758e1": "Filas de desplazamiento hacia atrás", - "411229c636": "luz", + "411229c636": "claro", "781f49d942": "divisor", - "77d9f9cd55": "Controla la línea divisoria dividida entre paneles en modo claro.", - "595b97b446": "Color del divisor de luz", - "7718d70356": "avance", + "77d9f9cd55": "Controla la línea divisoria entre paneles en modo claro.", + "595b97b446": "Color del divisor claro", + "7718d70356": "vista previa", "1dee533bd9": "Elige el tema utilizado cuando Orca está en modo claro.", - "1d89457764": "Tema ligero", - "da864e6cec": "modo de luz", + "1d89457764": "Tema claro", + "da864e6cec": "modo claro", "f268092ee3": "El modo claro puede usar su propio tema de terminal.", "match_dark_mode_title": "Coincidir con modo oscuro", "f785374072": "oscuro", "9c32726f47": "Controla la línea divisoria entre paneles en modo oscuro.", - "8987db7ff2": "Color divisor oscuro", - "13f6310dd3": "Elija el tema del terminal utilizado en modo oscuro.", + "8987db7ff2": "Color del divisor oscuro", + "13f6310dd3": "Elige el tema del terminal usado en modo oscuro.", "ec07ce9b02": "Tema oscuro", "f036794286": "activo", - "846a7a1204": "cristal", - "d1fa00a9cb": "flotar", + "846a7a1204": "panel", + "d1fa00a9cb": "pasar el cursor", "b5116e7b12": "sigue", - "f5d1e3d472": "enfocar", - "17cc3ea102": "Al pasar el cursor sobre un panel de terminal, se activa sin necesidad de hacer clic. Refleja la configuración de enfoque que sigue al mouse de Ghostty. Las selecciones y el cambio de ventana se mantienen seguros.", - "c6178a2b4d": "El foco sigue al ratón", - "f637a7dee9": "espesor", + "f5d1e3d472": "foco", + "17cc3ea102": "Al pasar el cursor sobre un panel de terminal, se activa sin tener que hacer clic. Refleja la configuración focus-follows-mouse de Ghostty. Las selecciones y el cambio de ventana se mantienen seguros.", + "c6178a2b4d": "El foco sigue al mouse", + "f637a7dee9": "grosor", "e58d4040d0": "Grosor de la línea divisoria del panel.", "2d5ab88b7c": "Grosor del divisor", "6c4c85ba43": "oscurecimiento", - "18dd5026c6": "Opacidad aplicada a paneles que no están actualmente activos.", + "18dd5026c6": "Opacidad aplicada a los paneles que no están activos actualmente.", "72bbcbd1dd": "Opacidad del panel inactivo", - "d4f7d1ce5c": "Opacidad del cursor terminal.", + "d4f7d1ce5c": "Opacidad del cursor del terminal.", "7f1e356a54": "Opacidad del cursor", - "25f606d9e5": "parpadear", + "25f606d9e5": "parpadeo", "a27f6edf52": "Utiliza la variante parpadeante de la forma del cursor seleccionada.", "b03d01fd49": "Cursor parpadeante", "eefd1d8332": "subrayar", - "015c82349f": "bloquear", - "a6e9dcc829": "bar", - "275a9d6395": "Apariencia predeterminada del cursor para los paneles del terminal Orca.", + "015c82349f": "bloque", + "a6e9dcc829": "barra", + "275a9d6395": "Apariencia predeterminada del cursor para los paneles de terminal de Orca.", "97bcfff662": "Forma del cursor", "1abcf4d7de": "Linux", "7d924d870d": "gráficos", - "bc7ae1f7c0": "representación", + "bc7ae1f7c0": "renderizado", "fffa9ab980": "renderizador", "6cddc858ba": "webgl", "4b4e80d850": "aceleración", "db82cb13b0": "GPU", "8f9f953de7": "Controla si el terminal utiliza la representación WebGL de xterm.js. Auto prueba WebGL cuando el renderizador es compatible, con respaldo conservador para software o renderizadores de GPU desconocidos.", "13a2502dfc": "Aceleración de GPU", - "d5e6c7fab1": "características de la fuente", - "a16224d16a": "calto", + "d5e6c7fab1": "características de fuente", + "a16224d16a": "calt", "6ded6297fe": "iosevka", - "e3aeea308e": "código cascada", - "35c2311a33": "cerebros jet mono", - "7f7640c29e": "codigo fira", + "e3aeea308e": "cascadia code", + "35c2311a33": "jetbrains mono", + "7f7640c29e": "fira code", "7ab424c4d3": "ligadura", "afc8d5f790": "ligaduras", "103cdb862f": "tipografía", - "893aa92997": "Renderice ligaduras de programación (por ejemplo, => → ≠ ≥) para las fuentes que las incluyen. \"Auto\" habilita ligaduras solo para fuentes de ligadura conocidas (Fira Code, JetBrains Mono, Cascadia Code, Iosevka, etc.).", + "893aa92997": "Renderiza ligaduras de programación (p. ej., => → ≠ ≥) para las fuentes que las incluyen. \"Auto\" habilita ligaduras solo para fuentes con ligaduras conocidas (Fira Code, JetBrains Mono, Cascadia Code, Iosevka, etc.).", "58da1ae45d": "Ligaduras de fuentes", - "7341e3d00e": "altura de la línea", - "36a1b38bc8": "Controla el multiplicador de altura de la línea terminal.", + "7341e3d00e": "altura de línea", + "36a1b38bc8": "Controla el multiplicador de altura de línea del terminal.", "0f2fb0cb74": "Altura de línea", "20ce287cc6": "peso", - "98c18f2c77": "Controla el peso de la fuente del texto del terminal.", + "98c18f2c77": "Controla el grosor de la fuente del texto del terminal.", "28ea41bd2d": "Peso de fuente", "b0bb76ae6b": "fuente", - "0acdc17891": "Familia de fuentes de terminal predeterminada para nuevos paneles y actualizaciones en vivo.", + "0acdc17891": "Familia de fuentes predeterminada del terminal para nuevos paneles y actualizaciones en vivo.", "e989914ad6": "Familia de fuentes", "33031c1465": "tamaño del texto", - "0fe0073f0c": "Tamaño de fuente de terminal predeterminado para nuevos paneles y actualizaciones en vivo.", + "0fe0073f0c": "Tamaño de fuente predeterminado del terminal para nuevos paneles y actualizaciones en vivo.", "5930244899": "Tamaño de fuente", "warp_import": { "title": "Importar desde Warp", - "description": "Import Warp themes as Orca terminal themes.", + "description": "Importa temas de Warp como temas de terminal de Orca.", "keyword_warp": "warp", - "keyword_themes": "themes", + "keyword_themes": "temas", "keyword_yaml": "yaml", - "keyword_legacy_title": "Import themes from Warp" + "keyword_legacy_title": "Importar temas desde Warp" }, "yaml_import": { - "title": "Import from YAML", - "description": "Import theme YAML files as Orca terminal themes.", + "title": "Importar desde YAML", + "description": "Importa archivos YAML de temas como temas de terminal de Orca.", "keyword_yaml": "yaml", - "keyword_custom": "custom" + "keyword_custom": "personalizado" }, - "ask_before_closing_running_terminals_title": "Ask Before Closing Running Terminals", - "ask_before_closing_running_terminals_description": "Show a confirmation before closing a terminal that has a running command or agent.", + "ask_before_closing_running_terminals_title": "Preguntar antes de cerrar terminales en ejecución", + "ask_before_closing_running_terminals_description": "Muestra una confirmación antes de cerrar un terminal que tiene un comando o agente en ejecución.", "scrollSpeed": { "title": "Velocidad de desplazamiento", "description": "Ajusta el desplazamiento normal del terminal, el desplazamiento rápido con modificador y la velocidad de rueda en TUI de pantalla completa." @@ -8204,13 +8272,13 @@ "windows": { "search": { "4d09141a42": "menú contextual", - "fcfa53920b": "pasta", + "fcfa53920b": "pegar", "e55186fe2b": "clic derecho", - "28ff08ed35": "ventanas", + "28ff08ed35": "Windows", "e7d2793b03": "terminal", - "8ba875c132": "En Windows, haga clic derecho y pegue el portapapeles en la terminal. Utilice Ctrl+clic derecho para abrir el menú contextual.", - "f0b8448570": "Haga clic derecho para pegar", - "04994f6929": "por defecto", + "8ba875c132": "En Windows, hacer clic derecho pega el portapapeles en el terminal. Usa Ctrl+clic derecho para abrir el menú contextual.", + "f0b8448570": "Clic derecho para pegar", + "04994f6929": "predeterminado", "fc564eadaf": "debian", "4ee2579c32": "ubuntu", "5074ad8b5f": "distribución", @@ -8223,16 +8291,16 @@ "4af2f7526e": "versión", "d414022016": "pwsh", "768613e483": "powershell 7", - "f9162f0b8e": "powershell de windows", + "f9162f0b8e": "windows powershell", "2d99cd91be": "powershell", "41a69bc24d": "Elija si la opción de shell de PowerShell inicia Windows PowerShell o PowerShell 7+ para nuevos paneles de terminal.", "860e0e6402": "Versión de PowerShell", "07ec155fb6": "bash.exe", - "5a2db98d23": "intento", + "5a2db98d23": "bash", "591912177b": "git bash", "12519edb5d": "símbolo del sistema", "6cd20b9e64": "cmd", - "7c7056940a": "caparazón", + "7c7056940a": "shell", "713c4a2f92": "Elija el shell predeterminado para los nuevos paneles de terminal en Windows.", "13715f9d23": "Shell predeterminado" } @@ -8243,23 +8311,23 @@ "search": { "f6e0dfa61c": "nube", "2d206de105": "clave API", - "04c25a6fb0": "abierto", + "04c25a6fb0": "openai", "b9dee49cd7": "descargar", "10d45a9fce": "stt", - "3d8b853963": "discurso", + "3d8b853963": "voz", "080202facb": "modelo", "7640ed9848": "voz", - "56defcd6c3": "Seleccione un modelo de voz a texto local o en la nube para usarlo en el dictado.", - "7e62cd7c41": "Modelo de habla", - "931b1a9e53": "empujar para hablar", - "064a9bd94a": "sostener", - "6fa48bcd41": "palanca", + "56defcd6c3": "Selecciona un modelo de voz a texto local o en la nube para usarlo en el dictado.", + "7e62cd7c41": "Modelo de voz", + "931b1a9e53": "pulsar para hablar", + "064a9bd94a": "mantener pulsado", + "6fa48bcd41": "alternar", "d86f5600da": "modo", "089d31a45b": "dictado", - "748b33e531": "Alternar o mantener presionado el comportamiento de dictado.", + "748b33e531": "Alterna el dictado o mantén pulsado para hablar.", "6a3abb4338": "Modo de dictado", "e360027a65": "micrófono", - "698376a38d": "Alternancia maestra para funciones de dictado de voz.", + "698376a38d": "Interruptor principal para las funciones de dictado por voz.", "20574cbc72": "Habilitar dictado de voz", "322d457a0d": "transcripción", "dcc7846641": "Configure la clave API de OpenAI utilizada para los modelos de voz a texto en la nube.", @@ -8272,15 +8340,15 @@ "action": { "recipe": { "options": { - "commitMessage": "Genere el mensaje de commit a partir de cambios preparados.", - "pullRequest": "Genere el título y la descripción de la reseña alojada.", - "branchName": "Cambie el nombre de las ramas creadas por Orca desde la tarea inicial del agente.", - "fixCommitFailure": "Inicie un agente cuando falle un enlace de commit o una commit de git.", - "fixChecks": "Inicie un agente a partir de comprobaciones fallidas de revisión alojada.", - "resolveConflicts": "Inicie un agente para conflictos de fusión de revisión local o alojada.", + "commitMessage": "Genera el mensaje de commit a partir de cambios staged.", + "pullRequest": "Genera el título y la descripción de la review alojada.", + "branchName": "Renombra ramas creadas por Orca a partir de la tarea inicial del agente.", + "fixCommitFailure": "Inicia un agente cuando falla un hook de commit o un git commit.", + "fixChecks": "Inicia un agente a partir de checks fallidos de una review alojada.", + "resolveConflicts": "Inicia un agente para conflictos de merge locales o de una review alojada.", "customCommand": "Comando personalizado", "supportedAgents": "Agentes compatibles con esta receta: {{value0}}.", - "unsupportedSavedAgent": "{{value0}} no puede ejecutar esta receta de generación de texto. Seleccione uno de los agentes compatibles a continuación.", + "unsupportedSavedAgent": "{{value0}} no puede ejecutar esta acción de generación de texto. Elige uno de los agentes compatibles a continuación.", "resolveComments": "Inicia un agente a partir de comentarios de PR o MR sin resolver seleccionados." } } @@ -8288,78 +8356,78 @@ } }, "agent-awake-copy": { - "e5995ce268": "Mantener la computadora activa mientras los agents están trabajando", - "95d3031db2": "Mantiene esta computadora y pantalla activas mientras los agents están trabajando. El comportamiento de cierre de la tapa sigue la configuración de energía de este dispositivo.", - "a42f6fbdd8": "Mantiene esta computadora y pantalla activas mientras los agents están trabajando. Orca también le pide a este dispositivo que permanezca despierto cuando la tapa está cerrada, sujeto a su política de energía." + "e5995ce268": "Mantener la computadora activa mientras los agentes están trabajando", + "95d3031db2": "Mantiene esta computadora y la pantalla activas mientras los agentes están trabajando. El comportamiento al cerrar la tapa sigue la configuración de energía de este dispositivo.", + "a42f6fbdd8": "Mantiene esta computadora y la pantalla activas mientras los agentes están trabajando. Orca también le pide a este dispositivo que permanezca activo cuando la tapa está cerrada, sujeto a su política de energía." }, "agent-status-hooks-copy": { - "7707c15abb": "Ganchos de estado del Agent", - "a68a642835": "Muestra los estados de trabajo, espera y finalización en Orca. Apague para quitar los ganchos administrados por Orca y deje de reinstalarlos." + "7707c15abb": "Hooks de estado del agente", + "a68a642835": "Muestra los estados trabajando, esperando y completado en Orca. Desactívalo para quitar los hooks administrados por Orca y dejar de reinstalarlos." }, "agent-generated-tab-title-copy": { "19ad21615a": "Generar títulos de pestañas automáticamente", - "b036c7a409": "Derive nombres breves de pestañas estables a partir del primer mensaje de agente conocido. Los cambios de nombre manuales siempre ganan." + "b036c7a409": "Deriva nombres de pestañas breves y estables a partir del primer prompt conocido del agente. Los cambios de nombre manuales siempre tienen prioridad." }, "WarpThemeImportModal": { "title": "Importar desde Warp", - "description": "Import Warp themes as Orca terminal themes.", - "yaml_title": "Import theme YAML", - "yaml_description": "Import theme YAML files (Warp format) as Orca terminal themes.", - "yaml_no_themes_found": "No themes found in the selected files.", - "choose_file": "Choose File", - "choose_folder": "Choose Folder", - "loading": "Loading Warp themes...", - "found_theme_one": "Found 1 theme", - "found_theme_other": "Found {{value0}} themes", - "found_in_source": " in {{value0}}", - "clear_all": "Clear all", - "select_all": "Select all", - "colors_only": "Colors only", - "no_themes_found": "No custom Warp themes found.", - "builtin_themes_hint": "Warp's preloaded themes are part of the Warp app and can't be read from disk. Orca already includes most of them, like Dracula, Gruvbox, Solarized, and Tokyo Night.", - "custom_theme_yaml_hint": "Los temas personalizados y de la comunidad deben existir como archivos YAML en una carpeta de temas de Warp para que la importación automática los encuentre. Si clonaste el repositorio público de temas de Warp, usa Choose Folder para importar esa copia.", - "choose_manually": "Choose a theme YAML file or folder to import manually.", - "skipped_files": "Skipped files", - "more_skipped_files": "{{value0}} more skipped files.", + "description": "Importa temas de Warp como temas de terminal de Orca.", + "yaml_title": "Importar YAML de tema", + "yaml_description": "Importa archivos YAML de temas (formato Warp) como temas de terminal de Orca.", + "yaml_no_themes_found": "No se encontraron temas en los archivos seleccionados.", + "choose_file": "Elegir archivo", + "choose_folder": "Elegir carpeta", + "loading": "Cargando temas de Warp...", + "found_theme_one": "Se encontró 1 tema", + "found_theme_other": "Se encontraron {{value0}} temas", + "found_in_source": " en {{value0}}", + "clear_all": "Borrar selección", + "select_all": "Seleccionar todo", + "colors_only": "Solo colores", + "no_themes_found": "No se encontraron temas personalizados de Warp.", + "builtin_themes_hint": "Los temas preinstalados de Warp forman parte de la app de Warp y no se pueden leer desde el disco. Orca ya incluye la mayoría, como Dracula, Gruvbox, Solarized y Tokyo Night.", + "custom_theme_yaml_hint": "Los temas personalizados y de la comunidad deben existir como archivos YAML en una carpeta de temas de Warp para que la importación automática los encuentre. Si clonaste el repositorio público de temas de Warp, usa Elegir carpeta para importar esa copia.", + "choose_manually": "Elige un archivo YAML de tema o una carpeta para importarlo manualmente.", + "skipped_files": "Archivos omitidos", + "more_skipped_files": "{{value0}} archivos omitidos más.", "cancel": "Cancelar", - "import_theme_one": "Import 1 Theme", - "import_theme_other": "Import {{value0}} Themes", - "import_themes": "Import Themes" + "import_theme_one": "Importar 1 tema", + "import_theme_other": "Importar {{value0}} temas", + "import_themes": "Importar temas" }, "useWarpThemeImport": { - "unknown_error": "Unknown error", - "imported_one": "Imported 1 theme", - "imported_other": "Imported {{value0}} themes", - "import_failed": "Failed to import themes", - "over_limit_one": "Importing these themes would exceed the {{value0}} custom terminal theme limit. Deselect 1 new theme and try again.", - "over_limit_other": "Importing these themes would exceed the {{value0}} custom terminal theme limit. Deselect {{value1}} new themes and try again." + "unknown_error": "Error desconocido", + "imported_one": "Se importó 1 tema", + "imported_other": "Se importaron {{value0}} temas", + "import_failed": "No se pudieron importar los temas", + "over_limit_one": "Importar estos temas superaría el límite de {{value0}} temas personalizados de terminal. Deselecciona 1 tema nuevo e inténtalo de nuevo.", + "over_limit_other": "Importar estos temas superaría el límite de {{value0}} temas personalizados de terminal. Deselecciona {{value1}} temas nuevos e inténtalo de nuevo." }, "YamlThemeImportButton": { - "label": "Import from YAML" + "label": "Importar desde YAML" }, "cli": { "source": { "control": { "integration": { "cards": { - "d5b3be8ecd": "Re-check", - "8cbc39f862": "Learn more", + "d5b3be8ecd": "Volver a comprobar", + "8cbc39f862": "Más información", "707180d09c": "glab auth login", - "4be0616873": "The GitLab CLI is installed but not authenticated. Run this command in a terminal:", - "54a640af7a": "Install GitLab CLI", - "b56fd5676a": "Install the GitLab CLI to enable merge requests, issues, and pipelines.", - "faddeb763d": "GitLab CLI status is not available in this runtime yet.", + "4be0616873": "La CLI de GitLab está instalada, pero no autenticada. Ejecuta este comando en un terminal:", + "54a640af7a": "Instalar la CLI de GitLab", + "b56fd5676a": "Instala la CLI de GitLab para habilitar merge requests, issues y pipelines.", + "faddeb763d": "El estado de la CLI de GitLab aún no está disponible en este runtime.", "a47f71e357": "CLI.", "2a6b359e75": "glab", - "1f2b347bd3": "Merge requests, issues, todos, and pipelines via the", + "1f2b347bd3": "Merge requests, issues, todos y pipelines mediante la", "8d90249d22": "gh auth login", - "2e44dda68a": "The GitHub CLI is installed but not authenticated. Run this command in a terminal:", - "7755c28af5": "Install GitHub CLI", - "23cb5a0dee": "Install the GitHub CLI to enable pull requests, issues, and checks.", - "6f30fc4216": "GitHub CLI status is not available in this runtime yet.", + "2e44dda68a": "La CLI de GitHub está instalada, pero no autenticada. Ejecuta este comando en un terminal:", + "7755c28af5": "Instalar la CLI de GitHub", + "23cb5a0dee": "Instala la CLI de GitHub para habilitar pull requests, issues y checks.", + "6f30fc4216": "El estado de la CLI de GitHub aún no está disponible en este runtime.", "6b2cfb52b4": "gh", - "b4d900e7f1": "Pull requests, issues, and checks via the", - "account_scope_prefix": "Account scope" + "b4d900e7f1": "Pull requests, issues y checks mediante la", + "account_scope_prefix": "Alcance de la cuenta" } } } @@ -8369,27 +8437,27 @@ "tracker": { "integration": { "cards": { - "c90f2ef419": "Re-check", - "dd3529015d": "Disconnect {{value0}}", - "8b2408a8e5": "Jira is connected for this runtime. Re-check if the connected site list looks stale.", - "8c20e76308": "Each connected Jira site has one token stored by the active runtime.", - "c24e56c532": "Test", - "3e7c10d286": "Testing...", - "a2c0015fb8": "Verified", - "e2ff968276": "Connect Jira", - "60996beda6": "Add Jira site", - "7ca5ffffdb": "Browse, create, and start work from Jira Cloud issues.", - "a1093a06c7": "Checking Jira access before showing setup actions.", - "9fa04a032e": "{{value0}} site{{value1}} connected", - "cef18762a2": "Add access with a Personal API key from your Linear settings. Full-access keys can see every team the key owner can reach.", - "6224fe9d34": "Each connected Linear workspace has one key stored by the active runtime. Full-access keys can cover all teams the key owner can access; restricted keys can be replaced any time.", - "1a12e33fe5": "Add Linear access", - "622c224082": "Add workspace access", - "eae4a9f16b": "Add Linear access to browse and link issues.", - "fe9231215b": "Checking Linear access before showing setup actions.", - "e1f5e6424c": "{{value0}} workspace{{value1}} connected", + "c90f2ef419": "Volver a comprobar", + "dd3529015d": "Desconectar {{value0}}", + "8b2408a8e5": "Jira está conectado para este runtime. Vuelve a comprobar si la lista de sitios conectados parece desactualizada.", + "8c20e76308": "Cada sitio de Jira conectado tiene un token almacenado por el runtime activo.", + "c24e56c532": "Probar", + "3e7c10d286": "Probando...", + "a2c0015fb8": "Verificado", + "e2ff968276": "Conectar Jira", + "60996beda6": "Agregar sitio de Jira", + "7ca5ffffdb": "Explora, crea y empieza a trabajar desde issues de Jira Cloud.", + "a1093a06c7": "Comprobando el acceso a Jira antes de mostrar las acciones de configuración.", + "9fa04a032e": "Conexión completada para {{value0}} sitio{{value1}}", + "cef18762a2": "Agrega acceso con una clave API personal desde tus ajustes de Linear. Las claves de acceso completo pueden ver todos los equipos a los que puede acceder el propietario de la clave.", + "6224fe9d34": "Cada workspace de Linear conectado tiene una clave almacenada por el runtime activo. Las claves de acceso completo pueden cubrir todos los equipos a los que puede acceder el propietario de la clave; las claves restringidas se pueden reemplazar en cualquier momento.", + "1a12e33fe5": "Agregar acceso a Linear", + "622c224082": "Agregar acceso a workspace", + "eae4a9f16b": "Agrega acceso a Linear para explorar y vincular issues.", + "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": "Account scope" + "account_scope_prefix": "Alcance de la cuenta" } } } @@ -8399,55 +8467,55 @@ "control": { "integration": { "cards": { - "793a06e899": "Re-check", - "1a9475dace": "Learn more", - "19fb419c12": "Gitea credentials are configured but could not authenticate. Check the token, API base URL, and repository permissions, then restart Orca if environment variables changed.", - "60708f23da": "only when Orca cannot derive the API URL from the remote.", + "793a06e899": "Volver a comprobar", + "1a9475dace": "Más información", + "19fb419c12": "Las credenciales de Gitea están configuradas, pero no se pudo autenticar. Comprueba el token, la URL base de la API y los permisos del repositorio; después reinicia Orca si cambiaron las variables de entorno.", + "60708f23da": "solo cuando Orca no pueda derivar la URL de la API desde el remote.", "709057ad91": "ORCA_GITEA_API_BASE_URL", - "6da9dfa5de": "for private repositories, and set", + "6da9dfa5de": "para repositorios privados, y configura", "6d5c2a3005": "ORCA_GITEA_TOKEN", - "fcbe0469fd": "Public repositories are detected from their git remote. Set", - "0613928cb3": "Gitea status is not available in this runtime yet.", - "05863d2599": "Pull requests and commit statuses via the Gitea REST API.", - "52f75876be": "Pull requests and commit statuses for detected repositories", - "0b5242f8a2": "{{value0}} · Pull requests and commit statuses", - "40f678df73": "Azure DevOps credentials are configured but could not authenticate. Check the token, API base URL, and repository permissions, then restart Orca if environment variables changed.", - "7bd345e3f6": "only when Orca cannot derive the API base URL from the git remote.", + "fcbe0469fd": "Los repositorios públicos se detectan desde su remote de git. Configura", + "0613928cb3": "El estado de Gitea aún no está disponible en este runtime.", + "05863d2599": "Pull requests y estados de commit mediante la API REST de Gitea.", + "52f75876be": "Pull requests y estados de commit para los repositorios detectados", + "0b5242f8a2": "{{value0}} · Pull requests y estados de commit", + "40f678df73": "Las credenciales de Azure DevOps están configuradas, pero no se pudo autenticar. Comprueba el token, la URL base de la API y los permisos del repositorio; después reinicia Orca si cambiaron las variables de entorno.", + "7bd345e3f6": "solo cuando Orca no pueda derivar la URL base de la API desde el remote de git.", "186a6689df": "ORCA_AZURE_DEVOPS_API_BASE_URL", - "b8a10b07c1": ". Set", + "b8a10b07c1": ". Configura", "fbfd237f5e": "ORCA_AZURE_DEVOPS_ACCESS_TOKEN", - "087feb92f1": ", or set", + "087feb92f1": ", o configura", "48842720d2": "ORCA_AZURE_DEVOPS_TOKEN", - "7bbc9c64f0": "Set", - "f3f47dc7de": "Azure DevOps status is not available in this runtime yet.", - "0eb50d5593": "Pull requests and build statuses via Azure DevOps REST API tokens.", - "54636c65d4": "Pull requests and build statuses for detected Azure Repos", - "ea204f5e03": "{{value0}} · Pull requests and build statuses", - "6154b02093": "Bitbucket credentials are configured but could not authenticate. Check the token and repository permissions, then restart Orca if environment variables changed.", + "7bbc9c64f0": "Configura", + "f3f47dc7de": "El estado de Azure DevOps aún no está disponible en este runtime.", + "0eb50d5593": "Pull requests y estados de build mediante tokens de la API REST de Azure DevOps.", + "54636c65d4": "Pull requests y estados de build para Azure Repos detectados", + "ea204f5e03": "{{value0}} · Pull requests y estados de build", + "6154b02093": "Las credenciales de Bitbucket están configuradas, pero no se pudo autenticar. Comprueba el token y los permisos del repositorio; después reinicia Orca si cambiaron las variables de entorno.", "e63fe8f627": "ORCA_BITBUCKET_ACCESS_TOKEN", "19416c874c": "ORCA_BITBUCKET_API_TOKEN", - "fc71a0e7aa": "and", + "fc71a0e7aa": "y", "63a7f47392": "ORCA_BITBUCKET_EMAIL", - "24ac1c69dc": "Bitbucket status is not available in this runtime yet.", - "a924e8dcd1": "Pull requests and build statuses via Bitbucket Cloud API tokens.", - "0fa5629dad": "Pull requests and build statuses" + "24ac1c69dc": "El estado de Bitbucket aún no está disponible en este runtime.", + "a924e8dcd1": "Pull requests y estados de build mediante tokens de la API de Bitbucket Cloud.", + "0fa5629dad": "Pull requests y estados de build" } } } } }, "computerUseSummary": { - "permissionsRequired": "{{value0}} permission{{value1}} required before agents can operate app windows.", - "checkingTitle": "Checking Computer Use access.", - "checkingDescription": "Orca is checking macOS privacy permissions for the Computer Use helper.", - "unavailableTitle": "Computer Use is unavailable.", - "unavailableDescription": "Computer Use permissions are unavailable because {{value0}}.", - "readyTitle": "Computer Use is ready.", - "readyDescription": "Agents can inspect and operate app windows when you ask.", - "permissionsTitle": "Finish setup to use local apps." + "permissionsRequired": "Requiere {{value0}} permiso{{value1}} antes de que los agentes puedan operar ventanas de apps.", + "checkingTitle": "Comprobando el acceso a Computer Use.", + "checkingDescription": "Orca está comprobando los permisos de privacidad de macOS para el helper de Computer Use.", + "unavailableTitle": "Computer Use no está disponible.", + "unavailableDescription": "Los permisos de Computer Use no están disponibles porque {{value0}}.", + "readyTitle": "Computer Use está listo.", + "readyDescription": "Los agentes pueden inspeccionar y operar ventanas de apps cuando se lo pidas.", + "permissionsTitle": "Completa la configuración para usar apps locales." }, "computerUseSkillRuntime": { - "thisDevice": "This device" + "thisDevice": "Este dispositivo" }, "WorkspaceDirectorySetting": { "1a2b3c4d5e": "Predeterminado del cliente", @@ -8455,32 +8523,32 @@ "3c4d5e6f7a": "Anula el valor predeterminado del cliente", "4d5e6f7a8b": "Hereda el valor predeterminado del cliente", "5e6f7a8b9c": "Restablecer", - "6f7a8b9cad": "Usa una ruta relativa (p. ej. .orca/worktrees) para una ubicación por proyecto, o una ruta absoluta para una carpeta compartida." + "6f7a8b9cad": "Usa una ruta relativa (p. ej., .orca/worktrees) para una ubicación por proyecto, o una ruta absoluta para una carpeta compartida." }, "ProviderHostScopeControl": { "scope_label": "{{value0}}: {{value1}}", - "change_host": "Open Remote Servers" + "change_host": "Abrir servidores remotos" }, "providerAccountScope": { - "remoteServer": "Remote server: {{value0}}", - "remoteServerCredentials": "Credentials and account checks for this provider are owned by this remote server. Use Settings > Remote Orca Servers > Advanced to edit another default runtime scope.", + "remoteServer": "Servidor remoto: {{value0}}", + "remoteServerCredentials": "Las credenciales y comprobaciones de cuenta de este proveedor pertenecen a este servidor remoto. Usa Configuración > Servidores Orca remotos > Avanzado para editar otro ámbito de runtime predeterminado.", "localMac": "Local Mac", - "localCredentials": "Credentials and account checks for this provider are owned by this desktop client. Use Settings > Remote Orca Servers > Advanced to edit server-owned credentials.", - "remoteServerRateLimit": "{{value0}} API budget is fetched from the CLI on this remote server. Use Settings > Remote Orca Servers > Advanced to view another default runtime budget.", - "localRateLimit": "{{value0}} API budget is fetched from the CLI on this desktop client. Use Settings > Remote Orca Servers > Advanced to view server-owned budgets." + "localCredentials": "Las credenciales y comprobaciones de cuenta de este proveedor pertenecen a este cliente de escritorio. Usa Configuración > Servidores Orca remotos > Avanzado para editar credenciales propiedad del servidor.", + "remoteServerRateLimit": "El presupuesto de API de {{value0}} se obtiene desde la CLI en este servidor remoto. Usa Configuración > Servidores Orca remotos > Avanzado para ver otro presupuesto de runtime predeterminado.", + "localRateLimit": "El presupuesto de API de {{value0}} se obtiene desde la CLI en este cliente de escritorio. Usa Configuración > Servidores Orca remotos > Avanzado para ver presupuestos propiedad del servidor." }, "settingOwnership": { - "clientDefault": "Client default", - "sourceControlAiDefaults": "Recipes, prompts, and hosted-review defaults are shared by this client; model choices and discovery stay scoped to the host where the agent runs.", - "projectOnThisHost": "Project on this host", - "repositorySourceControlAi": "These overrides apply to this project setup and inherit the client Source Control AI defaults until customized.", - "agentLaunchDefaults": "Default agent, command overrides, CLI arguments, and launch environment are client preferences. SSH and remote server launches still validate host availability at run time.", - "clientDefaultProjectScopes": "Client default + project scopes", - "terminalQuickCommands": "Commands are saved on this client, then scoped globally or to a project setup so they run from the selected terminal context.", - "hostOverride": "Host override", - "workspaceDirectory": "The client default is inherited until a host needs its own worktree directory.", - "providerHost": "Provider host", - "providerAccounts": "Credentials and account checks belong to the local client or selected remote server that owns the provider integration." + "clientDefault": "Predeterminado del cliente", + "sourceControlAiDefaults": "Las recetas, prompts y valores predeterminados de revisión alojada se comparten en este cliente; las elecciones de modelo y el descubrimiento quedan limitados al host donde se ejecuta el agente.", + "projectOnThisHost": "Proyecto en este host", + "repositorySourceControlAi": "Estas anulaciones se aplican a la configuración de este proyecto y heredan los valores predeterminados de Source Control AI del cliente hasta que se personalicen.", + "agentLaunchDefaults": "El agente predeterminado, las anulaciones de comandos, los argumentos de CLI y el entorno de lanzamiento son preferencias del cliente. Los lanzamientos por SSH y servidor remoto siguen validando la disponibilidad del host en tiempo de ejecución.", + "clientDefaultProjectScopes": "Predeterminado del cliente + ámbitos de proyecto", + "terminalQuickCommands": "Los comandos se guardan en este cliente y luego se limitan globalmente o a una configuración de proyecto para que se ejecuten desde el contexto de terminal seleccionado.", + "hostOverride": "Anulación del host", + "workspaceDirectory": "Se hereda el valor predeterminado del cliente hasta que un host necesite su propio directorio de worktree.", + "providerHost": "Host del proveedor", + "providerAccounts": "Las credenciales y comprobaciones de cuenta pertenecen al cliente local o al servidor remoto seleccionado que posee la integración del proveedor." }, "RepositoryForkSyncSection": { "defaultBranch": "rama predeterminada", @@ -8506,42 +8574,42 @@ "syncNow": "Sincronizar ahora", "modeLabel": "Modo de sincronización del fork", "ask": "Preguntar", - "safeAuto": "Seguro automático", + "safeAuto": "Automático seguro", "off": "Desactivado" }, "DefaultWindowsProjectRuntimeSetting": { - "defaultRuntime": "Default project runtime", + "defaultRuntime": "Runtime predeterminado del proyecto", "windows": "Windows", "wsl": "WSL", - "selectDistro": "Select distro", - "windowsDescription": "Projects inherit Windows unless a project overrides it.", - "wslUnavailable": "WSL is not available. Projects that inherit WSL will need repair.", - "distroRequired": "Choose a WSL distro before projects can inherit WSL.", - "wslDescription": "Projects inherit {{value0}} via WSL unless a project overrides it." + "selectDistro": "Seleccionar distro", + "windowsDescription": "Los proyectos heredan Windows, a menos que un proyecto lo anule.", + "wslUnavailable": "WSL no está disponible. Los proyectos que hereden WSL necesitarán reparación.", + "distroRequired": "Elige una distro de WSL antes de que los proyectos puedan heredar WSL.", + "wslDescription": "Los proyectos heredan {{value0}} mediante WSL, a menos que un proyecto lo anule." }, "ProjectWindowsRuntimeSetting": { - "projectRuntime": "Project runtime", - "defaultRuntime": "Default ({{value0}})", + "projectRuntime": "Runtime del proyecto", + "defaultRuntime": "Predeterminado ({{value0}})", "windows": "Windows", "wsl": "WSL", - "selectDistro": "Select distro", - "runtimeChangeHelp": "Runtime changes apply to new terminals, agent checks, and skill discovery for this project. Existing terminals keep their current runtime.", - "wslUnavailable": "WSL is not available. Switch this project to Windows or repair WSL.", - "distroMissing": "{{value0}} is not installed in WSL. Choose an installed distro or switch this project to Windows.", - "distroRequired": "Choose a WSL distro or switch this project to Windows.", - "inheritedWsl": "No project override. General settings select {{value0}} via WSL.", - "projectWsl": "This project runs in {{value0}} via WSL.", - "inheritedWindows": "No project override. General settings select Windows.", - "projectWindows": "This project runs on Windows.", + "selectDistro": "Seleccionar distro", + "runtimeChangeHelp": "Los cambios de runtime se aplican a nuevas terminales, checks de agentes y descubrimiento de skill para este proyecto. Las terminales existentes conservan su runtime actual.", + "wslUnavailable": "WSL no está disponible. Cambia este proyecto a Windows o repara WSL.", + "distroMissing": "{{value0}} no está instalado en WSL. Elige una distro instalada o cambia este proyecto a Windows.", + "distroRequired": "Elige una distro de WSL o cambia este proyecto a Windows.", + "inheritedWsl": "Sin anulación del proyecto. La configuración general selecciona {{value0}} mediante WSL.", + "projectWsl": "Este proyecto se ejecuta en {{value0}} mediante WSL.", + "inheritedWindows": "Sin anulación del proyecto. La configuración general selecciona Windows.", + "projectWindows": "Este proyecto se ejecuta en Windows.", "liveTerminalSingular": "{{count}} terminal activo", "liveTerminalPlural": "{{count}} terminales activos", "activeTaskSingular": "{{count}} tarea activa", "activeTaskPlural": "{{count}} tareas activas", "runtimeSessionJoin": "{{value0}} y {{value1}}", - "runtimeSessionWarning": "{{value0}} seguirá ejecutándose en el runtime actual. Deja que las tareas terminen o reinicia las terminales antes de continuar.", - "pendingRuntimeChange": "Runtime change pending. New project work will use the selected runtime after you apply.", + "runtimeSessionWarning": "Lo siguiente seguirá ejecutándose en el runtime actual: {{value0}}. Deja que las tareas terminen o reinicia las terminales antes de continuar.", + "pendingRuntimeChange": "Cambio de runtime pendiente. El nuevo trabajo del proyecto usará el runtime seleccionado después de aplicar.", "cancel": "Cancelar", - "applyRuntimeChange": "Apply runtime change" + "applyRuntimeChange": "Aplicar cambio de runtime" }, "PrivacyDiagnosticsRows": { "5a7cbe069a": "DO_NOT_TRACK=1 está configurado: crear y enviar archivos de diagnóstico está deshabilitado.", @@ -8551,234 +8619,246 @@ "e27c8d45bf": "Los diagnósticos están desactivados por una variable de entorno." }, "ShortcutCommandBlock": { - "eb72c52c28": "Press a shortcut, or double-tap a modifier (e.g. {{value0}}). Esc cancels.", - "70b5d25583": "{{value0}} shortcut", - "287e07ddde": "Modified", - "3c83cd7d1c": "Disabled", - "07939d084e": "Reset {{value0}} to default", - "9b02917027": "Reset to default", - "a799f90f82": "Disable {{value0}}", - "25e6e76618": "Disable shortcut", - "035a822ef0": "Add shortcut", - "a0e2ef0e61": "Add another shortcut for {{value0}}", - "245c83af24": "Add another shortcut", - "482a60225d": "Enable {{value0}}", - "6287677c37": "Enable", - "01481b964c": "Add shortcut for {{value0}}" + "eb72c52c28": "Pulsa un atajo o toca dos veces una tecla modificadora (p. ej., {{value0}}). Esc cancela.", + "70b5d25583": "Atajo de {{value0}}", + "287e07ddde": "Modificado", + "3c83cd7d1c": "Desactivado", + "07939d084e": "Restablecer {{value0}} al valor predeterminado", + "9b02917027": "Restablecer al valor predeterminado", + "a799f90f82": "Desactivar {{value0}}", + "25e6e76618": "Desactivar atajo", + "035a822ef0": "Agregar atajo", + "a0e2ef0e61": "Agregar otro atajo para {{value0}}", + "245c83af24": "Agregar otro atajo", + "482a60225d": "Activar {{value0}}", + "6287677c37": "Activar", + "01481b964c": "Agregar atajo para {{value0}}" }, "ShortcutRecorderButton": { - "1a13bb054d": "Press shortcut keys for {{value0}}. Escape cancels.", - "3732775d74": "Add shortcut for {{value0}}", - "88764af2c1": "Change shortcut for {{value0}}", - "30feb099d6": "Change shortcut {{value0}} of {{value1}} for {{value2}}", - "5d982a2a1f": "Listening for shortcut", - "152e0bcd64": "Add shortcut", - "5bd56445da": "Change shortcut", - "f5ed5dcbf6": "Press keys…" + "1a13bb054d": "Pulsa las teclas del atajo para {{value0}}. Escape cancela.", + "3732775d74": "Agregar atajo para {{value0}}", + "88764af2c1": "Cambiar atajo para {{value0}}", + "30feb099d6": "Cambiar atajo {{value0}} de {{value1}} para {{value2}}", + "5d982a2a1f": "Esperando atajo", + "152e0bcd64": "Agregar atajo", + "5bd56445da": "Cambiar atajo", + "f5ed5dcbf6": "Pulsa teclas…" }, "ShortcutRemoveButton": { - "9e29aff18b": "Eliminar {{value0}} acceso directo {{value1}}", - "2a9588b1c2": "Remove this binding" + "9e29aff18b": "Eliminar atajo {{value1}} de {{value0}}", + "2a9588b1c2": "Eliminar esta combinación" }, "BrowserLocalhostWorktreeLabelsSetting": { - "8ac8c3ad19": "Etiquetas del árbol de trabajo de localhost", - "1db3c8b983": "Abra los puertos del espacio de trabajo como URL de host local de Orca específicas del árbol de trabajo para que las pestañas del navegador sean más fáciles de distinguir." + "8ac8c3ad19": "Etiquetas de worktree para localhost", + "1db3c8b983": "Abre los puertos del workspace como URLs localhost de Orca específicas por worktree para que las pestañas del navegador sean más fáciles de distinguir." }, "AgentRuntimeSetting": { - "label": "Tiempo de ejecución del Agent", + "label": "Runtime de agentes", "wsl": "WSL", "loadingWsl": "Cargando WSL", - "selectDistro": "Seleccionar distribución", - "windowsDescription": "Detecte e inicie agents en Windows para proyectos que no anulan su tiempo de ejecución.", + "selectDistro": "Seleccionar distro", + "windowsDescription": "Detecta y lanza agentes en Windows para proyectos que no anulan su runtime.", "wslUnavailable": "WSL no está disponible en esta máquina.", - "distroRequired": "Elija una distribución WSL antes de que los proyectos puedan heredar WSL.", - "wslDescription": "Detecta y lanza agents en {{value0}} a través de WSL para proyectos que no anulan su tiempo de ejecución." + "distroRequired": "Elige una distro de WSL antes de que los proyectos puedan heredar WSL.", + "wslDescription": "Detecta y lanza agentes en {{value0}} mediante WSL para proyectos que no anulan su runtime." }, "MobileEmulatorSdkStatus": { "536026130e": "SDK de emulador", "dde0ec1cd8": "Cadenas de herramientas que Orca utiliza para ejecutar emuladores. Android funciona en cualquier sistema operativo a través del SDK de Android; Los simuladores de iOS necesitan Xcode en macOS.", "027cbf668a": "Android SDK", - "f6d080d128": "Usando la ruta configurada", + "f6d080d128": "Usando ruta configurada", "7fe4bd5907": "Detectado en", - "2784f0b22d": "Extraviado. Instale Android Studio, luego cree un dispositivo virtual.", + "2784f0b22d": "No encontrado. Instala Android Studio y luego crea un dispositivo virtual.", "b94ff260e6": "Descargar Android Studio", - "18925b082d": "Localizar la carpeta SDK", - "8c52684db8": "Claro", + "18925b082d": "Ubicar carpeta del SDK", + "8c52684db8": "Borrar", "76eb88b88e": "iOS Simulator (Xcode)", "c6f3ea4f12": "Listo", - "e4f14b50d7": "Instale Xcode y agregue un tiempo de ejecución del simulador de iOS.", + "e4f14b50d7": "Instala Xcode y agrega un runtime de iOS Simulator.", "63fe73a1ea": "No se pudo actualizar la carpeta SDK de Android." }, "AppearanceAdvancedDisclosure": { "advanced": "Avanzado" }, "DevToolsPane": { - "nativeActionLayoutCheck": "Native action layout check", - "secondary": "Secondary", - "secondaryActionClicked": "Secondary action clicked", - "primaryAction": "Primary action", - "primaryActionClicked": "Primary action clicked", - "branchHasChanges": "branch has changes", - "viewChangesClicked": "View Changes clicked", - "forceDeleteClicked": "Force Delete clicked", - "devOnlyCallback": "Dev-only callback.", - "infoToast": "Info toast", - "infoToastDescription": "Long informational copy with no explicit action.", - "localCanaryBehind": "Local canary is behind origin/canary", - "successToast": "Success toast", - "successToastDescription": "Short confirmation state.", - "settingsSaved": "Settings saved", - "shortSuccessCopy": "Short success copy.", - "errorToast": "Error toast", - "errorToastDescription": "Long error copy without recovery actions.", - "failedToSyncWorkspaceMetadata": "Failed to sync workspace metadata", - "nativeActionToast": "Native action toast", - "nativeActionToastDescription": "Uses Sonner action and cancel slots.", - "deleteFailureToast": "Delete failure toast", - "deleteFailureToastDescription": "Custom footer with View and Force Delete.", - "behindBaseRefToast": "Behind base ref toast", - "behindBaseRefToastDescription": "Persistent prompt with an inline Settings link and footer action.", - "notificationPlayground": "Notification playground", - "notificationPlaygroundDescription": "Dev-only triggers for checking toast layout, recovery actions, and long-copy wrapping.", - "devOnly": "Dev only" + "nativeActionLayoutCheck": "Comprobación de diseño de acción nativa", + "secondary": "Secundario", + "secondaryActionClicked": "Acción secundaria pulsada", + "primaryAction": "Acción principal", + "primaryActionClicked": "Acción principal pulsada", + "branchHasChanges": "la rama tiene cambios", + "viewChangesClicked": "Ver cambios pulsado", + "forceDeleteClicked": "Forzar eliminación pulsado", + "devOnlyCallback": "Callback solo para desarrolladores.", + "infoToast": "Toast informativo", + "infoToastDescription": "Texto informativo largo sin acción explícita.", + "localCanaryBehind": "La rama canary local está detrás de origin/canary", + "successToast": "Toast de éxito", + "successToastDescription": "Estado de confirmación breve.", + "settingsSaved": "Ajustes guardados", + "shortSuccessCopy": "Texto de éxito breve.", + "errorToast": "Toast de error", + "errorToastDescription": "Texto de error largo sin acciones de recuperación.", + "failedToSyncWorkspaceMetadata": "Error al sincronizar metadatos del espacio de trabajo", + "nativeActionToast": "Toast de acción nativa", + "nativeActionToastDescription": "Utiliza las ranuras de acción y cancelación de Sonner.", + "deleteFailureToast": "Toast de error al eliminar", + "deleteFailureToastDescription": "Pie personalizado con Ver y Forzar eliminación.", + "behindBaseRefToast": "Toast de retraso respecto a la rama base", + "behindBaseRefToastDescription": "Aviso persistente con un enlace a Ajustes y una acción en el pie.", + "notificationPlayground": "Área de prueba de notificaciones", + "notificationPlaygroundDescription": "Disparadores solo para desarrolladores para comprobar el diseño de toasts, acciones de recuperación y ajuste de texto largo.", + "devOnly": "Solo desarrollo" }, "EphemeralVmRecipeRow": { - "useInWorkspace": "Uso en el espacio de trabajo" + "useInWorkspace": "Usar en el espacio de trabajo" }, "EphemeralVmRuntimesSection": { - "cleanupFailed": "Error de limpieza", - "cleanupRunning": "limpieza en ejecución", + "cleanupFailed": "Error al limpiar", + "cleanupRunning": "Limpieza en curso", "cleanupDisabled": "Limpieza deshabilitada", - "running": "Correr", - "failed": "Fallido", - "loadFailed": "No se pudieron cargar tiempos de ejecución de VM temporales.", - "cleanupFailedToast": "No se pudo limpiar el tiempo de ejecución temporal de la VM.", - "markedCleaned": "Tiempo de ejecución de VM temporal marcado como limpio.", - "cleaned": "Se limpió el tiempo de ejecución temporal de la VM.", + "running": "En ejecución", + "failed": "Con error", + "loadFailed": "No se pudieron cargar los runtimes de VM temporales.", + "cleanupFailedToast": "No se pudo limpiar el runtime de VM temporal.", + "markedCleaned": "Runtime de VM temporal marcado como limpio.", + "cleaned": "Se limpió el runtime de VM temporal.", "copiedCleanupCommand": "Comando de limpieza copiado.", - "copiedCleanupPayload": "Carga útil de limpieza copiada.", + "copiedCleanupPayload": "Payload de limpieza copiado.", "copyCleanupFailed": "No se pudo copiar el comando de limpieza.", - "title": "Tiempos de ejecución temporales de VM", - "description": "Los tiempos de ejecución creados por recetas son propiedad del espacio de trabajo. Limpie las entradas obsoletas después de fallas, creaciones fallidas o recuperación manual.", - "refresh": "Actualizar tiempos de ejecución de VM temporales", - "loading": "Comprobando tiempos de ejecución de VM temporales...", - "empty": "No es necesario limpiar tiempos de ejecución de VM temporales.", - "copyCleanup": "comando copiar", - "retry": "Reintentar la limpieza", - "cleanup": "Limpieza" + "title": "Runtimes de VM temporales", + "description": "Los runtimes creados por recetas pertenecen al espacio de trabajo. Limpia entradas obsoletas después de cierres inesperados, creaciones fallidas o recuperación manual.", + "refresh": "Actualizar runtimes de VM temporales", + "loading": "Comprobando runtimes de VM temporales…", + "empty": "No hay runtimes de VM temporales que limpiar.", + "copyCleanup": "Copiar comando", + "retry": "Reintentar limpieza", + "cleanup": "Limpiar" }, "ephemeralVms": { "search": { "title": "Entornos por espacio de trabajo", - "description": "Descubra cómo las recetas propiedad del repo brindan a cada espacio de trabajo su propio entorno desechable bajo demanda." + "description": "Aprende cómo las recetas del repositorio dan a cada espacio de trabajo su propio entorno desechable bajo demanda." } }, "EphemeralVmsPane": { - "loadError": "No se pudieron cargar recetas.", - "copyError": "No se pudo copiar el mensaje.", - "skillTitle": "Habilidad Entornos por espacio de trabajo", - "skillDescription": "Configura, crea, autentica y valida recetas de entornos propiedad del repo.", - "whatTitle": "Qué hace la habilidad contigo", - "whatScaffold": "Escribe la receta y los scripts para su proveedor, conectados a través de un servidor Orca o SSH.", - "whatBuild": "Crea una imagen base reutilizable e inicia la sesión de su agent.", - "whatValidate": "Lo valida para que puedas crear un espacio de trabajo en él.", - "promptHint": "En cualquier espacio de trabajo, pregúntale a tu agent:", + "loadError": "No se pudieron cargar las recetas.", + "copyError": "No se pudo copiar el prompt.", + "skillTitle": "Skill de entornos por espacio de trabajo", + "skillDescription": "Configura, construye, autentica y valida recetas de entornos del repositorio.", + "whatTitle": "Lo que la skill hace contigo", + "whatScaffold": "Escribe la receta y los scripts para tu proveedor, conectados mediante un servidor Orca o SSH.", + "whatBuild": "Crea una imagen base reutilizable e inicia sesión con tu agente.", + "whatValidate": "Valida el entorno para que puedas crear un espacio de trabajo sobre él.", + "promptHint": "En cualquier espacio de trabajo, pídele a tu agente:", "copy": "Copiar", - "copied": "copiado", + "copied": "Copiado", "recipes": "Recetas", - "recipesHelp": "Las recetas que su agent agrega a orca.yaml aparecen aquí, listas para iniciar un espacio de trabajo.", - "refresh": "Actualizar recetas de VM efímeras", - "checking": "Comprobando recetas...", + "recipesHelp": "Las recetas que tu agente agrega a orca.yaml aparecen aquí, listas para iniciar un espacio de trabajo.", + "refresh": "Actualizar recetas de VMs efímeras", + "checking": "Comprobando recetas…", "none": "Aún no se han encontrado recetas." }, "ephemeralVmsExperimentalSetting": { - "description": "Muestra controles de configuración y objetivos de ejecución del espacio de trabajo para entornos bajo demanda de propiedad de repos.", - "toggleLabel": "Alternar entornos por espacio de trabajo" + "description": "Muestra controles de configuración y destinos de ejecución de espacios de trabajo para entornos bajo demanda del repositorio.", + "toggleLabel": "Activar o desactivar entornos por espacio de trabajo" + }, + "SourceControlActionRepoOverrideNote": { + "agent": "Agente", + "agentArgs": "Argumentos de la CLI", + "commandTemplate": "Plantilla del comando", + "more": "+{{count}} más", + "plural": "Los guardados globales no cambiarán {{count}} repositorios con sus propias recetas.", + "recipe": "Receta", + "review": "Revisar", + "reviewFirst": "Revisar primero", + "singular": "Los guardados globales no cambiarán 1 repositorio con su propia receta.", + "tooltipTitle": "Anulaciones del repositorio" } }, "right": { "sidebar": { "BulkActionBar": { - "79a9f5f712": "fuera del escenario (", - "ef5f5bd06e": "Escenario (", - "60ed678138": "seleccionado" + "79a9f5f712": "Unstage (", + "ef5f5bd06e": "Stage (", + "60ed678138": "seleccionados" }, "ChecksPanel": { - "2ef90c9819": "Inició un agente de inteligencia artificial para los cheques rotos.", - "a0181a8d76": "Inició un agente de IA para los conflictos.", + "2ef90c9819": "Se inició un agente de IA para los checks con errores.", + "a0181a8d76": "Se inició un agente de IA para los conflictos.", "34464d00b9": "actualizado", "058039787c": "Cancelar", - "2ab7fd4b6d": "Ahorrar", - "dda5924a40": "Las comprobaciones requieren una rama de Git y un contexto de revisión alojado", - "976cefd02f": "Cheques no disponibles", - "b5dd73a105": "Seleccione un espacio de trabajo para ver cheques", + "2ab7fd4b6d": "Guardar", + "dda5924a40": "Los checks requieren una rama de Git y contexto de revisión alojada", + "976cefd02f": "Checks no disponibles", + "b5dd73a105": "Selecciona un espacio de trabajo para ver los checks", "a4ef4e0832": "Ningún espacio de trabajo seleccionado", - "5594400d73": "No hay cheques rotos que arreglar.", - "abf59262fb": "Revise el mensaje antes de iniciar un agente.", + "5594400d73": "No hay checks con errores que corregir.", + "abf59262fb": "Revisa el prompt antes de iniciar un agente.", "4ede779461": "Resolver conflictos de revisión con IA", "3b203c62f8": "Esto eliminará permanentemente el comentario del PR.", "ea9b649ce3": "¿Eliminar comentario?", - "5788d1059d": "No se pudo actualizar el hilo de revisión. Consulta el presupuesto de la API de GitHub.", + "5788d1059d": "No se pudo actualizar el hilo de revisión. Revisa el presupuesto de la API de GitHub.", "07871c0589": "Vincular otro PR", - "7202f4a40a": "desvincular PR", - "7f4489f370": "Refrescar", - "5c88c6db07": "Abierto el {{value0}}", - "7fad8509fe": "Arreglar con IA", - "71026ca2cb": "Refrescante…", + "7202f4a40a": "Desvincular PR", + "7f4489f370": "Actualizar", + "5c88c6db07": "Abrir en {{value0}}", + "7fad8509fe": "Corregir con IA", + "71026ca2cb": "Actualizando…", "889cdfba04": "Crear {{value0}}", - "98f4c37b33": "Empujar y crear {{value0}}", + "98f4c37b33": "Hacer push y crear {{value0}}", "b6ce28da5b": "{{value0}} #{{value1}} ya está abierto", "cf9e69f3be": "{{value0}} ya está abierto", "192e686e57": "Abrir en {{value0}}", - "6633c7a1fb": "Rama de publicación", - "fdb27637f2": "Publicación…", + "6633c7a1fb": "Publicar rama", + "fdb27637f2": "Publicando…", "e56c42122e": "destructivo", - "786e3c143f": "Borrar", + "786e3c143f": "Eliminar", "653c105ecc": "Más acciones de PR", "f316a8ca2b": "No hay comentarios sin resolver seleccionados.", "d00ebdc402": "Resolver comentarios de {{value0}} con IA", - "ed3f79c031": "Revisa el prompt antes de iniciar un agente. Los hilos seleccionados se marcan como resueltos después del inicio.", - "f273f2271c": "Agente iniciado. Marcados {{value0}} como resueltos, omitidos {{value1}}, con error {{value2}}.", - "aa95b81a3a": "Agente iniciado. Marcados {{value0}} como resueltos, omitidos {{value1}}, con error {{value2}}.", + "ed3f79c031": "Revisa el prompt antes de iniciar un agente. Los hilos seleccionados se marcarán como resueltos después del inicio.", + "f273f2271c": "Agente iniciado. Marcados como resueltos: {{value0}}; omitidos: {{value1}}; con error: {{value2}}.", + "aa95b81a3a": "Agente iniciado. Marcados como resueltos: {{value0}}; omitidos: {{value1}}; con error: {{value2}}.", "495b2f8c4b": "Agente iniciado, pero no se pudieron marcar los comentarios seleccionados como resueltos.", - "3c3ad3a1d2": "Agente iniciado. Ningún comentario seleccionado se puede marcar como resuelto en el host." + "3c3ad3a1d2": "Agente iniciado. No se puede marcar ningún comentario seleccionado como resuelto en el host." }, "CreatePullRequestDialog": { "2bc1b4345e": "Cancelar", - "27ef4b195c": "Elija una rama base diferente antes de crear un {{value0}}.", + "27ef4b195c": "Elige una rama base diferente antes de crear un {{value0}}.", "7ef56f3efe": "Crear como borrador", - "0c9f9a568c": "Admite el formato Markdown. Utilice Generar con IA para completar automáticamente sus cambios.", + "0c9f9a568c": "Admite formato Markdown. Usa Generar con IA para autocompletarlo a partir de tus cambios.", "02b2ce911f": "Descripción (opcional)", "1cd53359db": "Descripción", "68314b4369": "Título", - "694550a610": "principal", - "0fad57a14c": "Busque sucursales remotas o ingrese el nombre de una sucursal.", - "8584ccb43c": "sucursal base", - "6f5f1962b6": "rama principal", + "694550a610": "main", + "0fad57a14c": "Busca ramas remotas o ingresa el nombre de una rama.", + "8584ccb43c": "Rama base", + "6f5f1962b6": "Rama head", "b504b3ceb1": "detalles antes de crear la reseña alojada.", - "f658ff2455": "Confirme la sucursal de destino y los detalles de {{value0}} antes de crear la revisión alojada.", + "f658ff2455": "Confirma la rama de destino y los detalles de {{value0}} antes de crear la revisión alojada.", "b7f43474d7": "Crear {{value0}}", - "7a21f0dae8": "Abierto el {{value0}}", + "7a21f0dae8": "Abrir en {{value0}}", "edc35a7027": "{{value0}} #{{value1}} ya está abierto", - "a154fe55e6": "Empujar y crear {{value0}}", + "a154fe55e6": "Hacer push y crear {{value0}}", "21c7a1daa0": "{{value0}} ya está abierto", "db9cee18f7": "Crear {{value0}}" }, "CreateHostedReviewComposer": { - "741ff8a0d2": "Empujar y crear {{value0}}" + "741ff8a0d2": "Hacer push y crear {{value0}}" }, "CreatePullRequestGenerateButton": { "4012459f8a": "Generar con IA", - "a0501572c1": "Genera detalles {{value0}} con IA", - "d47fd63012": "Generando detalles {{value0}}. Haga clic para detener.", + "a0501572c1": "Generar detalles de {{value0}} con IA", + "d47fd63012": "Generando detalles de {{value0}}. Haz clic para detener.", "bdf83ccb15": "Generando {{value0}}", - "f5513bdeb1": "generando", + "f5513bdeb1": "Generando", "a6ea6dc3aa": "Generando…", - "e61d7e7ad4": "Dejar de generar detalles {{value0}}", - "e041998cad": "dejar de generar" + "e61d7e7ad4": "Dejar de generar detalles de {{value0}}", + "e041998cad": "Dejar de generar" }, "FileExplorer": { - "79b1537dd3": "Seleccione un espacio de trabajo para buscar archivos", + "79b1537dd3": "Selecciona un espacio de trabajo para explorar archivos", "4da4d89845": "Volver al explorador", "6ed5ce817b": "Buscar", "2f4483d6c4": "No hay archivos que coincidan con este filtro" @@ -8788,35 +8868,36 @@ "21fe46ed36": "Nuevo archivo" }, "FileExplorerRow": { - "addc01145f": "Borrar", - "fc747429bf": "Rebautizar", - "0df0e5abac": "Buscar en la carpeta", + "addc01145f": "Eliminar", + "fc747429bf": "Renombrar", + "0df0e5abac": "Buscar en carpeta", "d6a25618aa": "Contraer carpeta", - "d87a4c42e1": "Abrir vista previa de Markdowns", + "d87a4c42e1": "Abrir vista previa de Markdown", "c2112579f6": "Descargar", - "dd112c81d2": "Abrir en el navegador Orca", - "1bb9be455c": "Agregar como proyecto...", - "0fec99bfd7": "Duplicado", + "dd112c81d2": "Abrir en Orca Browser", + "1bb9be455c": "Agregar como proyecto…", + "0fec99bfd7": "Duplicar", "f61af83316": "Nueva carpeta", "37c875d827": "Nuevo archivo", "b3e288bf41": "No se pudo descargar '{{value0}}'.", - "1a3df04ae1": "Abierto", - "bce4d4e44f": "Descargado '{{value0}}'", + "1a3df04ae1": "Abrir", + "bce4d4e44f": "Se descargó '{{value0}}'", "e26010014a": "Ignorado por .gitignore", - "a06551beee": "Ingresar", + "a06551beee": "Intro", "128a99ed5e": "No asignado", "2de3b21934": "markdown", "66a29dde82": "Copiar ruta relativa", "42e10cbf57": "Copiar rutas relativas", "b5d436aa30": "Copiar ruta", "98a79948b3": "Copiar", - "b234ab25b4": "No se pudo copiar el archivo al portapapeles", + "b234ab25b4": "No se pudo copiar el archivo en el portapapeles", "f9d7ca753d": "Copiar rutas", - "3161c4e425": "carpeta" + "3161c4e425": "carpeta", + "e887fa4b2e": "Abrir en terminal" }, "FileExplorerToolbar": { - "d238264654": "Mostrar archivos ignorados de Git", - "78f133232c": "Mostrar archivos de puntos", + "d238264654": "Mostrar archivos ignorados por Git", + "78f133232c": "Mostrar dotfiles", "31b4c3195d": "Más acciones del explorador", "d95e30fe28": "Actualizar explorador", "6026b16950": "Contraer todo", @@ -8829,20 +8910,20 @@ }, "GitHistoryPanel": { "cf7cad58d2": "Aún no hay commits", - "781a8bcf7b": "Cargando gráfico...", + "781a8bcf7b": "Cargando gráfico…", "d0fb0f4bf2": "Actualizar commits", - "9f7535d22b": "Las referencias son nombres de ramas o etiquetas que apuntan a esa commit exacta. Solo aparecen cuando Git tiene una referencia con nombre para la commit.", - "9289ba0cb9": "¿Qué son los árbitros?", + "9f7535d22b": "Las refs son nombres de ramas o tags que apuntan a ese commit exacto. Solo aparecen cuando Git tiene una ref con nombre para el commit.", + "9289ba0cb9": "¿Qué son las refs?", "d836037d02": "Commits", - "8232c8b2f2": "Confirmación abierta {{value0}}: {{value1}}", + "8232c8b2f2": "Abrir commit {{value0}}: {{value1}}", "9a8b85882d": "cargando", "62e685d5ec": "idle", "111e1d0db4": "error", - "e5e81e59a6": "Cambiar el tamaño de las commits", + "e5e81e59a6": "Cambiar tamaño del panel de commits", "6d1e0a7c3b": "No se pudieron cargar los archivos del commit" }, "HostedReviewActions": { - "4d5fb5a284": "Cerca", + "4d5fb5a284": "Cerrar", "9845a71e17": "Más acciones", "2bfaf4379c": "Más acciones de {{value0}}", "377269db6f": "{{value0}} reabierto", @@ -8850,36 +8931,36 @@ "78f5ff294c": "Esto reabrirá el {{value0}}.", "a3d572a4de": "Esto cerrará el {{value0}}.", "e4aca40024": "Eliminar espacio de trabajo", - "eefd50457e": "Eliminando...", + "eefd50457e": "Eliminando…", "3ce211ece6": "Reabrir {{value0}}", - "6645ac7dd1": "Reapertura...", + "6645ac7dd1": "Reabriendo…", "b25f63edd7": "abierto", - "d2ca293f3d": "Laboral...", + "d2ca293f3d": "Procesando…", "ef064cb7c3": "por defecto", "59b4dccf70": "destructivo" }, "PortsPanel": { "3ea4a02a8f": "Cancelar", - "4eb801ce93": "servidor de desarrollo", + "4eb801ce93": "dev-server", "8dfed0a15c": "Etiqueta (opcional)", - "17bea6e391": "servidor local", - "a3721a50b0": "Anfitrión remoto", + "17bea6e391": "localhost", + "a3721a50b0": "Host remoto", "d57545ff92": "Igual que remoto", - "b950b1948b": "Puerto Local", + "b950b1948b": "Puerto local", "9e5a4118b0": "Puerto remoto", - "c9d106547a": "Adelante", + "c9d106547a": "Reenviar", "c7e920aa7c": "anunciado como {{value0}}", "e740075063": "Eliminar", "b3548e59f4": "Editar", "fe2730d050": "Copiar {{value0}}", "b22b128b2a": "Abrir en el navegador", - "75aeea592f": "Abra {{value0}} en el navegador", + "75aeea592f": "Abrir {{value0}} en el navegador", "de349d4560": "abre {{value0}}", "907eb53ed2": "Reenviar un puerto", - "04efd3dad4": "Reenvíe un puerto para acceder a servicios remotos en su máquina local.", + "04efd3dad4": "Reenvía un puerto para acceder a servicios remotos en tu máquina local.", "1f0d2a24f9": "Sin puertos reenviados", "36b1b2984a": "Detectado", - "ddbe58d74e": "reenviado", + "ddbe58d74e": "Reenviados", "a103dae837": "Agregar", "6bc058dbe1": "Puertos", "d4c3cd679c": "Reconectando...", @@ -8890,9 +8971,9 @@ "57d930fa45": "PID", "5dd86dcf2f": "Proceso", "b1ff94fa27": "Protocolo", - "729be0b4e5": "Amable", - "0f1d8cd324": "Unir", - "1c1c18cefc": "DIRECCIÓN", + "729be0b4e5": "Tipo", + "0f1d8cd324": "Bind", + "1c1c18cefc": "Dirección", "f9528da632": "Detener proceso", "a223459512": "Mostrar detalles", "bdac206faf": "Copiar detalles", @@ -8904,25 +8985,25 @@ "c1b115c375": "Ningún espacio de trabajo seleccionado", "98e9a414f8": "No se pudo abrir el navegador", "a00f3a2840": "No se pudieron actualizar los puertos", - "97b562d21d": "Proceso detenido el :{{value0}}", - "9079776663": "Ahorrar", + "97b562d21d": "Proceso detenido en :{{value0}}", + "9079776663": "Guardar", "c57eda6822": "editar", "9f475dc994": "Reenviando...", - "d7c83cfd24": "Ahorro...", + "d7c83cfd24": "Guardando...", "31e80cff2d": "Reenvíe un puerto remoto a su máquina local.", "10360598a4": "Actualice la configuración de reenvío de puertos.", "80206251c8": "Editar reenvío de puerto", "4bc9b00912": "espacio de trabajo", "3e13cb63ee": "Desconocido", "472054d94c": "Puerto :{{value0}}", - "1119f90ad7": "recipiente", + "1119f90ad7": "contenedor", "d32820d3e2": "Externo", "4db4b5e435": "Otros espacios de trabajo", "38b16cfbef": "No se detectaron puertos", - "0d63d94db3": "Exploración...", + "0d63d94db3": "Escaneando...", "935dda7718": "Espacio de trabajo activo", - "740aca88ab": "Error al escanear el puerto del espacio de trabajo.", - "5be4f7f727": "Menú Puerto {{value0}}", + "740aca88ab": "Error al escanear los puertos del espacio de trabajo.", + "5be4f7f727": "Menú del puerto {{value0}}", "7550998473": "Copiar", "1004af16ab": "Copiar {{value0}}" }, @@ -8933,7 +9014,7 @@ "4107975b3a": "en", "6aeda362ed": "resultado", "98c8435e36": "Seleccione un espacio de trabajo para buscar", - "1ec640c9c7": "fósforo", + "1ec640c9c7": "coincidencia", "dcc294f28d": "(resultados truncados)" }, "SearchFilters": { @@ -8944,8 +9025,8 @@ }, "SearchHeader": { "6234a5ef85": "Usar expresión regular", - "4567e6e0b6": "Coincidir con toda la palabra", - "464ae3974f": "Caso de partido", + "4567e6e0b6": "Coincidir palabra completa", + "464ae3974f": "Distinguir mayúsculas y minúsculas", "693cbeadd0": "Buscar" }, "SearchQueryRow": { @@ -8973,65 +9054,65 @@ "c085946bda": "Copiar nota en la línea {{value0}}", "1623bf4e19": "Copiar nota", "655633c08a": "Enviado", - "3eb9b2805e": "Abrir nota sobre {{value0}}", - "0d963bf982": "Abierto {{value0}}", + "3eb9b2805e": "Abrir nota en {{value0}}", + "0d963bf982": "Abrir {{value0}}", "59654650d3": "Borrar notas para {{value0}}", "ac8cbe3bf5": "Pase el cursor sobre una línea en la vista de diferencias y haga clic en + para agregar una nota.", - "286dbda4d6": "Rever", + "286dbda4d6": "Reintentar", "476b77745b": "Cambiar referencia base", - "ed34038d0d": "Actualizar comparación de sucursales", - "493f963029": "Cambiar referencia base", - "3278b2767b": "adelante", + "ed34038d0d": "Actualizar comparación de ramas", + "493f963029": "Cambiar ref base", + "3278b2767b": "por delante", "11b5dd8e41": "Comparando contra", - "783a808870": "Cerca", - "a9bf7c171a": "Commit fallida", + "783a808870": "Cerrar", + "a9bf7c171a": "Falló el commit", "03d238218c": "Detalles", - "011f9713fc": "Commit bloqueada", + "011f9713fc": "Commit bloqueado", "cc199ccc5f": "Más commits y acciones remotas", "4d6e1fd7f3": "Más acciones", "37a81f29ad": "Generando mensaje de commit. Haga clic para detener.", - "b94112eb9e": "mensaje de Commit", + "b94112eb9e": "Mensaje de commit", "0d0a8359d3": "Mensaje", - "15b7f210d7": "Revise el mensaje antes de iniciar un agente.", - "054ead86b1": "Solucionar errores de Commit con IA", - "9e5ccd00aa": "Contexto de error de Commit no disponible", - "f0a2dc9e46": "Personalizar el lanzamiento...", + "15b7f210d7": "Revisa el prompt antes de iniciar un agente.", + "054ead86b1": "Solucionar error de commit con IA", + "9e5ccd00aa": "Contexto del error de commit no disponible", + "f0a2dc9e46": "Personalizar lanzamiento...", "ec7bfced55": "Elija un agente para solucionar el error de commit", "dd43c47089": "Elija un agente para este error de commit", - "30b8d4f181": "Soluciona el error de commit con IA", - "4b37ae99b0": "Inicie el agente de IA predeterminado para solucionar este error de commit", - "ae743199cd": "Elija una rama base diferente antes de crear un {{value0}}.", + "30b8d4f181": "Solucionar error de commit con IA", + "4b37ae99b0": "Iniciar el agente de IA predeterminado para solucionar este error de commit", + "ae743199cd": "Elige una rama base diferente antes de crear un {{value0}}.", "318e2a7f88": "Espere a que termine la generación con IA.", - "f76307c1f7": "Elija una rama base.", + "f76307c1f7": "Elige una rama base.", "4f76c0a9de": "La rama base debe ser distinta de la rama HEAD.", "c5e4175139": "Más {{value0}} y acciones remotas", "78ddfd0bb4": "Crear como borrador", "e64a632456": "principal", - "6055949c50": "{{value0}} rama base", + "6055949c50": "Rama base de {{value0}}", "1f7119f604": "Base", "9484270f45": "Generando título y descripción…", "a0dc20fc93": "Descripción (opcional)", "a8873e1d62": "{{value0}} descripción", "7d6a8f0082": "Título", "a6eda33521": "{{value0}} título", - "02d8c04339": "Genera detalles {{value0}} con IA", + "02d8c04339": "Generar detalles de {{value0}} con IA", "aee92f8684": "Generar", "e868cec4e1": "Generando…", - "b355e740b2": "Dejar de generar detalles {{value0}}", - "527e130b6f": "dejar de generar", + "b355e740b2": "Dejar de generar detalles de {{value0}}", + "527e130b6f": "Dejar de generar", "e1970d327d": "Nuevo {{value0}}", "f4c766f1ca": "Elija el agente y la plantilla de comando para esta ejecución.", "1a6a6e0bc5": "Generar detalles de reseña alojada", - "6b122529d4": "Generar mensaje de Commit", + "6b122529d4": "Generar mensaje de commit", "e48caaf0dd": "Inició un agente de IA para los conflictos.", - "901140f47d": "Revise el mensaje antes de iniciar un agente.", + "901140f47d": "Revisa el prompt antes de iniciar un agente.", "19652ddd76": "Resolver conflictos con IA", - "c9ad22888e": "Elija el objetivo de comparación de sucursales para este repositorio.", + "c9ad22888e": "Elige el destino de comparación de ramas para este repositorio.", "574d2f4413": "Borrar notas", "05bb8f4a48": "Cancelar", "48db37cca9": "Ver todo", - "78ce2d37ac": "Empuja al tenedor", - "c05fe04839": "Empuja hacia la bifurcación en {{value0}} (no origen)", + "78ce2d37ac": "Push al fork", + "c05fe04839": "Hace push al fork en {{value0}} (no a origin)", "c35baf2f1e": "Filtrar archivos…", "2fe2a67580": "Más acciones de notas", "eae2d051af": "Copiar todas las notas", @@ -9043,34 +9124,34 @@ "a5e5a11090": "Descartar todo falló: no se pueden eliminar los archivos antes de descartarlos", "8a5ba6a988": "No se pudo cargar la diferencia de commit", "fe5bd1a610": "Creando {{value0}}...", - "812cb992ee": "Abierto el {{value0}}", + "812cb992ee": "Abrir en {{value0}}", "eef5446523": "{{value0}} #{{value1}} ya está abierto", "0453ca3a9a": "{{value0}} creado, pero Orca no pudo actualizarlo todavía.", - "f99560ab29": "Cancelar {{value0}} falló", + "f99560ab29": "No se pudo abortar {{value0}}", "eae7a1da5f": "No se pudieron borrar las notas.", "657e0c90ad": "{{value0}} nota{{value1}}", - "df5040e3c3": "fuera del escenario", - "8cde1a2fb0": "Escenario", + "df5040e3c3": "Quitar del stage", + "8cde1a2fb0": "Agregar al stage", "d54dd48b0b": "Descartar cambios", "989f3d5e34": "Restaurar archivo", "2830dd64a2": "eliminado", "11463f7a98": "Eliminar archivo sin seguimiento", "d62bc0c7d8": "sin seguimiento", - "ab31221779": "Carpeta sin escenario", - "bfe9011a0e": "Carpeta de escenario", + "ab31221779": "Quitar carpeta del stage", + "bfe9011a0e": "Agregar carpeta al stage", "6d7f2a47e5": "Descartar carpeta", "9b367363b6": "Eliminar sin seguimiento en la carpeta", "540ca8f78c": "Cancelar fusión", "425f138269": "Abortar rebase", "04832d8047": "rebase", - "c105a61960": "unir", + "c105a61960": "merge", "d7a5942e41": "{{value0}}: {{value1}} sin resolver", - "c56ba7fa06": "diferencia", - "94c42b252e": "Maryland", + "c56ba7fa06": "Diff", + "94c42b252e": "MD", "e59bca888a": "markdown", - "b6922abb13": "No se puede cargar la comparación de ramas.", - "715d229c86": "Comparación de sucursales no disponible", - "97d8b03cdf": "Error en la comparación de sucursales", + "b6922abb13": "No se pudo cargar la comparación de ramas.", + "715d229c86": "Comparación de ramas no disponible", + "97d8b03cdf": "Error en la comparación de ramas", "424ee0e5bf": "error", "834cb3f23d": "Arreglar con IA", "60bd988f0b": "Corrección de IA", @@ -9082,30 +9163,30 @@ "26511c22b4": "Creando...", "7a09d7f9d2": "base", "383cf92c73": "árbol", - "d7ae61269b": "Comprometidos en Sucursal", + "d7ae61269b": "Commits en la rama", "48a003c1b1": "Cambios preparados", "d4ef4bafc5": "Cambios", "522f44dce5": "Archivos sin seguimiento", "3636d0f686": "listo", "d2e9189866": "todo", "a0cc0e6b4e": "cargando", - "9339382454": "Dejar de escena todo", - "24d2598eff": "poner en escena todo", + "9339382454": "Quitar todo del stage", + "24d2598eff": "Agregar todo al stage", "ce41708855": "Descartar todo", "2f609a2e7c": "Eliminar todo sin seguimiento", - "9bb062a886": "no comprometido", + "9bb062a886": "sin commit", "9febd8ab5f": "crear_pr", - "f62ce91ade": "origen", + "f62ce91ade": "origin", "3a231c845b": "desconocido", "3baf6c77b4": "Copiar todas las notas al portapapeles", "72f2bea3f4": "Expandir notas", "d13edef890": "Contraer notas", - "0fad573938": "No comprometido", + "0fad573938": "Sin commit", "77afaa8152": "Todo", "d6fb1df5fe": "{{value0}} ya está abierto", "05838cfdeb": "{{value0}} conflicto", "d206117f90": "{{value0}} conflicto ({{value1}})", - "0b5b8c234c": "Abierto {{value0}} ({{value1}})", + "0b5b8c234c": "Abrir {{value0}} ({{value1}})", "d97ef8f221": "líneas {{value0}}-{{value1}}", "6f8bfa0eb9": "línea {{value0}}", "c569d29a02": "ambos modificados", @@ -9115,9 +9196,9 @@ "24773ee581": "añadido por nosotros", "c03d7c952f": "añadido por ellos", "5b176fa431": "ambos eliminados", - "31f6d46278": "Irresoluto", + "31f6d46278": "Sin resolver", "2c417432b7": "Resuelto localmente", - "f3a8b2c1d0e5": "Ingrese un título {{value0}}.", + "f3a8b2c1d0e5": "Ingresa un título para {{value0}}.", "e2b7a1c0d9f4": "No se pudo crear {{value0}}", "hugeRepoIgnorePrompt": "Este repositorio tiene demasiados cambios activos. ¿Agregar \"{{value0}}\" a .gitignore?", "hugeRepoIgnoreAction": "Agregar a .gitignore", @@ -9138,59 +9219,61 @@ "473f18758e": "Configuración de IA de Source Control", "createPrIntentConfigureAi": "Agrega un mensaje de commit o configura la IA de Source Control.", "createPrIntentGenerateFailed": "No se pudo generar un mensaje de commit. Agrega uno y vuelve a intentarlo.", - "createPrIntentCommitFailed": "No se pudieron confirmar los cambios. Corrige el problema y vuelve a intentar Crear PR.", + "createPrIntentCommitFailed": "No se pudo hacer commit de los cambios. Corrige el issue y vuelve a intentar Crear PR.", "createPrIntentNeedsSync": "Sincroniza esta rama antes de crear una revisión.", "createPrIntentBranchNotReady": "La rama aún no está lista para crear una revisión.", "createPrIntentPublishing": "Publicando rama…", "createPrIntentForcePushing": "Haciendo force push con lease…", - "createPrIntentPushing": "Subiendo commits…", + "createPrIntentPushing": "Haciendo push de commits…", "createPrIntentRemoteFailed": "No se pudo actualizar la rama remota. Vuelve a intentar Crear PR.", "createPrIntentGeneratingDetails": "Generando detalles de revisión…", "createPrIntentBranchChangedDuringDetails": "La rama cambió mientras se generaban los detalles de la revisión. Vuelve a intentar Crear PR.", "createPrIntentCreatingReview": "Creando revisión…", "a91f8e2b01": "Ver como lista", "b82e9f3c12": "Ver como árbol", - "f71c4a8d90": "Más acciones de control de fuente", + "f71c4a8d90": "Más acciones de Source Control", "c8e4a1f902": "Filtro: {{value0}}", "b3c8f1a902": "Filtrar archivos por nombre", "d4f8c2a901": "Limpiar y cerrar filtro", "e8a1c4b203": "vs", - "f9b2441bb6": "1 commit adelante de {{value0}}", - "b715ef615b": "{{value0}} commits adelante de {{value1}}", - "c1a8f3e204": "1 commit detrás de {{value0}}", - "d2b9g4f315": "{{value0}} commits detrás de {{value1}}", - "e9c2a5d416": "A la par con {{value0}}" + "f9b2441bb6": "1 commit por delante de {{value0}}", + "b715ef615b": "{{value0}} commits por delante de {{value1}}", + "c1a8f3e204": "1 commit por detrás de {{value0}}", + "d2b9g4f315": "{{value0}} commits por detrás de {{value1}}", + "e9c2a5d416": "A la par con {{value0}}", + "createPrIntentCommitBlockedSummary": "Commit blocked: {{value0}} Fix the issue, then retry Create PR." }, "SourceControlAgentActionDialog": { "8e856842d1": "No se pudo iniciar el agente seleccionado.", "c075d00de1": "No se puede resolver la conexión del espacio de trabajo.", "38b899cc02": "Todos los repositorios", - "808cfe0a3b": "este repositorio", - "994cddd1f7": "no guardar" + "808cfe0a3b": "Este repositorio", + "994cddd1f7": "No guardar" }, "SourceControlAgentActionDialogForm": { - "013c9ac04a": "Fuera de", - "1bb611240f": "Utilice {basePrompt} para el mensaje predeterminado de Orca.", - "23280cbab1": "Esta plantilla no incluye {basePrompt}, por lo que el agente no recibirá el mensaje predeterminado de Orca.", + "013c9ac04a": "Guardar para", + "1bb611240f": "Usa {basePrompt} para el prompt predeterminado de Orca.", + "23280cbab1": "Esta plantilla no incluye {basePrompt}, por lo que el agente no recibirá el prompt predeterminado de Orca.", "5421a96acb": "Guardar e iniciar agente", - "6cefcdfba1": "Puede cambiarlo más tarde en la configuración de AI de control de fuente.", - "c29f9cf266": "Guarde este mensaje y no muestre esta reseña la próxima vez", - "d8f40128ee": "{basePrompt} es el mensaje predeterminado de Orca.", + "6cefcdfba1": "Puedes cambiarlo más tarde en la configuración de IA de Source Control.", + "c29f9cf266": "Guardar este prompt y no mostrar esta revisión la próxima vez", + "d8f40128ee": "{basePrompt} es el prompt predeterminado de Orca.", "ea4788705e": "Cancelar", "7ec6abbf2a": "Reiniciar", - "f4f3c9ca4a": "plantilla de aviso", + "f4f3c9ca4a": "Plantilla de prompt", "1bc0bdbb5e": "Lanzamiento:", - "fe119187bb": "--soneto modelo", + "fe119187bb": "--model sonnet", "bc8dc39f4b": "Argumentos CLI", "b99c33cec5": "Ajustes", "15c5d85706": "Agente", "3e8f21954f": "error", "74168d7ada": "idle", - "1d47db9bf0": "No hay agents habilitados", - "c7ff8cef11": "Detectando agents...", + "1d47db9bf0": "No hay agentes habilitados", + "c7ff8cef11": "Detectando agentes...", "b0da3a4d3e": "La receta de lanzamiento ya está guardada", "bff4795a6d": "Cambia el agente, los argumentos o la plantilla del prompt para actualizar la receta guardada.", - "5c75b24735": "Personaliza lo que recibe el agente antes de que Orca lo inicie." + "5c75b24735": "Personaliza lo que recibe el agente antes de que Orca lo inicie.", + "repoAgentOverrideNote": "Este repositorio anula tu valor predeterminado global ({{global}}) y actualmente ejecuta {{effective}}. Guarda en este repositorio para cambiar lo que se ejecuta aquí." }, "SourceControlTextGenerationDialog": { "c5b7fa7cb6": "Guardar como valor predeterminado global", @@ -9200,12 +9283,12 @@ }, "SourceControlTextGenerationDialogForm": { "25fcd8e49a": "Guardar valores predeterminados", - "d91b0a189d": "guardar receta", + "d91b0a189d": "Guardar receta", "1f6fcfb6cf": "Plantilla de comando", - "551ffd111b": "--soneto modelo", + "551ffd111b": "--model sonnet", "4eab815004": "Argumentos CLI", "914c8f6ac2": "Comando personalizado", - "cce2cbd01d": "Elige agente", + "cce2cbd01d": "Elegir agente", "9c14186dd2": "Agente" }, "activity": { @@ -9219,7 +9302,7 @@ "checks": { "panel": { "content": { - "3916814392": "detrás (commit base:", + "3916814392": "por detrás (commit base:", "755be805f6": "Sin comentarios", "751f7c6e5c": "Mostrando los primeros 100 comentarios por fuente", "94557d68e2": "Comentarios", @@ -9228,107 +9311,107 @@ "0fc6f743b3": "por", "8987d5a3dd": "Resuelto", "ba20d1a896": "Responder a {{value0}}", - "f6a40263ff": "Ahorrar", + "f6a40263ff": "Guardar", "b062f55f29": "Cancelar", "c1f6fc006a": "Responder", "2ba0a32bdd": "bot", "6cc6eace26": "Borrar", "03ca88f623": "Editar", - "d3923d18fe": "ir a comentar", + "d3923d18fe": "Ir al comentario", "1abb17aac9": "Más", "74c6885b8a": "Más acciones de comentarios", - "cbcc4ab3db": "Mostrando los primeros 100 cheques", - "0dca6bfab5": "Abrir detalles del cheque", - "991f50c7e4": "Aún no se han reportado controles", + "cbcc4ab3db": "Mostrando los primeros 100 checks", + "0dca6bfab5": "Abrir detalles del check", + "991f50c7e4": "No hay checks configurados", "9ad98f2a17": "pendiente", - "5e52f4ef7f": "defecto", - "02ca4f9074": "paso", - "e15a8b77ef": "No hay detalles en línea disponibles para esta verificación.", - "679bf2093c": "Copiar la cola del registro", - "d713f500b2": "Cola de registro (últimas 200 líneas)", + "5e52f4ef7f": "fallando", + "02ca4f9074": "correcto", + "e15a8b77ef": "No hay detalles en línea disponibles para este check.", + "679bf2093c": "Copiar extracto del log", + "d713f500b2": "Extracto del log", "a916648574": "Abrir detalles", - "07eccfa397": "No hay detalles disponibles para este cheque.", - "49731703ea": "Empleos", + "07eccfa397": "No hay detalles disponibles para este check.", + "49731703ea": "Jobs", "f2fe8a4e8f": "Anotaciones", - "d098e5529a": "Producción", + "d098e5529a": "Salida", "2dd5ddabc4": "flujo de trabajo #", - "aa8494ae3c": "controlar #", - "00e1c1658a": "Terminado", - "fd46a70f1a": "Comenzó", + "aa8494ae3c": "check #", + "00e1c1658a": "Completado", + "fd46a70f1a": "Iniciado", "a54ae21c6f": "Estado:", - "e4e3af15ee": "Ver todos los detalles", - "2524d1fb83": "Cola de registro disponible con todos los detalles.", - "a2fb3f4408": "Mostrando los primeros 100 empleos", + "e4e3af15ee": "Ver detalles completos", + "2524d1fb83": "Cola del log disponible en los detalles completos.", + "a2fb3f4408": "Mostrando los primeros 100 jobs", "df137989b3": "Mostrando las primeras 20 anotaciones", - "1f2b980522": "Cargando detalles del cheque...", + "1f2b980522": "Cargando detalles del check…", "0c96cd25e5": "Resolver", - "3a71a6ed0b": "Resuelva los conflictos antes de que se puedan completar las comprobaciones y la fusión.", + "3a71a6ed0b": "Resuelve los conflictos antes de que los checks y el merge puedan completarse.", "60186d8498": "Los conflictos bloquean esto", - "c16762ac8c": "Las comprobaciones y comentarios a continuación muestran el contexto obtenido actualmente.", + "c16762ac8c": "Los checks y comentarios a continuación muestran el contexto obtenido actualmente.", "9d0e7bcefc": "Sin acciones de PR de bloqueo", - "5856874b59": "Orca actualizará los controles mientras este panel permanezca abierto.", - "5341023167": "controlar", + "5856874b59": "Orca actualizará los checks mientras este panel permanezca abierto.", + "5341023167": "check", "b45db92d0e": "Arreglar", - "5d4ebf9391": "Inspeccione los detalles o inicie un pase de reparación de IA.", - "b652f38caf": "cheque fallido", - "87cd07c69a": "Esta rama tiene conflictos que deben ser resueltos.", - "0975eeaaef": "Archivos conflictivos", + "5d4ebf9391": "Inspecciona los detalles o inicia una pasada de corrección con IA.", + "b652f38caf": "check fallido", + "87cd07c69a": "Esta rama tiene conflictos que deben resolverse", + "0975eeaaef": "Archivos con conflictos", "6fa7f8723f": "commit", "2b2be92919": "Añadir comentario", "7440d09d2c": "Iniciar conversación", "b37ebdc51c": "Comentarios no disponibles.", "90206b6353": "comentario", "95ad090b01": "hilo", - "365254cc1b": "no resolver", - "7f793b571d": "Arrastre para cambiar el tamaño de los cheques", + "365254cc1b": "Marcar como no resuelto", + "7f793b571d": "Arrastra para cambiar el tamaño de los checks", "ee07b33924": "desconocido", "cdbfda4dec": "Anotación", "066fedd446": "Trabajos fallidos", - "ae8a04ef17": "Los detalles del archivo de conflicto no están disponibles", - "73d0675356": "Detalles refrescantes del conflicto...", - "f5bc5c4cf1": "The hosting provider reports conflicts, but local Git did not reproduce them. Refresh the review or push the branch to recalculate mergeability.", - "5bc9bda2af": "Run from this worktree", - "e87fb3d929": "Copy mergeability refresh commands", - "1e53e45072": "Copied", - "084c516efb": "Copy commands", + "ae8a04ef17": "Los detalles del archivo en conflicto no están disponibles", + "73d0675356": "Actualizando detalles del conflicto…", + "f5bc5c4cf1": "El proveedor de hosting informa conflictos, pero Git local no los reprodujo. Actualiza la revisión o haz push de la rama para recalcular la capacidad de merge.", + "5bc9bda2af": "Ejecutar desde este worktree", + "e87fb3d929": "Copiar comandos para actualizar la capacidad de merge", + "1e53e45072": "Copiado", + "084c516efb": "Copiar comandos", "5dc3af25c0": "Seleccionar comentario", - "d7a2f9c401": "Send unresolved {{value0}} comments", + "d7a2f9c401": "Enviar {{value0}} comentarios sin resolver", "d91f2a6c39": "Enviar {{value0}} comentarios en cola a la IA", - "a6de3e5a20": "Borrar comentarios en cola", + "a6de3e5a20": "Limpiar comentarios en cola", "49ea0937e4": "Agregar comentario a la lista de resolución", "9fecebb29d": "Agregar", - "b8c4e2a1f7": "View full logs", - "f8a2c91d04": "Queue for agent", - "b4e8a1c902": "Queued", + "b8c4e2a1f7": "Ver logs completos", + "f8a2c91d04": "Poner en cola para el agente", + "b4e8a1c902": "En cola", "7c1f0a2b11": "Abrir", - "e8b4c1a903": "Resolved · {{value0}}", - "c3a8e5d710": "Needs review · {{value0}}", - "a7f0c7e8d1": "Queue", - "8a621a2c4f": "Grouped", - "b13f85d75c": "Timeline", - "f5cf324efa": "Comment display options", - "5e6e5a13fa": "View", - "actionRequiredHint": "This check needs a manual action on GitHub (for example, approving the workflow run) before merging is unblocked." + "e8b4c1a903": "Resuelto · {{value0}}", + "c3a8e5d710": "Necesita revisión · {{value0}}", + "a7f0c7e8d1": "Poner en cola", + "8a621a2c4f": "Agrupados", + "b13f85d75c": "Cronología", + "f5cf324efa": "Opciones de visualización de comentarios", + "5e6e5a13fa": "Vista", + "actionRequiredHint": "Este check requiere una acción manual en GitHub (por ejemplo, aprobar la ejecución del workflow) antes de que el merge quede desbloqueado." }, "empty": { "state": { - "3322603418": "Pull request status unavailable", - "5b0cfae9a5": "Cree un {{value0}} para iniciar comprobaciones y revisiones.", + "3322603418": "Estado del pull request no disponible", + "5b0cfae9a5": "Crea un {{value0}} para iniciar checks y revisión.", "13e1c7d5ed": "No se encontró {{value0}}", "d372072df1": "La actualización de GitHub está pausada por el presupuesto de límite de velocidad actual", - "7c299df37b": "No se encontró ninguna solicitud de extracción", + "7c299df37b": "No se encontró ningún pull request", "3d4af82ff4": "Actualizando el estado de GitHub para esta rama", - "938b5606a6": "Comprobando la solicitud de extracción", + "938b5606a6": "Comprobando si hay un pull request", "6ba2440770": "Esperando actualizar el estado de GitHub para esta rama", "2bdd7aaf2d": "No se pudo actualizar el estado de GitHub. Se conservaron los datos almacenados en caché existentes.", - "5f478ab3d3": "No se pudo actualizar la solicitud de extracción", - "6ce9d4e069": "Empuje su rama antes de crear un {{value0}}.", - "76e15946a9": "La rama tiene commits no enviadas", - "f8543140cc": "Publique esta rama antes de crear un {{value0}}.", - "41252bc53f": "Sucursal no publicada", - "05e4aec17b": "{{value0}} cheques estarán disponibles una vez completada la operación.", + "5f478ab3d3": "No se pudo actualizar el pull request", + "6ce9d4e069": "Haz push de la rama antes de crear un {{value0}}.", + "76e15946a9": "La rama tiene commits sin push", + "f8543140cc": "Publica esta rama antes de crear un {{value0}}.", + "41252bc53f": "Rama no publicada", + "05e4aec17b": "Los checks de {{value0}} estarán disponibles cuando termine la operación", "d77c513c1e": "{{value0}} en progreso", - "b597440265": "Refresh GitHub status for this branch to load checks and review." + "b597440265": "Actualiza el estado de GitHub de esta rama para cargar checks y revisión." } } } @@ -9337,19 +9420,19 @@ "mr": { "merge": { "state": { - "04a3015a12": "Capaz de fusionarse", - "53c6d3b7e9": "GitLab dice que este MR se puede fusionar, pero el proceso aún está en ejecución", - "65c847ad1e": "Cheques pendientes", - "b41fbc180c": "GitLab dice que este MR se puede fusionar, pero algunos trabajos de canalización fallaron", + "04a3015a12": "Se puede hacer merge", + "53c6d3b7e9": "GitLab dice que este MR puede hacer merge, pero el pipeline sigue en ejecución", + "65c847ad1e": "Checks pendientes", + "b41fbc180c": "GitLab dice que este MR puede hacer merge, pero algunos jobs del pipeline fallaron", "49ac4fec10": "Los controles fallaron", - "22b7e50621": "Los informes de GitLab fusionan conflictos", + "22b7e50621": "GitLab informa conflictos de merge", "96b05e374c": "Conflictos", - "d63bb6f76e": "Esta solicitud de fusión aún es un borrador.", + "d63bb6f76e": "Este merge request aún está en borrador", "b2715092c6": "Borrador", "2388413f28": "Esta solicitud de fusión está cerrada", "88d044c42f": "Cerrado", - "ee482a2bad": "Esta solicitud de fusión ya está fusionada", - "fae95ae20d": "Fusionado" + "ee482a2bad": "Este merge request ya se hizo merge", + "fae95ae20d": "Mergeado" } } } @@ -9360,8 +9443,8 @@ "864111caa2": "Posición de la barra de actividades", "e8e2e4ce74": "Alternar barra lateral derecha", "441733b630": "Puertos", - "83a10e3c44": "cheques", - "0314901467": "Control de fuente", + "83a10e3c44": "Checks", + "0314901467": "Control de código fuente", "06219e4cb1": "Buscar", "8bc2bbc3a0": "Explorador", "45b78f03bc": "lado", @@ -9373,8 +9456,8 @@ "ef182dcb12": "buscar", "fc3095d2ed": "explorador", "aiVaultSessionHistory": "Agentes", - "folderWorkspaces": "Árboles de trabajo adjuntos", - "parentPrChecks": "Comprobaciones de PR" + "folderWorkspaces": "Worktrees adjuntos", + "parentPrChecks": "Checks de PR" }, "right": { "panel": { @@ -9384,9 +9467,9 @@ "cf5a7aba6f": "Lista", "d6d9c3c947": "Cita", "f49e0a21e0": "Código", - "542bf6a7e2": "Itálico", - "256300f8ea": "Atrevido", - "87aff03d63": "Envío...", + "542bf6a7e2": "Cursiva", + "256300f8ea": "Negrita", + "87aff03d63": "Enviando...", "commentTooLarge": "El comentario es demasiado grande para enviarlo de forma segura." } } @@ -9400,10 +9483,10 @@ "launch": { "a8b97d2318": "Se inició un agente de IA para el error de commit.", "5540ff50cc": "No se pudo generar el comando de inicio del agente.", - "9bbd9077a2": "No hay agents de IA habilitados. Configure los agents en Configuración.", - "d481ab22f9": "El agente de IA guardado no está disponible. Utilice Personalizar inicio para elegir otro agente.", - "f2b47026e8": "El mensaje de error de Commit está vacío. Actualice la configuración de AI de control de fuente.", - "4f4e0418a0": "No se pudo generar el mensaje del agente.", + "9bbd9077a2": "No hay agentes de IA habilitados. Configura los agentes en Configuración.", + "d481ab22f9": "El agente de IA guardado no está disponible. Usa Personalizar inicio para elegir otro agente.", + "f2b47026e8": "El prompt de error de commit está vacío. Actualiza la configuración de IA de Source Control.", + "4f4e0418a0": "No se pudo generar el prompt del agente.", "216f762bd7": "No se puede resolver la conexión del espacio de trabajo." } } @@ -9411,11 +9494,11 @@ }, "discard": { "confirmation": { - "2ae5a785b3": "¿Descartar todos los cambios no preparados?", - "ddf36f291c": "Esto eliminará y revertirá todos los cambios preparados. Se eliminarán los archivos nuevos preparados. Esto no se puede deshacer.", - "5ddd8cac7f": "¿Descartar todos los cambios por etapas?", + "2ae5a785b3": "¿Descartar todos los cambios unstaged?", + "ddf36f291c": "Esto quitará del stage y revertirá todos los cambios staged. Se eliminarán los archivos nuevos staged. Esto no se puede deshacer.", + "5ddd8cac7f": "¿Descartar todos los cambios staged?", "1426c2efff": "Esto revertirá todos los cambios realizados en este archivo. Esto no se puede deshacer.", - "d4df3a61df": "¿Descartar cambios a \"{{value0}}\"?", + "d4df3a61df": "¿Descartar cambios en \"{{value0}}\"?", "40e9357b2a": "Esto restaurará el archivo desde HEAD y descartará la eliminación. Esto no se puede deshacer.", "5c0bdbc4cb": "¿Restaurar \"{{value0}}\"?", "d97bf697c9": "Esto eliminará permanentemente este archivo. Esto no se puede deshacer.", @@ -9423,7 +9506,7 @@ }, "dialog": { "3bc61dc989": "Cancelar", - "15efa778e3": "Desechar", + "15efa778e3": "Descartar", "6de99d162b": "entrada", "42f89dd030": "archivos", "e7611dca35": "archivo", @@ -9434,59 +9517,59 @@ }, "dropdown": { "items": { - "7aad2c0240": "Operación de revisión alojada en curso...", + "7aad2c0240": "Operación de review alojada en curso…", "9e779995dd": "Crear {{value0}}", - "226b85a3a7": "Buscar", + "226b85a3a7": "Fetch", "323bb614aa": "Commit y sincronizar", "2b8e6595fd": "Commit", - "04d709801d": "Fetch from remote without merging" + "04d709801d": "Fetch del remoto sin hacer merge" } }, "primary": { "action": { "ed93b4f14f": "Commit", - "946a8a05ea": "Crea un {{value0}} para esta rama", + "946a8a05ea": "Crear un {{value0}} para esta rama", "e7ffa46946": "Crear {{value0}}", - "95550cff15": "Empujar", - "d64292a938": "Jalar", + "95550cff15": "Push", + "d64292a938": "Pull", "795f1509c5": "Sincronizar", - "390abeab93": "Empuje forzado", - "1884cf34af": "Publicar esta rama en origen", - "7b4d02e6b8": "Rama de publicación", - "3d5dccef0b": "Nada que comprometer. PR ya está fusionado.", - "41d4bcf157": "Comprobando el estado de las PR...", - "acce237921": "Nada que comprometer. La sucursal no tiene cambios para publicar.", - "fa3bd4f40c": "Prepare al menos un archivo para commit", - "5a477d80cb": "Organiza todos los cambios", - "18a0fca877": "Escenario todo", - "f01f16d77f": "Ingrese un mensaje de commit para commit", - "ab41fb926b": "Realizar cambios por etapas", - "2d8f185fbc": "Prepare todos los cambios antes de enviar archivos parcialmente preparados", - "a6457b46a7": "Resolver conflictos antes de comprometerse", + "390abeab93": "Force Push", + "1884cf34af": "Publicar esta rama en origin", + "7b4d02e6b8": "Publicar rama", + "3d5dccef0b": "No hay nada para hacer commit. El PR ya tiene merge.", + "41d4bcf157": "Comprobando el estado del PR…", + "acce237921": "No hay nada para hacer commit. La rama no tiene cambios para publicar.", + "fa3bd4f40c": "Haz stage de al menos un archivo para crear un commit", + "5a477d80cb": "Hacer stage de todos los cambios", + "18a0fca877": "Hacer stage de todo", + "f01f16d77f": "Ingresa un mensaje de commit para hacer commit", + "ab41fb926b": "Hacer commit de los cambios staged", + "2d8f185fbc": "Haz stage de todos los cambios antes de commitear archivos parcialmente staged", + "a6457b46a7": "Resuelve los conflictos antes de hacer commit", "484f45c439": "{{value0}} en progreso…", - "6f7a8b9c0d": "Remote operation in progress…", - "7f8a9b0c1d": "Remote operation in progress — try again once it finishes", - "74fc171e99": "Empuje forzado en progreso...", - "16aee3a5c1": "Compromiso en progreso...", - "e61b0d7a3c": "Cambia a una rama antes de publicar commits.", - "1d47e850cf": "Envía actualizaciones a la rama de revisión vinculada", - "c39d0c75c3": "El destino de la rama de revisión vinculada no está disponible.", + "6f7a8b9c0d": "Operación remota en curso…", + "7f8a9b0c1d": "Operación remota en curso — inténtalo de nuevo cuando termine", + "74fc171e99": "Force Push en curso…", + "16aee3a5c1": "Commit en curso…", + "e61b0d7a3c": "Haz checkout de una rama antes de publicar commits.", + "1d47e850cf": "Haz push de actualizaciones a la rama de review vinculada", + "c39d0c75c3": "El destino de la rama de review vinculada no está disponible.", "8c6d15a07d": "Crear PR", "d37e68f61d": "Preparando la rama para revisión…", "c72e5e65d1": "Prepara esta rama y crea un {{value0}}", - "b8e4f2a901": "Wait for the remote operation to finish.", - "c9f3a1b802": "Resolve conflicts before creating a {{value0}}.", - "d2a8c4e703": "No changes on this branch to include in a {{value0}}.", - "e3b9d5f814": "Cannot create a {{value0}} from the default branch.", - "f4c0e6a925": "Commit changes before creating a {{value0}}.", - "a5d1f7b036": "Publish commits before creating a {{value0}}.", - "b6e2a8c147": "Push commits before creating a {{value0}}.", - "c7f3b9d258": "Sync this branch before creating a {{value0}}.", - "d8a4c0e369": "Authenticate before creating a {{value0}}.", - "e9b5d1f470": "Check out a branch before creating a {{value0}}.", - "f0c6e2a581": "This branch is not ready for a {{value0}} yet.", - "8f9a0b1c2d": "Nothing to commit. Branch is up to date.", - "h3i4j5k607": "Checking whether this branch can create a {{value0}}…" + "b8e4f2a901": "Espera a que termine la operación remota.", + "c9f3a1b802": "Resuelve los conflictos antes de crear un {{value0}}.", + "d2a8c4e703": "No hay cambios en esta rama para incluir en un {{value0}}.", + "e3b9d5f814": "No se puede crear un {{value0}} desde la rama predeterminada.", + "f4c0e6a925": "Haz commit de los cambios antes de crear un {{value0}}.", + "a5d1f7b036": "Publica los commits antes de crear un {{value0}}.", + "b6e2a8c147": "Haz push de los commits antes de crear un {{value0}}.", + "c7f3b9d258": "Sincroniza esta rama antes de crear un {{value0}}.", + "d8a4c0e369": "Autentícate antes de crear un {{value0}}.", + "e9b5d1f470": "Haz checkout de una rama antes de crear un {{value0}}.", + "f0c6e2a581": "Esta rama aún no está lista para un {{value0}}.", + "8f9a0b1c2d": "No hay nada para hacer commit. La rama está actualizada.", + "h3i4j5k607": "Comprobando si esta rama puede crear un {{value0}}…" } } } @@ -9501,14 +9584,14 @@ } }, "useFileDeletion": { - "72691dfebc": "No se pudo realizar {{value0}} '{{value1}}'.", + "72691dfebc": "No se pudo {{value0}} '{{value1}}'.", "96affe1302": "'{{value0}}' se movió a {{value1}}", "74727df633": "'{{value0}}' eliminado", - "d979a4fbb5": "¿Eliminar permanentemente '{{value0}}'?", + "d979a4fbb5": "¿Eliminar '{{value0}}' permanentemente?", "a76c74f105": "destructivo", - "92276aceb7": "Borrar", - "7fb9435c86": "This permanently deletes the directory and its contents on the remote host. This cannot be undone.", - "23e98f192f": "This permanently deletes the file on the remote host. This cannot be undone." + "92276aceb7": "Eliminar", + "7fb9435c86": "Esto elimina permanentemente el directorio y su contenido en el host remoto. Esto no se puede deshacer.", + "23e98f192f": "Esto elimina permanentemente el archivo en el host remoto. Esto no se puede deshacer." }, "useFileExplorerHandlers": { "32cd9fd991": "No se puede abrir el destino del enlace simbólico" @@ -9528,67 +9611,71 @@ "request": { "review": { "copy": { - "a1f8c3d2e4": "La inserción se realizó correctamente, pero la creación de {{value0}} falló: {{value1}}" + "a1f8c3d2e4": "El push se realizó correctamente, pero falló la creación de {{value0}}: {{value1}}" } } } } }, "AiVaultPanel": { - "resumeCommandCopied": "Resume command copied", + "resumeCommandCopied": "Comando para reanudar copiado", "valueCopied": "{{value0}} copiado", "valueCopyFailed": "No se pudo copiar {{value0}}", - "openWorkspaceBeforeResuming": "Open a workspace before resuming a session.", + "openWorkspaceBeforeResuming": "Abre un workspace antes de reanudar una sesión.", "localWorkspacesOnly": "Reanudar desde el historial solo está disponible en espacios de trabajo locales.", - "agentSessionQueued": "{{value0}} session queued", - "sessionHistory": "Agent Session History", - "shownRecent": "{{value0}} shown · {{value1}} recent", - "resumePastSessions": "Resume past sessions", - "refreshSessionHistory": "Refresh Session History", - "searchSessions": "Search sessions", - "clearSearch": "Clear search", - "remoteBrowseLocalHistory": "Remote workspaces can browse local history. Resume actions run from local workspaces.", - "transcriptsSkipped": "{{count}} transcript skipped", - "noAgentSessionsFound": "No agent sessions found", - "noSessionsMatchFilters": "No sessions match the current filters", + "agentSessionQueued": "Sesión de {{value0}} en cola", + "sessionHistory": "Historial de sesiones de agentes", + "shownRecent": "{{value0}} mostradas · {{value1}} recientes", + "resumePastSessions": "Reanudar sesiones anteriores", + "refreshSessionHistory": "Actualizar historial de sesiones", + "searchSessions": "Buscar sesiones", + "clearSearch": "Limpiar búsqueda", + "remoteBrowseLocalHistory": "Los espacios de trabajo remotos pueden explorar el historial local. Las acciones de reanudación se ejecutan desde espacios de trabajo locales.", + "transcriptsSkipped": "{{count}} transcripciones omitidas", + "noAgentSessionsFound": "No se encontraron sesiones de agentes", + "noSessionsMatchFilters": "Ninguna sesión coincide con los filtros actuales", "sessionId": "ID de sesión", - "logPath": "Ruta del registro", + "logPath": "Ruta del log", "agents": "Agentes", "sessionsShownCompact": "{{value0}} mostradas", - "originalPaneUnavailable": "Original pane is no longer available.", - "worktreeUnavailable": "Worktree is no longer available.", - "runtimeBrowseLocalHistory": "Runtime-hosted workspaces can browse local history. Resume actions are available in local and SSH workspaces.", - "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", - "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session." + "originalPaneUnavailable": "El panel original ya no está disponible.", + "worktreeUnavailable": "El worktree ya no está disponible.", + "runtimeBrowseLocalHistory": "Los workspaces alojados en runtime pueden explorar el historial local. Las acciones de reanudación están disponibles en workspaces locales y SSH.", + "runtimeWorkspacesUnsupported": "Reanudar desde el historial no está disponible en workspaces alojados en runtime.", + "openSupportedWorkspace": "Abre un workspace local o SSH antes de reanudar una sesión.", + "localSessionSshWorkspaceUnsupported": "El historial de esta sesión solo existe en este equipo. Para reanudarla, abre un workspace local. Los workspaces SSH no tienen acceso a ese historial.", + "sessionHostMismatchUnsupported": "Esta sesión pertenece a un host diferente. Abre un espacio de trabajo en el mismo host para reanudarla." }, "AiVaultPanelControls": { - "scanningSessions": "Scanning sessions", - "scopeAriaLabel": "Session History scope: {{value0}}", - "currentWorkspaceLower": "current workspace", - "currentWorktreeLower": "current worktree", - "allSessionsLower": "all sessions", - "thisScope": "This", - "allScope": "All", - "scope": "Scope", - "currentWorkspace": "Current workspace", - "allSessions": "All sessions", - "viewOptionsAriaLabel": "Session History view options", - "viewOptions": "View options", + "scanningSessions": "Buscando sesiones", + "scopeAriaLabel": "Ámbito del historial de sesiones: {{value0}}", + "currentWorkspaceLower": "workspace actual", + "currentWorktreeLower": "worktree actual", + "allSessionsLower": "todas las sesiones", + "thisScope": "Este", + "allScope": "Todo", + "scope": "Ámbito", + "currentWorkspace": "Espacio de trabajo actual", + "allSessions": "Todas las sesiones", + "viewOptionsAriaLabel": "Opciones de vista del historial de sesiones", + "viewOptions": "Opciones de vista", "agents": "Agentes", - "sort": "Sort", - "lastUpdated": "Last updated", - "created": "Created", - "group": "Group", + "sort": "Ordenar", + "lastUpdated": "Última actualización", + "created": "Creado", + "group": "Agrupar", "folder": "Carpeta", "agent": "Agente", - "resetView": "Reset view", - "hideEmptySessions": "Hide empty sessions", + "resetView": "Restablecer vista", + "hideEmptySessions": "Ocultar sesiones vacías", "workspaceScope": "Workspace", "worktreeScope": "Worktree", "globalScope": "Global", "projectScope": "Proyecto", "currentProjectLower": "proyecto actual", - "project": "Proyecto" + "project": "Proyecto", + "hostScopeAriaLabel": "Session History host: {{value0}}", + "host": "Host" }, "AiVaultSessionDetails": { "updated": "Actualizado", @@ -9607,8 +9694,8 @@ "resumeCommand": "Comando para reanudar", "sessionActions": "Acciones de sesión {{value0}}", "resumeInNewTab": "Reanudar en nueva pestaña", - "resumeInWorktree": "Reanudar en árbol de trabajo", - "viewLog": "Ver registro", + "resumeInWorktree": "Reanudar en worktree", + "viewLog": "Ver log", "copyResumeCommand": "Copiar comando para reanudar", "openLog": "Abrir registro", "revealLog": "Mostrar registro", @@ -9639,19 +9726,19 @@ "systemRole": "Sistema", "sessionRole": "Sesión", "jumpToOriginalPane": "Saltar al panel original", - "worktree": "Árbol de trabajo", - "jumpToWorktree": "Saltar al árbol de trabajo" + "worktree": "Worktree", + "jumpToWorktree": "Saltar al worktree" }, "AiVaultSessionRow": { - "resumeAgentSession": "Reanudar sesión {{value0}}", + "resumeAgentSession": "Reanudar sesión de {{value0}}", "resumeInNewTab": "Reanudar en nueva pestaña", "copyResumeCommand": "Copiar comando para reanudar", "openLog": "Abrir registro", - "revealLog": "Mostrar registro", + "revealLog": "Mostrar registro en carpeta", "openWorkingDirectory": "Abrir directorio de trabajo", "copySessionId": "Copiar ID de sesión", "copyLogPath": "Copiar ruta de registro", - "messageCount": "{{value0}} msjs", + "messageCount": "{{value0}} mens.", "tokenCount": "{{value0}} tok", "toggleSessionDetails": "Detalles de sesión de {{value0}}", "hideDetails": "Ocultar detalles", @@ -9666,7 +9753,7 @@ "systemRole": "Sistema", "sessionRole": "Sesión", "jumpToOriginalPane": "Saltar al panel original", - "jumpToWorktree": "Saltar al árbol de trabajo" + "jumpToWorktree": "Saltar al worktree" }, "FileExplorerNameFilter": { "26fb73c6e3": "Buscar archivos", @@ -9696,15 +9783,15 @@ "pull": { "policy": { "notice": { - "merge": "Fusionar (Merge)", - "mergeDescription": "Crear un commit de fusión cuando tanto el repositorio local como el remoto hayan cambiado.", - "rebase": "Reorganizar (Rebase)", - "rebaseDescription": "Volver a aplicar los commits locales sobre la rama remota.", - "fastForwardOnly": "Solo avance rápido (Fast-forward)", - "fastForwardOnlyDescription": "Solo realizar pull cuando no se requiera fusión ni rebase.", + "merge": "Merge", + "mergeDescription": "Crea un commit de merge cuando hayan cambiado tanto la rama local como la remota.", + "rebase": "Rebase", + "rebaseDescription": "Reaplica los commits locales sobre la rama remota.", + "fastForwardOnly": "Solo fast-forward", + "fastForwardOnlyDescription": "Solo permite pull cuando no se necesita merge ni rebase.", "title": "El pull requiere una política", - "diverged": "Divergido", - "body": "Esta rama tiene commits locales y remotos. Ejecute un comando en este árbol de trabajo o en el host SSH, luego intente Pull o Sincronizar de nuevo.", + "diverged": "Divergida", + "body": "Esta rama tiene commits locales y remotos. Ejecuta un comando en este worktree o en el host SSH y luego intenta Pull o Sync de nuevo.", "copyAria": "Copiar el comando de política de pull {{value0}}", "copied": "Copiado", "copyCommand": "Copiar comando" @@ -9712,15 +9799,15 @@ } }, "AiVaultSessionWorktree": { - "currentWorktree": "Árbol de trabajo actual", - "activeWorktree": "Árbol de trabajo activo", - "archivedWorktree": "Árbol de trabajo archivado", - "unavailableWorktree": "Árbol de trabajo no disponible", - "jumpToWorktree": "Saltar al árbol de trabajo", - "noRecordedWorktree": "No se registró ningún árbol de trabajo para esta sesión.", - "archivedJumpUnavailable": "Esta sesión se encuentra en un árbol de trabajo archivado.", - "noActiveWorktreeMatch": "Ningún árbol de trabajo activo coincide con esta sesión.", - "noActiveWorktreeTarget": "No hay ningún árbol de trabajo activo disponible." + "currentWorktree": "Worktree actual", + "activeWorktree": "Worktree activo", + "archivedWorktree": "Worktree archivado", + "unavailableWorktree": "Worktree no disponible", + "jumpToWorktree": "Saltar al worktree", + "noRecordedWorktree": "No se registró ningún worktree para esta sesión.", + "archivedJumpUnavailable": "Esta sesión está en un worktree archivado.", + "noActiveWorktreeMatch": "Ningún worktree activo coincide con esta sesión.", + "noActiveWorktreeTarget": "No hay ningún worktree activo disponible." } } }, @@ -9732,32 +9819,32 @@ "929734aea5": "Deseleccionar todo" }, "NestedRepoScanLimitNotice": { - "642a43c139": "Límites de análisis del repositorio anidado", - "574eb5408b": "Mostrando resultados de escaneo parciales.", + "642a43c139": "Límites de escaneo de repositorios anidados", + "574eb5408b": "Mostrando resultados parciales del escaneo.", "03e9beab7b": "El escaneo se detuvo antes de tiempo." }, "RepoCombobox": { "b3e15f4525": "Agregar proyecto", - "b4a235e886": "Agregar proyecto", + "b4a235e886": "Agregando proyecto", "3639fd9da2": "SSH", - "e7ed739236": "Ningún proyecto/carpeta coincide con su búsqueda.", + "e7ed739236": "Ningún proyecto o carpeta coincide con tu búsqueda.", "a0c48f5f29": "Buscar proyectos/carpetas...", "116812151a": "Agregando proyecto…" }, "repo": { "icon": { "0ad395d475": "Caja", - "857977b901": "formas", - "b1b8d99fc4": "AI", - "137bdb1856": "Métrica", + "857977b901": "Formas", + "b1b8d99fc4": "IA", + "137bdb1856": "Métricas", "d202c659a3": "Diseño", - "c4fd14299d": "Compañía", - "4ab9433660": "Trabajar", + "c4fd14299d": "Empresa", + "4ab9433660": "Trabajo", "febfbe0cd5": "Herramientas", "ecf63ec3ef": "Lanzamiento", "31826b712e": "API", - "70bef15d40": "capas", - "b5fac337aa": "Calcular", + "70bef15d40": "Capas", + "b5fac337aa": "Cómputo", "d37b4e2641": "Servidor", "3c5a593bc8": "Web", "477b28c948": "Base de datos", @@ -9772,68 +9859,64 @@ "pet": { "pet": { "models": { - "7433516faf": "Duendecillo", + "7433516faf": "Gremlin", "a84d5677ff": "OpenCode", - "2528586aa7": "Claudio" + "2528586aa7": "Claudino" } - }, - "PetOverlay": { - "de932b0e8f": "@keyframes pet-bob { 0%,100% { transformar: traducirY(0); } 50% { transformar: traducirY(-4px); } }", - "4712d196c6": "@keyframes pet-{{value0}} { desde { posición-fondo: {{value1}}px {{value2}}px; } a { posición de fondo: {{value3}}px {{value4}}px; } }" } }, "onboarding": { "AgentStep": { "e6a369bd04": "Agentes populares", - "d7b3ef168b": "Detectado en su sistema", + "d7b3ef168b": "Detectados en tu sistema", "9c163bb0e0": "Instrucciones de instalación", - "69af7e9c1c": "aún no está en tu RUTA. Orca lo configurará como predeterminado y podrás instalarlo en cualquier momento.", - "1eee1c7bd8": "No se detectaron agents en su RUTA. Elija uno para instalarlo más tarde o continúe con un terminal en blanco.", - "hideAgents": "Ocultar agents", - "showMoreAgents": "Mostrar {{value0}} más agents→", - "yoloPermissionsLabel": "Yolo / Dangerously skip permissions", - "yoloPermissionsInfo": "Agent permission info", - "yoloPermissionsTooltip": "Skip permission checks for agents for less interruptions" + "69af7e9c1c": "aún no está en tu PATH. Orca lo configurará como predeterminado y podrás instalarlo en cualquier momento.", + "1eee1c7bd8": "No se detectaron agentes en tu PATH. Elige uno para instalarlo más tarde o continúa con una terminal en blanco.", + "hideAgents": "Ocultar agentes", + "showMoreAgents": "Mostrar {{value0}} agentes más→", + "yoloPermissionsLabel": "Yolo / Omitir permisos de forma peligrosa", + "yoloPermissionsInfo": "Información de permisos del agente", + "yoloPermissionsTooltip": "Omite las comprobaciones de permisos de los agentes para reducir interrupciones" }, "FeatureSetupChecklist": { - "77f74946f5": "Los Agents pueden enviarse mensajes entre sí, realizar tareas y coordinar traspasos.", - "399cf885c0": "Orquestación de Agents", - "c5292c409d": "Los Agents pueden inspeccionar las ventanas de las aplicaciones y operar aplicaciones locales cuando usted lo solicite.", + "77f74946f5": "Los agentes pueden enviarse mensajes entre sí, tomar tareas y coordinar handoffs.", + "399cf885c0": "Orquestación de agentes", + "c5292c409d": "Los agentes pueden inspeccionar ventanas de apps y operar apps locales cuando se lo pidas.", "1ecfb490ac": "Uso de la computadora", - "01426f3a23": "Los Agents pueden navegar por sitios, inspeccionar páginas y realizar tareas del navegador.", - "ea85d9e628": "Uso del navegador del Agent", - "linearTicketsTitle": "Habilidad de Agent de Linear", - "linearTicketsDescription": "Los Agents pueden usar tareas Linear enlazadas para traspasos más completos y conscientes del ticket.", - "linearTicketsSetupSummary": "Recomendado para espacios de trabajo Linear; no afecta la configuración de conexión de Linear." + "01426f3a23": "Los agentes pueden navegar por sitios, inspeccionar páginas y trabajar en tareas del navegador.", + "ea85d9e628": "Uso del navegador por agentes", + "linearTicketsTitle": "Skill de agente de Linear", + "linearTicketsDescription": "Los agentes pueden usar tareas de Linear enlazadas para handoffs con más contexto de ticket.", + "linearTicketsSetupSummary": "Recomendado para espacios de trabajo de Linear; no afecta la configuración de conexión de Linear." }, "FeatureSetupInlineTerminal": { - "789b59936e": "Presione Enter para ejecutar el comando y confirme npx si se le solicita. También puedes configurar esto más tarde en Configuración.", - "47fc6cc6dc": "Comando de configuración de habilidades", - "c767ab7061": "Configuración de habilidades" + "789b59936e": "Pulsa Enter para ejecutar el comando y confirma npx si se te solicita. También puedes configurarlo más tarde en Configuración.", + "47fc6cc6dc": "Comando de configuración de skills", + "c767ab7061": "Configuración de skills" }, "IntegrationsStep": { "277f30eb34": "Linear, GitLab, Bitbucket, Azure DevOps, Gitea y Jira se encuentran en Configuración > Integraciones.", "3a3e360289": "Más fuentes de tareas", - "80e3ce0bc9": "Vuelva a comprobar", + "80e3ce0bc9": "Volver a comprobar", "04ef416712": "Agregar acceso Linear", "dd9c186a8b": "Agregar acceso al espacio de trabajo", "c91a5782f1": "Conectado", "27743304b1": "Linear", - "af69f42372": "Presione Entrar para ejecutar la autenticación CLI de GitHub. Vuelva a verificar GitHub después de que finalice el flujo del navegador o dispositivo.", + "af69f42372": "Pulsa Enter para ejecutar la autenticación de la CLI de GitHub. Vuelve a comprobar GitHub cuando termine el flujo del navegador o del dispositivo.", "f9d2e12d17": "Comando de inicio de sesión de GitHub", "6d469169f2": "Configuración de GitHub", "bd5d976fb2": "Instalar gh", - "50db38cf4b": "Solicitudes de extracción, problemas y estado de verificación.", - "c1547656f0": "De cheques…", - "8405043962": "Iniciar sesión es necesario", - "5c115cb713": "CLI no instalado", + "50db38cf4b": "Pull requests, issues y estado de checks.", + "c1547656f0": "Comprobando…", + "8405043962": "Se requiere iniciar sesión", + "5c115cb713": "CLI no instalada", "217beb0658": "GitHub", "4983ae7433": "Agregue acceso Linear con una clave API personal. Las claves de acceso total pueden mostrar todos los equipos a los que puede acceder el propietario de la clave.", - "b08a6ac93c": "{{value0}} espacio de trabajo{{value1}} vinculado. Agregue otro espacio de trabajo o reemplace una clave restringida en cualquier momento.", + "b08a6ac93c": "{{value0}} workspace{{value1}} vinculado. Agrega otro workspace o reemplaza una clave restringida en cualquier momento.", "93f0c49ad1": "no autenticado", "a3bcf13694": "conectado", "d6e5dba05a": "Iniciar sesión", - "0b4a7d23ab": "Iniciar sesión", + "0b4a7d23ab": "Iniciando sesión", "a74c6d6b18": "no instalado" }, "NotificationStep": { @@ -9859,11 +9942,11 @@ "277ba45540": "Incorporación de Orca", "97c42cda00": "Instale la CLI de GitHub para:", "ae3b00ca82": "Configurar tareas de GitHub", - "ff92d15436": "Orca le notificará cuando los agents hayan terminado o necesiten ayuda.", + "ff92d15436": "Orca te notificará cuando los agentes terminen o necesiten ayuda.", "b054332836": "Configurar notificaciones", - "04ae28d8ca": "Elige el look que quieras mirar durante horas.", + "04ae28d8ca": "Elige la apariencia que quieras ver durante horas.", "f396db9f20": "Haz que te sientas como en casa", - "322fc50a18": "Orca trabaja con todos los agentes CLI. Elija el que alcanzará más. Cambie en cualquier momento.", + "322fc50a18": "Orca funciona con cualquier agente CLI. Elige el que más usarás. Puedes cambiarlo en cualquier momento.", "198b148b3c": "Elija su agente predeterminado", "a5e5da02f7": "integraciones", "35bbaf5ae0": "notificaciones", @@ -9877,7 +9960,7 @@ "111d3f8d92": "Saltar a la configuración del proyecto" }, "OnboardingInlineCommandTerminal": { - "4123609efd": "Terminal de arranque..." + "4123609efd": "Iniciando terminal..." }, "OnboardingSkipConfirmationDialog": { "9f47f345a4": "¡No tardará mucho!", @@ -9889,30 +9972,30 @@ }, "RepoStep": { "e8fdb36338": "Escaneo de repositorios. Haga clic para detener.", - "b7c4da0504": "SSH? Configurar hosts en Configuración", - "c33b190ca3": "Sólo rutas de servidor", + "b7c4da0504": "¿SSH? Configura hosts en Configuración", + "c33b190ca3": "Solo rutas del host", "7b679207e4": "Espacio de trabajo", - "24c7c8696c": "Clonar en la ruta del servidor", - "7932e95f68": "Clon", + "24c7c8696c": "Clonar en ruta del host", + "7932e95f68": "Clonar", "955134915e": "git@github.com:org/repo.git", "288d8444b7": "Pegue una URL HTTPS o SSH.", - "132425a3e3": "Clonar un repo", - "6558d50c69": "¿Quieres importar muchos repos a la vez? Seleccione la carpeta principal.", - "831524961f": "Elija cualquier directorio local, repo git o no.", + "132425a3e3": "Clonar un repositorio", + "6558d50c69": "¿Quieres importar muchos repositorios a la vez? Seleccione la carpeta principal.", + "831524961f": "Elija cualquier directorio local, repositorio git o no.", "f4e9c8dcf8": "Buscar una carpeta", "e8214aa632": "Abrir como carpeta", "3863747c56": "Agregar proyecto Git", - "2ebbc26343": "/inicio/usuario/proyecto", - "466108ab89": "Introduzca una ruta que exista en el servidor de ejecución.", - "8cab104e3c": "Abrir un proyecto de servidor", + "2ebbc26343": "/home/user/project", + "466108ab89": "Introduce una ruta que exista en el host seleccionado.", + "8cab104e3c": "Abrir un proyecto en este host", "2d20200346": "Importar repositorios", "27ca610db1": "Atrás", "cecd6593fa": "Carpeta escaneada:", "c7af322fc3": "Dejar de escanear", "c3d9d44ca2": "Detener escaneo", - "cf23006ba7": "Servidor de ejecución", - "7ec3f48820": "/casa/usuario", - "2e6438dd34": "{{value0}}Encontrado {{value1}} {{value2}} en esta carpeta." + "cf23006ba7": "Host seleccionado", + "7ec3f48820": "/home/user", + "2e6438dd34": "{{value0}}Se encontraron {{value1}} {{value2}} en esta carpeta." }, "ThemeStep": { "a4b254779d": "Tecla de opción de macOS", @@ -9920,22 +10003,22 @@ "8ca01945f2": "Divisores", "b3a99a2d29": "Ventana", "86c0f1caa2": "Relleno", - "06a24f4f2d": "Bandera", + "06a24f4f2d": "Colores", "c021e9dddd": "paleta temática", "ab2a583a97": "Cursor", "cc1858e19e": "Fuente", "248c812283": "Importar", - "7ee9234e54": "Se detectó una configuración fantasmal.", + "7ee9234e54": "Se detectó una configuración de Ghostty.", "78b6386140": "Importado de Ghostty.", "2c3aa538f8": "Buscando una configuración de Ghostty...", "94b9dc561d": "Configuración → Terminal", "dd5c16ad1b": "Más opciones de terminal, incluyendo fuente, cursor y paleta, en", - "ad192706e6": "Luz", + "ad192706e6": "Claro", "fa7b673ea9": "Oscuro", "827ea7b4a2": "Sistema", "699ddf83c2": "No se pudo importar la configuración de Ghostty", "16a9f0446a": "No se encontraron configuraciones de Ghostty para importar", - "ad19e5c916": "Importador…", + "ad19e5c916": "Importando…", "906c4373fe": "ajustes" }, "use": { @@ -9948,7 +10031,7 @@ } }, "AgentFeatureSetupStep": { - "97dcdc010f": "Instalar CLI y habilidades" + "97dcdc010f": "Activar capacidades" }, "WindowsTerminalStep": { "powerShell": "PowerShell", @@ -9980,7 +10063,7 @@ "new": { "workspace": { "SmartWorkspaceNameField": { - "2a0d535f69": "Crear nueva sucursal", + "2a0d535f69": "Crear nueva rama", "a44229ce4d": "como nombre del espacio de trabajo", "766083a596": "\"", "34ca97bce3": "\"", @@ -9992,7 +10075,7 @@ "9ef1a7c4b0": ", que es diferente del proyecto seleccionado.", "ad188067ae": "La URL de GitHub apunta a", "4bd98f1091": "¿Cambiar de proyecto?", - "0c9e668e3a": "Claro", + "0c9e668e3a": "Borrar", "7199ff19c7": "Borrar fuente seleccionada", "370a1faf67": "Abrir en el navegador", "2c69728c2a": "Abrir enlace en el navegador", @@ -10001,13 +10084,13 @@ "2cfc6be192": "GitLab", "7a47af0565": "Linear", "0a180280bd": "GitHub", - "b3c60c2b7c": "Elegante", + "b3c60c2b7c": "Inteligente", "26824f60dd": "Todo", "6fad211c66": "Cerrado", "2319d87718": "Fusionado", "622864b52a": "Abierto", "fda67f0b61": "proyecto actual", - "3e8bb1176a": "Conecte Linear en Configuración para buscar problemas.", + "3e8bb1176a": "Conecta Linear en Configuración para buscar issues.", "69ce292138": "lineal", "9c004911c3": "gitlab", "switchTaskSourceTitle": "¿Cambiar el origen de tareas?", @@ -10020,62 +10103,62 @@ "placeholderSmartWithBranchGitLab": "Escribe un nombre, #1234, rama, GitHub o URL de GitLab", "placeholderSmartGitLab": "Escribe un nombre, #1234, GitHub o URL de GitLab", "unavailable": "No disponible", - "searchGitHub": "Buscar PR e incidencias de GitHub", - "searchGitLab": "Buscar MR e incidencias de GitLab", + "searchGitHub": "Buscar PRs e issues de GitHub", + "searchGitLab": "Buscar MRs e issues de GitLab", "searchBranches": "Buscar ramas", - "searchLinear": "Buscar incidencias de Linear", + "searchLinear": "Buscar issues de Linear", "workspaceName": "Nombre del espacio de trabajo" }, "ProjectHostSetupCombobox": { - "empty": "No hosts are ready for this project.", - "placeholder": "Choose host" + "empty": "No hay hosts listos para este proyecto.", + "placeholder": "Elegir host" }, "ProjectCombobox": { - "search": "Search projects...", - "empty": "No projects match your search." + "search": "Buscar proyectos...", + "empty": "Ningún proyecto coincide con tu búsqueda." } } }, "mobile": { "MobileHero": { - "a8fb43cf1c": "Continue", + "a8fb43cf1c": "Continuar", "3f90dbd274": "Hecho", "b622eba64d": "Atrás", "65b3f2e8bc": "Generando…", - "27735e5f4e": "Emparejamiento QR", - "bb0074ce11": "Emparejamiento de código QR", + "27735e5f4e": "QR de emparejamiento", + "bb0074ce11": "Código QR de emparejamiento", "010dddcf27": "Copiar código de emparejamiento", "4c1df4eba7": "¿No puedes escanear?", "85067b9e06": "Actualizar interfaces de red", "ca85e595a7": "No se encontraron interfaces", "79d2f480da": "Interfaz de red para anunciar", "dfd2aa9d5d": "Red", - "2f077ef4eb": "y escanea el código.", + "2f077ef4eb": ", y escanea el código.", "3aa7bb2d8b": "Emparejar escritorio", - "d1495e5e64": "Abra Orca Mobile, toque", - "901c98bb93": "Empareja esto", + "d1495e5e64": "Abre Orca Mobile, toca", + "901c98bb93": "Empareja este", "3960f5c339": "Paso 2 de 2", - "3241f3c26a": "Instalar QR", - "7af266b80d": "Instalar código QR", + "3241f3c26a": "QR de instalación", + "7af266b80d": "Código QR de instalación", "aa97420ba4": "Copiar enlace de instalación", - "ac1eb64952": "Androide", + "ac1eb64952": "Android", "711e6f4b47": "iOS", - "e75647ace0": "Escanee el QR con su teléfono o abra el enlace de instalación para obtener Orca Mobile.", + "e75647ace0": "Escanea el código QR con tu teléfono o abre el enlace de instalación para obtener Orca Mobile.", "0d9b33299e": "Obtén la aplicación.", "92ddfdfa1f": "Paso 1 de 2", "ff48d9d520": "Emparejar otro dispositivo", "f9cbf4bb53": "Revocar dispositivo", "34f878d04f": "Revocar {{value0}}", - "94829abdb1": "emparejado", - "266c18c105": "Abre Orca Mobile para continuar donde lo dejaste o vincular otro dispositivo.", - "5410d55d79": "Orca Móvil", - "10d27b4cba": "empezar", + "94829abdb1": "Emparejado", + "266c18c105": "Abre Orca Mobile para continuar donde lo dejaste o emparejar otro dispositivo.", + "5410d55d79": "Orca Mobile", + "10d27b4cba": "Empezar", "da1d5e5ed0": "Disponible en", "ec0607bf66": "Plataformas móviles compatibles", - "b4ccce5cb7": "Controla Orca desde tu teléfono. Consulte a los agents, revise los cambios e inicie tareas mientras no está en su escritorio.", + "b4ccce5cb7": "Controla Orca desde tu teléfono. Consulta el estado de los agentes, revisa los cambios e inicia tareas cuando estés lejos de tu escritorio.", "cd4e5e816f": "Tus espacios de trabajo, en tu bolsillo.", "a6cffbbb0b": "Generar código", - "e59a252eca": "regenerar código", + "e59a252eca": "Regenerar código", "d0b52871ce": "Tus teléfonos están emparejados.", "051978a785": "Tu teléfono está emparejado.", "channel": { @@ -10091,7 +10174,7 @@ "6a66e38943": "No se pudo copiar el código de emparejamiento", "3c1f7168bb": "Código de emparejamiento copiado", "4c8bd11c1a": "No se pudo generar el código de emparejamiento", - "b353e18de1": "El transporte de WebSocket no se está ejecutando", + "b353e18de1": "El transporte WebSocket no está en ejecución", "4e1eb5d55c": "No se pudo revocar el dispositivo", "255372e6e8": "Dispositivo revocado", "1b4509a8a1": "emparejado", @@ -10099,24 +10182,24 @@ }, "MobilePageToolbar": { "ad2284a9e2": "Cerrar · Esc", - "9883b58693": "Cerrar Orca Móvil", + "9883b58693": "Cerrar Orca Mobile", "fb5f28330e": "Mostrar en la barra lateral", "c669abcf8f": "Ocultar de la barra lateral", - "e1c7b4a92d": "Configure in Settings > Mobile.", + "e1c7b4a92d": "Configúralo en Ajustes > Móvil.", "a4f8c2d91e": "Remove Orca Mobile from left sidebar", "b7e3d1c84a": "Show Orca Mobile in left sidebar", - "f3d8e5b71a": "Adds the shortcut back to the left sidebar." + "f3d8e5b71a": "Vuelve a añadir el acceso directo a la barra lateral." }, "PhoneCarousel": { "96d651cb87": "Sesión terminal", - "93217b41c1": "Lista de árbol de trabajo", + "93217b41c1": "Lista de worktrees", "89c7713645": "Pantalla de inicio de Orca Móvil" }, "mobile": { "platform": { "copy": { - "2a532d6fd7": "Escanee con su cámara Android para descargar el último APK de GitHub Releases.", - "432db52b73": "Escanee con la cámara de su iPhone para abrir la App Store.", + "2a532d6fd7": "Escanea con la cámara de tu Android para descargar el APK más reciente desde GitHub Releases.", + "432db52b73": "Escanea con la cámara de tu iPhone para abrir la App Store.", "preview": { "tagline": "Las funciones más recientes, actualizadas a diario." }, @@ -10137,17 +10220,17 @@ "0bad5b07c8": "GitHub y Linear", "d047197480": "GitHub · Linear", "a4c3f7b7aa": "Tareas", - "d33d7a9c29": "orca  ·  feat/página móvil", - "25d6e8a491": "hazaña/página móvil", + "d33d7a9c29": "orca  ·  feat/mobile-page", + "25d6e8a491": "feat/mobile-page", "c791677f2f": "Reanudar", "cf3f98fa3f": "Desconectado", - "091355da3d": "M1 Mini · inicio", - "0bc1881bc4": "Conectados · 40 árboles de trabajo · 5 activos", - "19c212e25e": "macbook pro", + "091355da3d": "M1 Mini · home", + "0bc1881bc4": "Conectado · 40 worktrees · 5 activos", + "19c212e25e": "MacBook Pro", "2f1a1d10c4": "Escritorios", - "156db8a68a": "RP creados", - "4a40af029b": "tiempo del Agent", - "00a6903322": "Agents generados", + "156db8a68a": "PRs creados", + "4a40af029b": "Tiempo de agentes", + "00a6903322": "Agentes iniciados", "c0e2e9dcd9": "Bienvenido de nuevo", "af761a0c0d": "Ajustes", "5d94e8ddcc": "Orca" @@ -10155,44 +10238,44 @@ "TerminalSlide": { "0bb39f8fe6": "Enviar", "69334b4b10": "Dictado de voz", - "29f2d13839": "Escribe un comando...", + "29f2d13839": "Escribe un comando…", "817090af40": "Ctrl+C", - "53ff909568": "Pestaña", - "4930eaaae7": "ESC", - "fa22927f13": "Pasta", + "53ff909568": "Tab", + "4930eaaae7": "Esc", + "fa22927f13": "Pegar", "985373052e": "Cambiar al modo teléfono", - "58a9ee6003": "formato de llamada de herramienta. ¿Quieres que agregue la diferencia a continuación?", + "58a9ee6003": "formato de tool-call. ¿Quieres que agregue el diff a continuación?", "aa64b519c6": "pantalla del terminal. Paleta Tokyonight, Menlo, real claude", - "e75112c834": "Reemplacé la diapositiva de escaneo en pares con una de alta fidelidad.", - "3ce3e8c892": "14 aprobados, 1 saltado (1,8s)", + "e75112c834": "Reemplacé la diapositiva de escaneo de emparejamiento por una de alta fidelidad", + "3ce3e8c892": "14 aprobados, 1 omitido (1,8 s)", "4b3666f9a9": "src/cache/worktree-cache.test.ts", - "1d448b69f7": "APROBAR", + "1d448b69f7": "PASS", "d39445686a": "src/transport/host-store.test.ts", - "a6e7cdc688": "prueba pnpm --filtro móvil", - "21b67dfc92": "Intento", - "d6d1041a1c": "⎿ Se reemplazó la diapositiva de escaneo de pares con sesión de terminal", - "336c0e070e": "móvil/orca-mobile-sidebar-mock-v3.html", + "a6e7cdc688": "pnpm test --filter mobile", + "21b67dfc92": "Bash", + "d6d1041a1c": "⎿ Se reemplazó la diapositiva de escaneo de emparejamiento por una sesión de terminal", + "336c0e070e": "mobile/orca-mobile-sidebar-mock-v3.html", "6d4ebd5833": "Editar", - "fc83e0d5ef": "⎿ Leer 2103 líneas", + "fc83e0d5ef": "⎿ Leídas 2103 líneas", "80cc356591": "Leer", "2c10d43745": "claude", "e0f98be657": "orca/feat-mobile-page", - "2defc05141": "desarrollador@mac", + "2defc05141": "dev@mac", "da121ba48d": "PLAN.md", - "e4befee569": "caparazón", + "e4befee569": "shell", "606aa93192": "Archivos", - "94febb0976": "control de fuente", + "94febb0976": "Control de código fuente", "8d6516312d": "2 terminales · claude activo", - "8432787c4e": "hazaña/página móvil", + "8432787c4e": "feat/mobile-page", "8fd998acd3": "Atrás" }, "WorktreeListSlide": { "357a519567": "Activo", "79a24ff530": "Fijado", - "22971156df": "Repo", + "22971156df": "Repositorio", "17f9e0d226": "Reciente", "0e3e809a4b": "Filtrar", - "b4271864bd": "macbook pro", + "b4271864bd": "MacBook Pro", "cefd048225": "Atrás", "c5ad56786d": "hilandero" } @@ -10201,8 +10284,8 @@ "title": "Dirección de red personalizada", "description": "Anuncia una dirección que tu teléfono pueda alcanzar cuando no esté en la misma red Wi-Fi: por ejemplo, un nombre de host de Tailscale o una IP estática.", "label": "Dirección", - "placeholder": "my-mac.ts.net o 192.168.1.50", - "hint": "Introduce una dirección IP o un nombre de host de Tailscale (termina en .ts.net).", + "placeholder": "my-mac.ts.net, home.example.com o 192.168.1.50", + "hint": "Introduce una dirección IP o un nombre de host (un nombre MagicDNS de Tailscale, un dominio DDNS o un nombre de host de LAN), opcionalmente con :puerto.", "cancel": "Cancelar", "use": "Usar dirección" }, @@ -10217,16 +10300,16 @@ "rate": { "limit": { "display": { - "ebc0e8ecf1": "Cargando presupuesto de API de GitLab...", - "a2d3d1fdde": "El presupuesto de la API de GitLab no está disponible.", - "a2f68645ac": "Actualizar el presupuesto de la API de GitLab", + "ebc0e8ecf1": "Cargando cuota de la API de GitLab...", + "a2d3d1fdde": "La cuota de la API de GitLab no está disponible.", + "a2f68645ac": "Actualizar la cuota de la API de GitLab", "2f9c16d6c3": "Orca usa REST a través de la CLI de GitLab.", - "14e144f7a7": "Presupuesto de la API de GitLab", - "3e2c982cfa": "izquierda, se reinicia en", + "14e144f7a7": "Cuota de la API de GitLab", + "3e2c982cfa": "restantes, se restablece en", "ea8ad0bae8": "de", - "0a891e8935": "API DESCANSO", - "953f7c6062": "Este host de GitLab no devolvió encabezados de límite de velocidad.", - "budget_scope_prefix": "Ámbito del presupuesto" + "0a891e8935": "API REST", + "953f7c6062": "Este host de GitLab no devolvió cabeceras de rate limit.", + "budget_scope_prefix": "Ámbito de la cuota" } } } @@ -10240,27 +10323,27 @@ "0ee79e0674": "Mover a la barra de estado" }, "FloatingTerminalOrchestrationDialog": { - "f726054620": "Permite a los agents traspasar contexto y coordinar el trabajo a través de Orca.", - "1cd3f8af64": "Habilidad de orquestación", - "6f0aed26b8": "Instale la CLI de Orca y la habilidad de orquestación para que los agents puedan coordinarse a través de Orca.", + "f726054620": "Permite a los agentes transferir contexto y coordinar el trabajo a través de Orca.", + "1cd3f8af64": "Skill de orquestación", + "6f0aed26b8": "Instala la CLI de Orca y el skill de orquestación para que los agentes puedan coordinarse a través de Orca.", "05d7aabc20": "No instalado", "630c0ac8c8": "Instalado", - "dfd021ce46": "De cheques...", + "dfd021ce46": "Comprobando...", "543f325a14": "Habilitar orquestación" }, "FloatingTerminalPanel": { "fc1042e92b": "Minimizar", "8b07759314": "Nuevo navegador", - "88ffb502e5": "Abrir nota de Markdowns", - "629528690b": "Nueva nota de Markdowns", - "3215fc73e9": "Nueva Terminal", - "da508bd7f5": "Ahorrar", + "88ffb502e5": "Abrir nota Markdown", + "629528690b": "Nueva nota Markdown", + "3215fc73e9": "Nueva terminal", + "da508bd7f5": "Guardar", "918c2139f3": "No guardar", "e7bf09d4d4": "Cancelar", "690b6fb98a": "Cambios no guardados", - "bbc177f98f": "Permitir", - "adc281394d": "Despedir", - "8cf80db43b": "Configure la CLI de Orca y la habilidad del agente para que los agents puedan coordinarse a través de Orca.", + "bbc177f98f": "Habilitar", + "adc281394d": "Descartar", + "8cf80db43b": "Configura la CLI de Orca y el skill de agente para que los agentes puedan coordinarse a través de Orca.", "2a3c5ddf5e": "Habilitar orquestación", "d6b563ae24": "Cargando editor...", "8b14ba6c17": "Nueva pestaña del navegador", @@ -10278,11 +10361,11 @@ "1bbaa0302f": "Minimizar el espacio de trabajo flotante", "3f4ca29961": "Maximizar el espacio de trabajo flotante", "1c79cba25d": "Restaurar el espacio de trabajo flotante", - "648352c51f": "Abra {{value0}} en el espacio de trabajo flotante", - "82da3701e7": "No se pudo generar el comando de inicio para {{value0}}.", + "648352c51f": "Abrir {{value0}} en el espacio de trabajo flotante", + "82da3701e7": "No se pudo crear el comando de inicio para {{value0}}.", "109870e023": "Maximizar", "b5686fee1e": "Restaurar", - "1e502f1284": "Abierto" + "1e502f1284": "Abrir" } } }, @@ -10290,66 +10373,66 @@ "wall": { "AgentCapabilitiesSetupAction": { "b8dc9dd8a2": "Instalado", - "1b51644c2d": "Deje que los agents controlen el escritorio, muevan el cursor, hagan clic y escriban en cualquier aplicación.", - "362a07517d": "Uso de la computadora", - "5e8fe5a72d": "Ofrezca a los agents acceso directo al navegador de Orca para que puedan probar páginas, realizar capturas de pantalla y actuar en función de lo que ven.", - "e638da007a": "Uso del navegador del Agent", - "c61c91e642": "Permita que los agents se coordinen a través de Orca para que las tareas grandes de varios pasos avancen hasta su finalización.", - "ac07f8887f": "Orquestación de Agents", + "1b51644c2d": "Permite que los agentes controlen el escritorio, muevan el cursor, hagan clic y escriban en cualquier app.", + "362a07517d": "Uso de computadora", + "5e8fe5a72d": "Da a los agentes acceso directo al navegador de Orca para que puedan probar páginas, hacer capturas de pantalla y actuar según lo que ven.", + "e638da007a": "Uso del navegador por agentes", + "c61c91e642": "Permite que los agentes se coordinen a través de Orca para mantener en marcha tareas grandes de varios pasos hasta completarlas.", + "ac07f8887f": "Orquestación de agentes", "e9eb197e12": "Permisos de uso de computadora abiertos", - "3a59452a67": "Comando de habilidad copiado e insertado a continuación para su revisión.", - "c605f51f2b": "Configuración de capacidad lista", - "1aa657d8f4": "Alguna configuración de capacidad necesita atención", - "c89534cbe9": "Instalar CLI y habilidades" + "3a59452a67": "Comando de skill copiado e insertado abajo para revisarlo.", + "c605f51f2b": "Configuración de capacidades lista", + "1aa657d8f4": "La configuración de algunas capacidades necesita atención", + "c89534cbe9": "Instalar CLI y skills" }, "AiCommitPrSettingsCard": { - "8d4152701a": "p.ej. ollama corre llama3.1 {{value0}}", + "8d4152701a": "p. ej. ollama run llama3.1 {{value0}}", "9ee54037a4": "Comando personalizado", - "4b2fc4b80c": "esfuerzo de pensamiento", + "4b2fc4b80c": "Esfuerzo de razonamiento", "be8917699e": "Modelo", - "4d9b6d84df": "sin soporte. Elija Claude, Codex o Personalizado.", - "560d4feb00": "Costumbre", + "4d9b6d84df": "no compatible. Elige Claude, Codex o Personalizado.", + "560d4feb00": "Personalizado", "29d119fe95": "Agente", "f9382b48a1": "Habilitar autor de IA", - "1c0cb4fabb": "autor de IA", + "1c0cb4fabb": "Autor de IA", "bd14e9c42a": "No configurado", "1f9468c5c9": "{{value0}} no compatible" }, "BrowserAnimatedVisual": { "46df009982": "Comienza tu prueba gratuita", "25f15c2219": "Pro", - "59ae327405": "Motor de arranque", + "59ae327405": "Starter", "9e0f530390": "Precios", - "0ce7c24b4d": "Laboral…", + "0ce7c24b4d": "Trabajando…", "f2034c4930": ">", "6e4616d039": "Claude", "0f8481e1a7": "Enviar a Claude", "3d2352f94b": "Describe el cambio...", "d8856b604a": "div.pricing-grid > div.card.starter:nth-of-type(1) > a.cta", - "7da6eed7bf": "servidor local: 3000", + "7da6eed7bf": "localhost:3000", "0a2bd01c02": "Nueva pestaña del navegador", "04096318ab": "Terminal 1", "eb88125c6f": "✓ Verificado: la prueba gratuita aún funciona.", - "051c97d15a": ".pp-card[data-card=\"inicio\"] .pp-cta", + "051c97d15a": ".pp-card[data-card=\"starter\"] .pp-cta", "4fa59ca545": "✓ Actualizado", - "1bec24acc1": "@keyframes browserFlash { 0% { opacidad: 0; } 20% { opacidad: 0,85; } 100% { opacidad: 0; } } @keyframes browserTabIn { de { opacidad: 0; transformar: traducirY(-2px); } a {opacidad: 1; transformar: ninguno; } } @keyframes browserViewIn { de { opacidad: 0; transformar: traducirY(4px); } a {opacidad: 1; transformar: ninguno; } }", - "73bbb46073": "/precios", - "f39be6ca14": "/inscribirse" + "1bec24acc1": "@keyframes browserFlash { 0% { opacity: 0; } 20% { opacity: 0.85; } 100% { opacity: 0; } } @keyframes browserTabIn { from { opacity: 0; transform: translateY(-2px); } to { opacity: 1; transform: none; } } @keyframes browserViewIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }", + "73bbb46073": "/pricing", + "f39be6ca14": "/signup" }, "BrowserUseSkillSetupCard": { - "cbc45022d4": "Permite a los agents navegar y verificar páginas en el navegador de Orca.", - "d5bb1cd4ba": "Habilidad de uso del navegador" + "cbc45022d4": "Permite a los agentes navegar y verificar páginas en el navegador de Orca.", + "d5bb1cd4ba": "Skill Browser Use" }, "ComputerUseAnimatedVisual": { "d8401975b1": "aprobado", "f27676a92c": "estado:", - "6804cb356f": "haga clic en enviado", + "6804cb356f": "clic enviado", "1719b28a81": "encontrado \"Aprobar\"", "79445f7512": "aprobar la nota en mi aplicación", "99a8624bcb": ">", "2adb561b44": "Sesión de Claude Code iniciada", - "94787f01f8": "Código Claude", - "9cddfe96b2": "aplicación local", + "94787f01f8": "Claude Code", + "9cddfe96b2": "app local", "9634d870d1": "Aprobar", "3cc2df3671": "Hecho", "bdd5312213": "Pendiente", @@ -10357,47 +10440,47 @@ }, "EditorAnimatedVisual": { "7a763daf2f": "itálico", - "8521536429": "atrevido ·", + "8521536429": "negrita ·", "8341391520": "para bloques ·", "3fe42a1da0": "Tipo", "8268b2376b": "Bloque de código", "37fa4948ce": "Lista de viñetas", "f25687c588": "Cita", "abbdeea15d": "Bloques básicos", - "a26a68d30c": "Título 2", - "722170663a": "Título 1", - "1fb29ad710": "Encabezamientos", + "a26a68d30c": "Encabezado 2", + "722170663a": "Encabezado 1", + "1fb29ad710": "Encabezados", "4426aab46f": "Actualice el índice de documentos una vez que aparezca el nuevo mosaico.", - "95f0c3a46f": "Pruebe con humo el flujo de instalación en una máquina nueva.", + "95f0c3a46f": "Haz un smoke test del flujo de instalación en una máquina nueva.", "22ae7b4d9d": "Una breve nota para el equipo: reunir lo que queda antes del envío.", - "5a55c00a81": "plan de lanzamiento", + "5a55c00a81": "Plan de lanzamiento", "218503f9f3": "guardado automáticamente", - "cda56c5915": "notas / plan de lanzamiento.md", - "e16479c1c5": "[menú-barra-datos] [fila-barra-datos].barra-activa { fondo: rgba(24,24,27,0.07); sombra de cuadro: inserción 0 0 0 1px rgba(24,24,27,0.06); } [data-md-active-line][data-role=\"activo\"] { color: rgb(113 113 122); familia de fuentes: ui-monospace, SFMono-Regular, Menlo, monospace; tamaño de fuente: 12,5 px; } [data-md-active-line][data-role=\"h1\"] { color: heredar; familia de fuentes: heredar; tamaño de fuente: 18px; peso de fuente: 700; espacio entre letras: -0,01 em; altura de línea: 1,2; margen superior: 6px; } [datos-md-caret] { mostrar: bloque en línea; ancho: 1,5 px; altura: 1em; fondo: color actual; alineación vertical: -2px; margen izquierdo: 1px; animación: md-caret-blink 1.05s pasos(1) infinito; } @keyframes md-caret-blink { 0%, 50% { opacidad: 1 } 51%, 100% { opacidad: 0 } } @keyframes md-block-in { de { opacidad: 0; transformar: traducirY(-2px); } a {opacidad: 1; transformar: ninguno; } } @keyframes md-cursor-ripple { 0% { transformar: escala (0.4); opacidad: 0,9; } 100% { transformar: escala(1.4); opacidad: 0; } } [data-clicking=\"1\"] [data-cursor-ripple] { animación: md-cursor-ripple 460ms de avance gradual; }" + "cda56c5915": "notes / launch-plan.md", + "e16479c1c5": "[data-slash-menu] [data-slash-row].slash-active { background: rgba(24,24,27,0.07); box-shadow: inset 0 0 0 1px rgba(24,24,27,0.06); } [data-md-active-line][data-role=\"active\"] { color: rgb(113 113 122); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; } [data-md-active-line][data-role=\"h1\"] { color: inherit; font-family: inherit; font-size: 18px; font-weight: 700; letter-spacing: -0.01em; line-height: 1.2; margin-top: 6px; } [data-md-caret] { display: inline-block; width: 1.5px; height: 1em; background: currentColor; vertical-align: -2px; margin-left: 1px; animation: md-caret-blink 1.05s steps(1) infinite; } @keyframes md-caret-blink { 0%, 50% { opacity: 1 } 51%, 100% { opacity: 0 } } @keyframes md-block-in { from { opacity: 0; transform: translateY(-2px); } to { opacity: 1; transform: none; } } @keyframes md-cursor-ripple { 0% { transform: scale(0.4); opacity: 0.9; } 100% { transform: scale(1.4); opacity: 0; } } [data-clicking=\"1\"] [data-cursor-ripple] { animation: md-cursor-ripple 460ms ease-out forwards; }" }, "FeatureTourPreview": { "1170621527": ">", - "304ad0dfc1": "Pensamiento...", - "ef8b164dd1": "revisar src/autenticación", + "304ad0dfc1": "Pensando...", + "ef8b164dd1": "revisar src/auth", "952d3ddd9a": "sesión iniciada", "771d8881c2": "claude", - "6ed43cb0e0": "tablero.spec.ts", - "24fedd5a52": "iniciar sesión.spec.ts", + "6ed43cb0e0": "dashboard.spec.ts", + "24fedd5a52": "login.spec.ts", "8279e9d95b": "Ejecutando 12 pruebas", - "6218a9014d": "prueba de dramaturgo pnpm", + "6218a9014d": "pnpm playwright test", "04d54d50ec": "orca · zsh", - "1aa8a9a24a": "Terminal divisible", + "1aa8a9a24a": "Terminal dividible", "2a7cfc82c8": "Vinculado a GH #1842", - "3822d8d14b": "fix/worktree-picker-trunca", + "3822d8d14b": "fix/worktree-picker-truncates", "d54aefe09e": "LIN-329", "40bbd92ef4": "Comenzar", - "c1f28c03b2": "El selector de árbol de trabajo se trunca", - "fc0cc0b267": "#1842", + "c1f28c03b2": "El selector de worktree se trunca", + "fc0cc0b267": "GH #1842", "0688842445": "GH #1799", - "bee6b4088d": "GitHub y tareas Lineares", - "5171768676": "orquestando 3 agents", + "bee6b4088d": "Tareas de GitHub y Linear", + "5171768676": "orquestando 3 agentes", "cebc7769cd": "rediseñar el flujo de autenticación", - "e44269e97d": "Orquestación de Agents", + "e44269e97d": "Orquestación de agentes", "ec4a73f5e6": "PR 3/3", "cfdfd4d6b4": "PR 2/3", "b1f17bcc74": "PR 1/3", @@ -10405,12 +10488,12 @@ "9c812e0d7c": "acelerar el proceso de CI", "3c4adfd821": "arreglar la condición de carrera de inicio de sesión", "56a0271428": "Espacios de trabajo aislados", - "ef737dcee1": "GitHub y tareas Lineares", + "ef737dcee1": "Tareas de GitHub y Linear", "ac51c061e2": "codex", - "47f16ecf34": "Ship several things at once. Each workspace keeps its branch, terminal, and agent activity together.", - "70aa182266": "Hand off a goal and walk away. A coordinator agent fans out and ships parallel PRs.", - "f10c14dd9d": "Skip the tab-switching. Pick from your GitHub or Linear backlog and start a workspace in one click.", - "5d6ee181b6": "Open any workspace to return to its terminal, then split panes for tests, logs, and agents." + "47f16ecf34": "Avanza en varias cosas a la vez. Cada workspace mantiene juntos su rama, terminal y actividad de agentes.", + "70aa182266": "Delega un objetivo y sigue con lo tuyo. Un agente coordinador se despliega y publica PRs en paralelo.", + "f10c14dd9d": "Evita cambiar de pestaña. Elige desde tu backlog de GitHub o Linear e inicia un workspace con un clic.", + "5d6ee181b6": "Abre cualquier workspace para volver a su terminal y luego divide paneles para tests, logs y agentes." }, "FeatureWallBody": { "25ec5356d6": "Configuración" @@ -10434,11 +10517,11 @@ "13294d3405": "Hecho" }, "FeatureWallSetupWorkflowActions": { - "486c2f4d8d": "Primero agregue un proyecto git, luego configure el script de configuración para ese repositorio.", + "486c2f4d8d": "Agrega primero un proyecto git y luego configura el script de configuración para ese repositorio.", "00078a6134": "Ver en configuración", - "14327073cc": "Ahorrar", - "88469e926b": "Guión de configuración", - "5c5b65044e": "instalación pnpm", + "14327073cc": "Guardar", + "88469e926b": "Script de configuración", + "5c5b65044e": "pnpm install", "a7463915b6": "No se pudo guardar el script de configuración", "6299297dac": "Script de configuración guardado", "f0bbf7da77": "Pruébalo", @@ -10452,48 +10535,48 @@ }, "ReviewNotesAnimatedVisual": { "5dbd27c4c2": "Codex", - "09094f25e2": "Código Claude", - "294aaff104": "enviar notas a", + "09094f25e2": "Claude Code", + "294aaff104": "Enviar notas a", "ea4e45b71b": "Agregar nota", "271ea0cbf3": "Cancelar", "a7a89d8f94": "Línea", - "5cb213f967": "notas de IA", - "1eee3a397e": "src/server/migrate.ts (diferencia)" + "5cb213f967": "Notas de IA", + "1eee3a397e": "src/server/migrate.ts (diff)" }, "ReviewPRViewAnimatedVisual": { - "7c2808ecff": "truncamiento antes de la fusión.", + "7c2808ecff": "truncamiento antes del merge.", "c2062da7ec": "stderr", "6f4c2d7cb7": "Agregar un caso de cobertura para", "71828fba75": "¿Podemos incluir el comando defectuoso en la carga útil de diagnóstico?", "fb1a856b6d": "abierto", "7a8b896e11": "Comentarios", "ca36f7b27c": "Aprobado", - "25f6838e43": "hilas", - "2ef0b97954": "verificación de tipo", - "8ed213397c": "Correr", - "d340c052fb": "verificar", + "25f6838e43": "lint", + "2ef0b97954": "typecheck", + "8ed213397c": "En ejecución", + "d340c052fb": "verify", "9a097cae12": "1 pendiente", - "2f37142229": "Aplastar y fusionar", + "2f37142229": "Squash y merge", "0aab7ab84a": "Agregar seguimiento de errores de diagnóstico local", "dfe313e0c9": "ABIERTO", "6e3f5223c5": "Explorador", - "ab2901bce6": "cheques", + "ab2901bce6": "Checks", "d7f80060ca": "Control de fuente", "8e715588e4": "Buscar", - "a6c8b9e32f": "Checks passed", + "a6c8b9e32f": "Checks aprobados", "f4d5e1a7b2": "3 checks" }, "ReviewShipAnimatedVisual": { "4d99496b8c": "Crear PR", "62544e0852": "Cancelar", - "bcd5cae3c4": "Descripción de la solicitud de extracción", + "bcd5cae3c4": "Descripción del pull request", "3774b80eae": "Descripción", - "07da9245cc": "Título de la solicitud de extracción", + "07da9245cc": "Título del pull request", "54a093c52d": "Título", - "3b9b96d6a6": "principal", - "ce7d5d3a18": "sucursal base", + "3b9b96d6a6": "main", + "ce7d5d3a18": "Rama base", "e4473d438f": "Generar con IA", - "c30cd930ff": "Crear solicitud de extracción", + "c30cd930ff": "Crear pull request", "ea0100dd15": "Ver todo", "e725000cd7": "Cambios", "a079083a6c": "Commit", @@ -10502,35 +10585,35 @@ "cd8a3a39d7": "3 commits por delante" }, "TasksAnimatedVisual": { - "efba6f77eb": "Número de lectura #", - "b68c92fbdc": "Iniciar espacio de trabajo", + "efba6f77eb": "Leyendo issue #", + "b68c92fbdc": "Iniciar workspace", "4331c4d0f8": "Abierto", - "b13375617e": "El selector de árbol de trabajo trunca los nombres", - "72f9e516a3": "prensado", - "fe47c9c9e8": "Espacio de trabajo listo", - "61ffda7601": "Creando espacio de trabajo" + "b13375617e": "El selector de worktree trunca los nombres", + "72f9e516a3": "pulsando", + "fe47c9c9e8": "Workspace listo", + "61ffda7601": "Creando workspace" }, "WorkbenchAnimatedVisual": { - "633a91e358": "Pensamiento…", + "633a91e358": "Pensando…", "932c4b3a97": ">", - "ca2cfbf188": "Terminal dividido hacia abajo", - "e370fa8c2b": "Terminal dividido a la derecha", + "ca2cfbf188": "Dividir terminal abajo", + "e370fa8c2b": "Dividir terminal a la derecha", "b85eab49dd": "src/auth/session.ts", "99f5224f1e": "Editar", - "0d93c298a7": "lanzar src/auth", - "17cfdc3344": "grep", - "9923847785": "Leer", + "0d93c298a7": "throw src/auth", + "17cfdc3344": "Grep", + "9923847785": "Read", "c0eb94125e": "revisar casos extremos de autenticación", "431ca9842a": "Sesión de Claude Code iniciada", "000106adfe": "claude", - "7d9f1d5f7d": "(0,8s)", - "944199e54a": "› actualizaciones totales del carrito", - "623881d72e": "pago.spec.ts", - "5c5cbd783f": "(1,2s)", - "3261c6853b": "› puede iniciar sesión", - "defe550fe2": "iniciar sesión.spec.ts", + "7d9f1d5f7d": "(0.8s)", + "944199e54a": "› cart total updates", + "623881d72e": "checkout.spec.ts", + "5c5cbd783f": "(1.2s)", + "3261c6853b": "› can sign in", + "defe550fe2": "login.spec.ts", "0b20782e0f": "Ejecutando 12 pruebas con 4 trabajadores", - "4371cc9931": "prueba de dramaturgo pnpm", + "4371cc9931": "pnpm playwright test", "16877e038d": "se divide", "a2b114dad0": "se divide a la derecha ·", "0bc9ad0cd1": "Mismo panel:", @@ -10541,12 +10624,12 @@ "setup": { "status": { "8eccfcb314": "Instalado", - "21d4f79c93": "haga clic en Instalar CLI y habilidades para abrir la configuración de acceso de macOS", - "5c9293e51a": "comprobando el acceso a la aplicación", - "aae94eeb52": "Haga clic en Instalar CLI y habilidades", - "aa8e143a2f": "No se pudo verificar la instalación", + "21d4f79c93": "haz clic en Instalar CLI y skills para abrir la configuración de acceso de macOS", + "5c9293e51a": "comprobando acceso a la app", + "aae94eeb52": "Haz clic en Instalar CLI y skills", + "aa8e143a2f": "No se pudo comprobar la instalación", "9b33e7fb13": "Comprobando instalación", - "4c8e1f92a7": "abre Orca Desktop en esta Mac", + "4c8e1f92a7": "abre Orca Desktop en este Mac", "6d2b0a84e1": "No disponible en esta compilación" } } @@ -10572,19 +10655,19 @@ }, "notes": { "styles": { - "db6691aa0a": ".ravs-window { posición: absoluta; recuadro: 0; --ravs-soft-surface: color-mix(en srgb, var(--primer plano) 2%, var(--card)); --ravs-soft-fill: mezcla de colores (en srgb, var(--primer plano) 6%, transparente); --ravs-panel-border: mezcla de colores (en srgb, var(--primer plano) 18%, var(--border)); --ravs-emphasis-border: color-mix(en srgb, var(--primer plano) 44%, var(--border)); --ravs-sombra-flotante: 0 14px 30px rgb(0 0 0 / 0.22), 0 2px 6px rgb(0 0 0 / 0.12); fondo: var(--tarjeta); borde: 1px var sólido (--borde); radio del borde: 10px; desbordamiento: oculto; pantalla: flexible; dirección flexible: columna; sombra de cuadro: 0 1px 2px rgb(0 0 0 / 0.08); } .ravs-difftoolbar { mostrar: flex; alinear elementos: centro; espacio: 8px; relleno: 6px 10px; borde inferior: var sólido de 1 px (--borde); fondo: var(--ravs-soft-surface); tamaño de fuente: 11px; color: var(--primer plano silenciado); } .ravs-diff-path { ancho mínimo: 0; desbordamiento: oculto; desbordamiento de texto: puntos suspensivos; espacio en blanco: nowrap; familia de fuentes: ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--primer plano); } .ravs-ai-chip { margen izquierdo: automático; pantalla: flexible en línea; alinear elementos: estirar; desbordamiento: oculto; radio del borde: 6px; borde: 1px var sólido (--borde); fondo: var(--ravs-soft-surface); opacidad: 0; transformar: traducirY(-2px); transición: opacidad 320 ms facilidad, transformación 320 ms facilidad; } .ravs-ai-chip.is-visible { opacidad: 1; transformar: ninguno; } .ravs-ai-chip .ravs-count-btn, .ravs-ai-chip .ravs-send-btn { pantalla: inline-flex; alinear elementos: centro; espacio: 5px; relleno: 3px 8px; tamaño de fuente: 11px; color: var(--primer plano silenciado); fondo: transparente; altura de línea: 1; } .ravs-ai-chip .ravs-count-btn { borde-derecha: 1px solid var(--border); } .ravs-ai-chip .ravs-send-btn { relleno: 3px 7px; posición: relativa; } .ravs-send-glow { posición: absoluta; recuadro: 0; fondo: rgba(34, 197, 94, 0,18); opacidad: 0; transición: opacidad 280 ms facilidad; eventos de puntero: ninguno; } .ravs-ai-chip .ravs-send-btn.is-flash .ravs-send-glow { opacidad: 1; } .ravs-count-num { familia de fuentes: ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--primer plano); peso de fuente: 600; } .ravs-diffbody { flexionar: 1; altura mínima: 0; posición: relativa; fondo: var(--editor-surface, var(--card)); } .ravs-diffscroll { posición: absoluta; recuadro: 0; desbordamiento: oculto; familia de fuentes: ui-monospace, SFMono-Regular, Menlo, monospace; tamaño de fuente: 11,5 px; altura de línea: 1,55; color: var(--primer plano); relleno: 4px 0 8px; transición: opacidad 240 ms facilidad; } .ravs-diffscroll.is-hidden { opacidad: 0; eventos de puntero: ninguno; } .ravs-term { posición: absoluta; recuadro: 0; fondo: var(--editor-surface, var(--card)); color: var(--primer plano); familia de fuentes: ui-monospace, SFMono-Regular, Menlo, monospace; tamaño de fuente: 11px; altura de línea: 1,45; desbordamiento: oculto; pantalla: flexible; dirección flexible: columna; opacidad: 0; eventos de puntero: ninguno; transición: opacidad 240 ms facilidad; índice z: 4; } .ravs-term.is-visible { opacidad: 1; } .ravs-term-body { flex: 1; altura mínima: 0; relleno: 10px 12px; desbordamiento: oculto; pantalla: flexible; dirección flexible: columna; espacio: 6px; } .ravs-term-line { espacio en blanco: pre-envoltura; salto de palabra: salto de palabra; altura de línea: 1,45; } .ravs-term-muted { color: var(--silenciado-primer plano); } .ravs-term-glifo { color: rgb(217 119 6); margen derecho: 6px; } .ravs-term-check { color: rgb(16 185 129); peso de fuente: 700; margen derecho: 6px; } .ravs-term-spinner { mostrar: bloque en línea; ancho: 8px; altura: 8px; margen derecho: 6px; radio del borde: 999px; borde: mezcla de colores sólidos de 1,5 px (en srgb, var (--primer plano) 20%, transparente); color-superior-borde: var(--primer plano); alineación vertical: -1px; animación: ravs-term-spin 0,9 s lineal infinito; } @keyframes ravs-term-spin { a { transformar: rotar (360 grados) } } .ravs-hunk-header { display: grid; columnas-plantilla-cuadrícula: 36px 36px 16px minmax(0,1fr); alinear elementos: centro; relleno: 1px 8px 1px 0; fondo: rgba(99, 102, 241, 0,06); color: var(--primer plano silenciado); tamaño de fuente: 10,5 px; borde superior: var sólido de 1 px (--borde); borde inferior: var sólido de 1 px (--borde); } .ravs-hunk-header .ravs-text { columna-cuadrícula: 4 / -1; espacio en blanco: nowrap; desbordamiento: oculto; desbordamiento de texto: puntos suspensivos; color: rgb(99102241); tamaño de fuente: 10,5 px; } .ravs-diff-line { mostrar: cuadrícula; columnas-plantilla-cuadrícula: 36px 36px 16px minmax(0,1fr); alinear elementos: estirar; posición: relativa; } .ravs-ln { text-align: derecha; relleno: 0 6px 0 0; color: var(--primer plano silenciado); tamaño de fuente: 10,5 px; selección de usuario: ninguna; opacidad: 0,85; } .ravs-marker { text-align: centro; color: var(--primer plano silenciado); peso de fuente: 700; opacidad: 0,7; } .ravs-text-cell { padding-right: 8px; espacio en blanco: pre; desbordamiento: oculto; } .ravs-tok-kw { color: #a855f7; } .ravs-tok-id { color: #2563eb; } .ravs-tok-str { color: #16a34a; } .ravs-diff-line.is-add { fondo: color-mix(en srgb, var(--git-decoration-added) 14%, transparente); } .ravs-diff-line.is-add .ravs-marker { color: color-mix(en srgb, var(--git-decoration-added) 72%, transparente); opacidad: 1; } .ravs-diff-line.is-rem { fondo: color-mix(en srgb, var(--git-decoration-deleted) 14%, transparente); } .ravs-diff-line.is-rem .ravs-marker { color: color-mix(en srgb, var(--git-decoration-deleted) 72%, transparente); opacidad: 1; } .ravs-add-note-btn { posición: absoluta; izquierda: 4px; ancho: 18px; altura: 18px; pantalla: flexible en línea; alinear elementos: centro; justificar-contenido: centro; relleno: 0; borde: mezcla de colores sólidos de 1 px (en srgb, color actual 22%, var (--borde)); radio del borde: 4px; fondo: var(--ravs-soft-fill); color: var(--primer plano); índice z: 5; opacidad: 0; sombra de cuadro: 0 1px 2px rgb(0 0 0 / 0.14); eventos de puntero: ninguno; transición: opacidad 160 ms facilidad; } .ravs-add-note-btn.is-visible { opacidad: 1; } .ravs-note-row { relleno: 4px 8px 4px 0; altura máxima: 0; desbordamiento: oculto; opacidad: 0; transición: altura máxima 360 ms cúbico-bezier (.4,0,.2,1), opacidad 280 ms facilidad 60 ms, relleno 360 ms cúbico-bezier (.4,0,.2,1); } .ravs-note-row.is-visible { altura máxima: 90px; opacidad: 1; } .ravs-note-card { margen: 0 12px; posición: relativa; borde: 1px solid var(--ravs-panel-border); borde izquierdo: 3px solid var(--ravs-emphasis-border); radio del borde: 6px; color de fondo: var(--card); relleno: 5px 8px 5px 10px; sombra de cuadro: 0 1px 2px rgb(0 0 0 / 0.16); } .ravs-note-meta { tamaño de fuente: 9.5px; peso de fuente: 600; transformación de texto: mayúsculas; espacio entre letras: 0,04 em; color: var(--primer plano silenciado); } .ravs-note-body { tamaño de fuente: 11,5 px; color: var(--primer plano); altura de línea: 1,35; margen superior: 2px; } .ravs-popover { posición: absoluta; izquierda: 12px; derecha: 12px; ancho máximo: ninguno; índice z: 20; relleno: 8px 10px; borde: 1px solid var(--ravs-panel-border); borde izquierdo: 3px solid var(--ravs-emphasis-border); radio del borde: 6px; color de fondo: var(--card); color: var(--primer plano); sombra-cuadro: var(--ravs-sombra-flotante); pantalla: flexible; dirección flexible: columna; espacio: 6px; opacidad: 0; transformar: traducirY(-4px) escala(0.985); eventos de puntero: ninguno; transición: opacidad 180 ms facilidad, transformación 180 ms facilidad; } .ravs-popover.is-visible { opacidad: 1; transformar: ninguno; eventos de puntero: automático; } .ravs-pop-label { tamaño de fuente: 10px; peso de fuente: 600; transformación de texto: mayúsculas; espacio entre letras: 0,04 em; color: var(--primer plano silenciado); } .ravs-pop-input { altura mínima: 38px; altura máxima: 80 px; relleno: 6px 8px; borde: 1px var sólido (--borde); radio del borde: 4px; fondo: var(--editor-surface, var(--card)); tamaño de fuente: 12px; altura de línea: 1,4; color: var(--primer plano); espacio en blanco: preenvoltura; salto de palabra: salto de palabra; desbordamiento: oculto; } .ravs-pop-footer { pantalla: flex; justificar contenido: extremo flexible; espacio: 6px; } .ravs-pop-btn { tamaño de fuente: 11px; peso de fuente: 500; relleno: 4px 9px; radio del borde: 5px; altura de línea: 1; borde: 1px sólido transparente; pantalla: flexible en línea; alinear elementos: centro; espacio: 5px; } .ravs-pop-btn.is-cancel { color: var(--silenciado-primer plano); fondo: transparente; } .ravs-pop-btn.is-add { color: var(--primer-primer plano); fondo: var(--primario); } .ravs-send-menu { posición: absoluta; índice z: 30; derecha: 8px; arriba: 6px; ancho mínimo: 200px; fondo: var(--popover); color: var(--popover-primer plano); borde: 1px var sólido (--borde); radio del borde: 8px; relleno: 4px; sombra-cuadro: var(--ravs-sombra-flotante); opacidad: 0; transformar: traducirY(-4px) escala(0.985); eventos de puntero: ninguno; transición: opacidad 180 ms facilidad, transformación 180 ms facilidad; } .ravs-send-menu.is-visible { opacidad: 1; transformar: ninguno; eventos de puntero: automático; } .ravs-menu-section { relleno: 4px 8px 2px; tamaño de fuente: 9,5 px; peso de fuente: 700; transformación de texto: mayúsculas; espacio entre letras: 0,06 em; color: var(--primer plano silenciado); } .ravs-menu-row { mostrar: cuadrícula; columnas-plantilla-cuadrícula: 16px minmax(0,1fr); alinear elementos: centro; espacio: 8px; relleno: 6px 8px; radio del borde: 5px; tamaño de fuente: 12px; color: var(--popover-primer plano); } .ravs-menu-row.is-hot { fondo: var(--accent); sombra de cuadro: inserción 0 0 0 1px var (--borde); } .ravs-cursor { posición: absoluta; índice z: 40; eventos de puntero: ninguno; transición: transformar 600 ms cúbico-bezier (.45, .05, .2,1), opacidad 200 ms facilidad; transformar: traducir (-30px, 220px); opacidad: 0; } .ravs-cursor.is-visible { opacidad: 1; } .ravs-cursor .ravs-ripple { posición: absoluta; izquierda: -6px; arriba: -6px; ancho: 28px; altura: 28 píxeles; radio del borde: 999px; borde: mezcla de colores sólidos de 2px (en srgb, var(--primer plano) 52%, transparente); opacidad: 0; } .ravs-cursor.is-clicking .ravs-ripple { animación: ravs-ripple 460 ms hacia adelante; } @keyframes ravs-ripple { 0% { transformar: escala(0.4); opacidad: 0,9; } 100% { transformar: escala(1.4); opacidad: 0; } } .ravs-caret { mostrar: bloque en línea; ancho: 1,5 px; altura: 1em; fondo: color actual; alineación vertical: -2px; margen izquierdo: 1px; animación: ravs-caret-blink 1,05s pasos(1) infinito; } @keyframes ravs-caret-blink { 0%, 50% { opacidad: 1 } 51%, 100% { opacidad: 0 } }" + "db6691aa0a": ".ravs-window { position: absolute; inset: 0; --ravs-soft-surface: color-mix(in srgb, var(--foreground) 2%, var(--card)); --ravs-soft-fill: color-mix(in srgb, var(--foreground) 6%, transparent); --ravs-panel-border: color-mix(in srgb, var(--foreground) 18%, var(--border)); --ravs-emphasis-border: color-mix(in srgb, var(--foreground) 44%, var(--border)); --ravs-floating-shadow: 0 14px 30px rgb(0 0 0 / 0.22), 0 2px 6px rgb(0 0 0 / 0.12); background: var(--card); border: 1px solid var(--border); border-radius: 10px; overflow: hidden; display: flex; flex-direction: column; box-shadow: 0 1px 2px rgb(0 0 0 / 0.08); } .ravs-difftoolbar { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--border); background: var(--ravs-soft-surface); font-size: 11px; color: var(--muted-foreground); } .ravs-diff-path { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--foreground); } .ravs-ai-chip { margin-left: auto; display: inline-flex; align-items: stretch; overflow: hidden; border-radius: 6px; border: 1px solid var(--border); background: var(--ravs-soft-surface); opacity: 0; transform: translateY(-2px); transition: opacity 320ms ease, transform 320ms ease; } .ravs-ai-chip.is-visible { opacity: 1; transform: none; } .ravs-ai-chip .ravs-count-btn, .ravs-ai-chip .ravs-send-btn { display: inline-flex; align-items: center; gap: 5px; padding: 3px 8px; font-size: 11px; color: var(--muted-foreground); background: transparent; line-height: 1; } .ravs-ai-chip .ravs-count-btn { border-right: 1px solid var(--border); } .ravs-ai-chip .ravs-send-btn { padding: 3px 7px; position: relative; } .ravs-send-glow { position: absolute; inset: 0; background: rgba(34, 197, 94, 0.18); opacity: 0; transition: opacity 280ms ease; pointer-events: none; } .ravs-ai-chip .ravs-send-btn.is-flash .ravs-send-glow { opacity: 1; } .ravs-count-num { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--foreground); font-weight: 600; } .ravs-diffbody { flex: 1; min-height: 0; position: relative; background: var(--editor-surface, var(--card)); } .ravs-diffscroll { position: absolute; inset: 0; overflow: hidden; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11.5px; line-height: 1.55; color: var(--foreground); padding: 4px 0 8px; transition: opacity 240ms ease; } .ravs-diffscroll.is-hidden { opacity: 0; pointer-events: none; } .ravs-term { position: absolute; inset: 0; background: var(--editor-surface, var(--card)); color: var(--foreground); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; line-height: 1.45; overflow: hidden; display: flex; flex-direction: column; opacity: 0; pointer-events: none; transition: opacity 240ms ease; z-index: 4; } .ravs-term.is-visible { opacity: 1; } .ravs-term-body { flex: 1; min-height: 0; padding: 10px 12px; overflow: hidden; display: flex; flex-direction: column; gap: 6px; } .ravs-term-line { white-space: pre-wrap; word-break: break-word; line-height: 1.45; } .ravs-term-muted { color: var(--muted-foreground); } .ravs-term-glyph { color: rgb(217 119 6); margin-right: 6px; } .ravs-term-check { color: rgb(16 185 129); font-weight: 700; margin-right: 6px; } .ravs-term-spinner { display: inline-block; width: 8px; height: 8px; margin-right: 6px; border-radius: 999px; border: 1.5px solid color-mix(in srgb, var(--foreground) 20%, transparent); border-top-color: var(--foreground); vertical-align: -1px; animation: ravs-term-spin 0.9s linear infinite; } @keyframes ravs-term-spin { to { transform: rotate(360deg) } } .ravs-hunk-header { display: grid; grid-template-columns: 36px 36px 16px minmax(0,1fr); align-items: center; padding: 1px 8px 1px 0; background: rgba(99, 102, 241, 0.06); color: var(--muted-foreground); font-size: 10.5px; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); } .ravs-hunk-header .ravs-text { grid-column: 4 / -1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: rgb(99 102 241); font-size: 10.5px; } .ravs-diff-line { display: grid; grid-template-columns: 36px 36px 16px minmax(0,1fr); align-items: stretch; position: relative; } .ravs-ln { text-align: right; padding: 0 6px 0 0; color: var(--muted-foreground); font-size: 10.5px; user-select: none; opacity: 0.85; } .ravs-marker { text-align: center; color: var(--muted-foreground); font-weight: 700; opacity: 0.7; } .ravs-text-cell { padding-right: 8px; white-space: pre; overflow: hidden; } .ravs-tok-kw { color: #a855f7; } .ravs-tok-id { color: #2563eb; } .ravs-tok-str { color: #16a34a; } .ravs-diff-line.is-add { background: color-mix(in srgb, var(--git-decoration-added) 14%, transparent); } .ravs-diff-line.is-add .ravs-marker { color: color-mix(in srgb, var(--git-decoration-added) 72%, transparent); opacity: 1; } .ravs-diff-line.is-rem { background: color-mix(in srgb, var(--git-decoration-deleted) 14%, transparent); } .ravs-diff-line.is-rem .ravs-marker { color: color-mix(in srgb, var(--git-decoration-deleted) 72%, transparent); opacity: 1; } .ravs-add-note-btn { position: absolute; left: 4px; width: 18px; height: 18px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 1px solid color-mix(in srgb, currentColor 22%, var(--border)); border-radius: 4px; background: var(--ravs-soft-fill); color: var(--foreground); z-index: 5; opacity: 0; box-shadow: 0 1px 2px rgb(0 0 0 / 0.14); pointer-events: none; transition: opacity 160ms ease; } .ravs-add-note-btn.is-visible { opacity: 1; } .ravs-note-row { padding: 4px 8px 4px 0; max-height: 0; overflow: hidden; opacity: 0; transition: max-height 360ms cubic-bezier(.4,0,.2,1), opacity 280ms ease 60ms, padding 360ms cubic-bezier(.4,0,.2,1); } .ravs-note-row.is-visible { max-height: 90px; opacity: 1; } .ravs-note-card { margin: 0 12px; position: relative; border: 1px solid var(--ravs-panel-border); border-left: 3px solid var(--ravs-emphasis-border); border-radius: 6px; background-color: var(--card); padding: 5px 8px 5px 10px; box-shadow: 0 1px 2px rgb(0 0 0 / 0.16); } .ravs-note-meta { font-size: 9.5px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted-foreground); } .ravs-note-body { font-size: 11.5px; color: var(--foreground); line-height: 1.35; margin-top: 2px; } .ravs-popover { position: absolute; left: 12px; right: 12px; max-width: none; z-index: 20; padding: 8px 10px; border: 1px solid var(--ravs-panel-border); border-left: 3px solid var(--ravs-emphasis-border); border-radius: 6px; background-color: var(--card); color: var(--foreground); box-shadow: var(--ravs-floating-shadow); display: flex; flex-direction: column; gap: 6px; opacity: 0; transform: translateY(-4px) scale(0.985); pointer-events: none; transition: opacity 180ms ease, transform 180ms ease; } .ravs-popover.is-visible { opacity: 1; transform: none; pointer-events: auto; } .ravs-pop-label { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted-foreground); } .ravs-pop-input { min-height: 38px; max-height: 80px; padding: 6px 8px; border: 1px solid var(--border); border-radius: 4px; background: var(--editor-surface, var(--card)); font-size: 12px; line-height: 1.4; color: var(--foreground); white-space: pre-wrap; word-break: break-word; overflow: hidden; } .ravs-pop-footer { display: flex; justify-content: flex-end; gap: 6px; } .ravs-pop-btn { font-size: 11px; font-weight: 500; padding: 4px 9px; border-radius: 5px; line-height: 1; border: 1px solid transparent; display: inline-flex; align-items: center; gap: 5px; } .ravs-pop-btn.is-cancel { color: var(--muted-foreground); background: transparent; } .ravs-pop-btn.is-add { color: var(--primary-foreground); background: var(--primary); } .ravs-send-menu { position: absolute; z-index: 30; right: 8px; top: 6px; min-width: 200px; background: var(--popover); color: var(--popover-foreground); border: 1px solid var(--border); border-radius: 8px; padding: 4px; box-shadow: var(--ravs-floating-shadow); opacity: 0; transform: translateY(-4px) scale(0.985); pointer-events: none; transition: opacity 180ms ease, transform 180ms ease; } .ravs-send-menu.is-visible { opacity: 1; transform: none; pointer-events: auto; } .ravs-menu-section { padding: 4px 8px 2px; font-size: 9.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted-foreground); } .ravs-menu-row { display: grid; grid-template-columns: 16px minmax(0,1fr); align-items: center; gap: 8px; padding: 6px 8px; border-radius: 5px; font-size: 12px; color: var(--popover-foreground); } .ravs-menu-row.is-hot { background: var(--accent); box-shadow: inset 0 0 0 1px var(--border); } .ravs-cursor { position: absolute; z-index: 40; pointer-events: none; transition: transform 600ms cubic-bezier(.45,.05,.2,1), opacity 200ms ease; transform: translate(-30px, 220px); opacity: 0; } .ravs-cursor.is-visible { opacity: 1; } .ravs-cursor .ravs-ripple { position: absolute; left: -6px; top: -6px; width: 28px; height: 28px; border-radius: 999px; border: 2px solid color-mix(in srgb, var(--foreground) 52%, transparent); opacity: 0; } .ravs-cursor.is-clicking .ravs-ripple { animation: ravs-ripple 460ms ease-out forwards; } @keyframes ravs-ripple { 0% { transform: scale(0.4); opacity: 0.9; } 100% { transform: scale(1.4); opacity: 0; } } .ravs-caret { display: inline-block; width: 1.5px; height: 1em; background: currentColor; vertical-align: -2px; margin-left: 1px; animation: ravs-caret-blink 1.05s steps(1) infinite; } @keyframes ravs-caret-blink { 0%, 50% { opacity: 1 } 51%, 100% { opacity: 0 } }" } }, "pr": { "view": { "styles": { - "fc9a23c83d": ".ravpr-stage { posición: absoluta; recuadro: 0; desbordamiento: oculto; } .ravpr-stack { posición: absoluta; recuadro: 0; pantalla: flexible; justificar contenido: extremo flexible; relleno: 4px 34px 4px 2px; desbordamiento: oculto; } .ravpr-sidebar, .ravpr-card { posición: absoluta; arriba: 4px; derecha: 2px; ancho: 464px; altura: calc(100% - 8px); fondo: var(--card, #fff); borde: 1px var sólido (--borde); radio del borde: 10px; color: var(--primer plano, #18181b); desbordamiento: oculto; sombra de cuadro: 0 1px 2px rgba(24,24,27,0.04); } .ravpr-sidebar { opacidad: 0; transición: opacidad 220 ms facilidad; } .ravpr-sidebar.is-visible { opacidad: 1; } .ravpr-sidebar.is-hiding { opacidad: 0; } .ravpr-card { pantalla: flex; dirección flexible: columna; ancho mínimo: 0; opacidad: 0; transición: opacidad 260 ms facilidad; } .ravpr-card.is-visible { opacidad: 1; } .ravpr-tabs { posición: relativa; pantalla: flexible; alinear elementos: centro; espacio: 14px; altura: 36px; relleno: 0 14px; fondo: rgba(24,24,27,0.015); color: var(--primer plano silenciado, #71717a); } .ravpr-tab { posición: relativa; ancho: 18px; altura: 18px; pantalla: flexible en línea; alinear elementos: centro; justificar-contenido: centro; color: var(--primer plano silenciado, #71717a); } .ravpr-tab.is-active, .ravpr-tab.is-hovered { color: var(--primer plano, #18181b); } .ravpr-tab.is-active::después { contenido: ''; posición: absoluta; izquierda: -5px; derecha: -5px; abajo: -10px; altura: 1px; fondo: var(--primer plano, #18181b); } .ravpr-tooltip { posición: absoluta; arriba: 34px; izquierda: 106px; índice z: 6; relleno: 7px 11px; radio del borde: 8px; fondo: var(--card, #fff); color: var(--primer plano, #18181b); tamaño de fuente: 12px; altura de línea: 1; sombra de cuadro: 0 8px 22px rgba(0,0,0,0.22); opacidad: 0; transformar: traducirY(-3px); eventos de puntero: ninguno; transición: opacidad 160 ms facilidad, transformación 160 ms facilidad; } .ravpr-tooltip.is-visible { opacidad: 1; transformar: traducirY(0); } .ravpr-explorer { relleno: 10px 12px 12px; } .ravpr-heading { color: var(--primer plano silenciado, #71717a); tamaño de fuente: 10px; peso de fuente: 600; espacio entre letras: 0,05 em; transformación de texto: mayúsculas; } .ravpr-file-list { margen superior: 8px; pantalla: flexible; dirección flexible: columna; espacio: 2px; } .ravpr-file { pantalla: cuadrícula; columnas-plantilla-cuadrícula: 14px minmax(0,1fr) 22px; alinear elementos: centro; espacio: 8px; altura mínima: 28px; relleno: 4px 6px; radio del borde: 6px; } .ravpr-file.is-active { fondo: rgba(24,24,27,0.06); sombra de cuadro: inserción 0 0 0 1px rgba(24,24,27,0.06); } .ravpr-file-icon { ancho: 12px; altura: 12px; radio del borde: 3px; fondo: rgba(24,24,27,0.14); } .ravpr-nombre-archivo { altura: 8px; radio del borde: 999px; fondo: rgba(24,24,27,0.14); } .ravpr-file-status { ancho: 14px; altura: 8px; radio del borde: 999px; fondo: rgba(24,24,27,0.12); } .ravpr-body { relleno: 10px 12px 18px; pantalla: flexible; dirección flexible: columna; espacio: 5px; altura mínima: 0; } .ravpr-número-fila { mostrar: flex; alinear elementos: centro; espacio: 7px; } .ravpr-number { familia de fuentes: ui-monospace, SFMono-Regular, Menlo, monospace; tamaño de fuente: 12px; peso de fuente: 700; } .ravpr-open { pantalla: inline-flex; alinear elementos: centro; justificar-contenido: centro; altura: 18px; relleno: 0 7px; radio del borde: 5px; fondo: rgba(16.185.129,0,10); borde: 1px rgba sólido (16,185,129,0.28); color: rgb(4 120 87); tamaño de fuente: 9px; peso de fuente: 700; altura de línea: 1; } .ravpr-title { tamaño de fuente: 12px; peso de fuente: 600; altura de línea: 1,35; color: var(--primer plano, #18181b); margen inferior: 2px; } .ravpr-merge { mostrar: inline-flex; alinear elementos: centro; justificar-contenido: centro; espacio: 6px; altura: 30 píxeles; altura mínima: 30px; flexionar: 0 0 30px; radio del borde: 7px; fondo: rgb(22 163 74); color: #fff; tamaño de fuente: 11,5 px; peso de fuente: 700; margen inferior: 2px; sombra de cuadro: 0 1px 2px rgba(22,163,74,0.18); transición: caja-sombra 220 ms de facilidad, filtro 220 ms de facilidad; } .ravpr-merge.is-ready { box-shadow: 0 0 0 3px rgba(34,197,94,0.22), 0 1px 2px rgba(22,163,74,0.18); } .ravpr-section-row, .ravpr-check-row { mostrar: cuadrícula; columnas-plantilla-cuadrícula: 18px minmax(0,1fr) auto; alinear elementos: centro; espacio: 7px; relleno: 5px 0; tamaño de fuente: 11,5 px; color: var(--primer plano, #18181b); } .ravpr-check-row { relleno: 5px 7px; tamaño de fuente: 10,5 px; } .ravpr-label { espacio en blanco: nowrap; desbordamiento: oculto; desbordamiento de texto: puntos suspensivos; } .ravpr-meta, .ravpr-check-state { color: var(--primer plano silenciado, #71717a); tamaño de fuente: 10,5 px; } .ravpr-check-state { tamaño de fuente: 10px; } .ravpr-ring { mostrar: bloque en línea; ancho: 14px; altura: 14px; radio del borde: 999px; borde: 2px rgba sólido (245,158,11,0.35); color del borde superior: rgb(245 158 11); animación: ravpr-spin 1.1s lineal infinito; } .ravpr-check { ancho: 15px; altura: 15 píxeles; radio del borde: 999px; pantalla: ninguna; alinear elementos: centro; justificar-contenido: centro; fondo: rgba(34,197,94,0.14); color: rgb(22 163 74); } .ravpr-section-row.is-done .ravpr-ring, .ravpr-check-row.is-done .ravpr-ring { pantalla: ninguna; } .ravpr-section-row.is-done .ravpr-check, .ravpr-check-row.is-done .ravpr-check { display: inline-flex; } .ravpr-reveal { mostrar: flex; dirección flexible: columna; espacio: 3px; opacidad: 0; transformar: traducirY(4px); transición: opacidad 260 ms facilidad, transformación 260 ms facilidad; eventos de puntero: ninguno; } .ravpr-reveal.is-visible { opacidad: 1; transformar: traducirY(0); eventos de puntero: automático; } .ravpr-check-list, .ravpr-comment-list { mostrar: flex; dirección flexible: columna; espacio: 4px; altura mínima: 0; } .ravpr-comment-card { borde: var sólida de 1px (--borde); radio del borde: 8px; fondo: var(--card, #fff); desbordamiento: oculto; opacidad: 0; transformar: traducirY(4px); transición: opacidad 260 ms facilidad, transformación 260 ms facilidad; } .ravpr-comment-card.is-visible { opacidad: 1; transformar: traducirY(0); } .ravpr-comment-head { mostrar: cuadrícula; columnas-plantilla-cuadrícula: 18px minmax(0,1fr) auto; alinear elementos: centro; espacio: 7px; relleno: 5px 7px; fondo: rgba(24,24,27,0.015); } .ravpr-avatar { ancho: 16px; altura: 16px; radio del borde: 999px; fondo: rgba(24,24,27,0.16); } .ravpr-autor { ancho: 78px; altura: 8px; radio del borde: 999px; fondo: rgba(24,24,27,0.18); } .ravpr-comment-path { familia de fuentes: ui-monospace, SFMono-Regular, Menlo, monospace; tamaño de fuente: 9,5 px; color: var(--primer plano silenciado, #71717a); } .ravpr-comment-body { relleno: 5px 7px 6px; tamaño de fuente: 11px; altura de línea: 1,32; color: var(--primer plano, #18181b); } .ravpr-comment-body code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; tamaño de fuente: 10,5 px; relleno: 1px 4px; radio del borde: 4px; fondo: rgba(24,24,27,0.06); } .ravpr-cursor { posición: absoluta; índice z: 40; eventos de puntero: ninguno; transición: transformar 600 ms cúbico-bezier (.45, .05, .2,1), opacidad 200 ms facilidad; transformar: traducir (-30px, 220px); opacidad: 0; } .ravpr-cursor.is-visible { opacidad: 1; } .ravpr-ripple { posición: absoluta; izquierda: -6px; arriba: -6px; ancho: 28px; altura: 28 píxeles; radio del borde: 999px; borde: 2px rgba sólido (24,24,27,0.5); opacidad: 0; } .ravpr-cursor.is-clicking .ravpr-ripple { animación: ravpr-ripple 460 ms hacia adelante; } @keyframes ravpr-ripple { 0% { transformar: escala (0.4); opacidad: 0,9; } 100% { transformar: escala(1.4); opacidad: 0; } } @keyframes ravpr-spin { a { transformar: rotar (360 grados); } }" + "fc9a23c83d": ".ravpr-stage { position: absolute; inset: 0; overflow: hidden; } .ravpr-stack { position: absolute; inset: 0; display: flex; justify-content: flex-end; padding: 4px 34px 4px 2px; overflow: hidden; } .ravpr-sidebar, .ravpr-card { position: absolute; top: 4px; right: 2px; width: 464px; height: calc(100% - 8px); background: var(--card, #fff); border: 1px solid var(--border); border-radius: 10px; color: var(--foreground, #18181b); overflow: hidden; box-shadow: 0 1px 2px rgba(24,24,27,0.04); } .ravpr-sidebar { opacity: 0; transition: opacity 220ms ease; } .ravpr-sidebar.is-visible { opacity: 1; } .ravpr-sidebar.is-hiding { opacity: 0; } .ravpr-card { display: flex; flex-direction: column; min-width: 0; opacity: 0; transition: opacity 260ms ease; } .ravpr-card.is-visible { opacity: 1; } .ravpr-tabs { position: relative; display: flex; align-items: center; gap: 14px; height: 36px; padding: 0 14px; background: rgba(24,24,27,0.015); color: var(--muted-foreground, #71717a); } .ravpr-tab { position: relative; width: 18px; height: 18px; display: inline-flex; align-items: center; justify-content: center; color: var(--muted-foreground, #71717a); } .ravpr-tab.is-active, .ravpr-tab.is-hovered { color: var(--foreground, #18181b); } .ravpr-tab.is-active::after { content: ''; position: absolute; left: -5px; right: -5px; bottom: -10px; height: 1px; background: var(--foreground, #18181b); } .ravpr-tooltip { position: absolute; top: 34px; left: 106px; z-index: 6; padding: 7px 11px; border-radius: 8px; background: var(--card, #fff); color: var(--foreground, #18181b); font-size: 12px; line-height: 1; box-shadow: 0 8px 22px rgba(0,0,0,0.22); opacity: 0; transform: translateY(-3px); pointer-events: none; transition: opacity 160ms ease, transform 160ms ease; } .ravpr-tooltip.is-visible { opacity: 1; transform: translateY(0); } .ravpr-explorer { padding: 10px 12px 12px; } .ravpr-heading { color: var(--muted-foreground, #71717a); font-size: 10px; font-weight: 600; letter-spacing: 0.05em; text-transform: uppercase; } .ravpr-file-list { margin-top: 8px; display: flex; flex-direction: column; gap: 2px; } .ravpr-file { display: grid; grid-template-columns: 14px minmax(0,1fr) 22px; align-items: center; gap: 8px; min-height: 28px; padding: 4px 6px; border-radius: 6px; } .ravpr-file.is-active { background: rgba(24,24,27,0.06); box-shadow: inset 0 0 0 1px rgba(24,24,27,0.06); } .ravpr-file-icon { width: 12px; height: 12px; border-radius: 3px; background: rgba(24,24,27,0.14); } .ravpr-file-name { height: 8px; border-radius: 999px; background: rgba(24,24,27,0.14); } .ravpr-file-status { width: 14px; height: 8px; border-radius: 999px; background: rgba(24,24,27,0.12); } .ravpr-body { padding: 10px 12px 18px; display: flex; flex-direction: column; gap: 5px; min-height: 0; } .ravpr-number-row { display: flex; align-items: center; gap: 7px; } .ravpr-number { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; font-weight: 700; } .ravpr-open { display: inline-flex; align-items: center; justify-content: center; height: 18px; padding: 0 7px; border-radius: 5px; background: rgba(16,185,129,0.10); border: 1px solid rgba(16,185,129,0.28); color: rgb(4 120 87); font-size: 9px; font-weight: 700; line-height: 1; } .ravpr-title { font-size: 12px; font-weight: 600; line-height: 1.35; color: var(--foreground, #18181b); margin-bottom: 2px; } .ravpr-merge { display: inline-flex; align-items: center; justify-content: center; gap: 6px; height: 30px; min-height: 30px; flex: 0 0 30px; border-radius: 7px; background: rgb(22 163 74); color: #fff; font-size: 11.5px; font-weight: 700; margin-bottom: 2px; box-shadow: 0 1px 2px rgba(22,163,74,0.18); transition: box-shadow 220ms ease, filter 220ms ease; } .ravpr-merge.is-ready { box-shadow: 0 0 0 3px rgba(34,197,94,0.22), 0 1px 2px rgba(22,163,74,0.18); } .ravpr-section-row, .ravpr-check-row { display: grid; grid-template-columns: 18px minmax(0,1fr) auto; align-items: center; gap: 7px; padding: 5px 0; font-size: 11.5px; color: var(--foreground, #18181b); } .ravpr-check-row { padding: 5px 7px; font-size: 10.5px; } .ravpr-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .ravpr-meta, .ravpr-check-state { color: var(--muted-foreground, #71717a); font-size: 10.5px; } .ravpr-check-state { font-size: 10px; } .ravpr-ring { display: inline-block; width: 14px; height: 14px; border-radius: 999px; border: 2px solid rgba(245,158,11,0.35); border-top-color: rgb(245 158 11); animation: ravpr-spin 1.1s linear infinite; } .ravpr-check { width: 15px; height: 15px; border-radius: 999px; display: none; align-items: center; justify-content: center; background: rgba(34,197,94,0.14); color: rgb(22 163 74); } .ravpr-section-row.is-done .ravpr-ring, .ravpr-check-row.is-done .ravpr-ring { display: none; } .ravpr-section-row.is-done .ravpr-check, .ravpr-check-row.is-done .ravpr-check { display: inline-flex; } .ravpr-reveal { display: flex; flex-direction: column; gap: 3px; opacity: 0; transform: translateY(4px); transition: opacity 260ms ease, transform 260ms ease; pointer-events: none; } .ravpr-reveal.is-visible { opacity: 1; transform: translateY(0); pointer-events: auto; } .ravpr-check-list, .ravpr-comment-list { display: flex; flex-direction: column; gap: 4px; min-height: 0; } .ravpr-comment-card { border: 1px solid var(--border); border-radius: 8px; background: var(--card, #fff); overflow: hidden; opacity: 0; transform: translateY(4px); transition: opacity 260ms ease, transform 260ms ease; } .ravpr-comment-card.is-visible { opacity: 1; transform: translateY(0); } .ravpr-comment-head { display: grid; grid-template-columns: 18px minmax(0,1fr) auto; align-items: center; gap: 7px; padding: 5px 7px; background: rgba(24,24,27,0.015); } .ravpr-avatar { width: 16px; height: 16px; border-radius: 999px; background: rgba(24,24,27,0.16); } .ravpr-author { width: 78px; height: 8px; border-radius: 999px; background: rgba(24,24,27,0.18); } .ravpr-comment-path { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 9.5px; color: var(--muted-foreground, #71717a); } .ravpr-comment-body { padding: 5px 7px 6px; font-size: 11px; line-height: 1.32; color: var(--foreground, #18181b); } .ravpr-comment-body code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 10.5px; padding: 1px 4px; border-radius: 4px; background: rgba(24,24,27,0.06); } .ravpr-cursor { position: absolute; z-index: 40; pointer-events: none; transition: transform 600ms cubic-bezier(.45,.05,.2,1), opacity 200ms ease; transform: translate(-30px, 220px); opacity: 0; } .ravpr-cursor.is-visible { opacity: 1; } .ravpr-ripple { position: absolute; left: -6px; top: -6px; width: 28px; height: 28px; border-radius: 999px; border: 2px solid rgba(24,24,27,0.5); opacity: 0; } .ravpr-cursor.is-clicking .ravpr-ripple { animation: ravpr-ripple 460ms ease-out forwards; } @keyframes ravpr-ripple { 0% { transform: scale(0.4); opacity: 0.9; } 100% { transform: scale(1.4); opacity: 0; } } @keyframes ravpr-spin { to { transform: rotate(360deg); } }" } } }, "ship": { "styles": { - "90cdcd2ecc": ".ravs-ship-root { posición: absoluta; recuadro: 0; } .ravs-ship-stack { posición: absoluta; recuadro: 0; pantalla: cuadrícula; columnas-plantilla-cuadrícula: 232px minmax(0,1fr); espacio: 14px; relleno: 4px 2px; /* Por qué: las tarjetas se ajustan al tamaño de su contenido en lugar de extenderse hasta la altura total del padre, para que las dos tarjetas no muestren espacios vacíos debajo de su contenido. */ alinear-elementos: inicio; } /* Minibarra lateral de control de código fuente: encabezado de recuento anticipado, área de texto de Commit + botón de Commit dividido, luego una sección de CAMBIOS con filas de archivos. Las filas del archivo son la superficie sobre la que se anima el pulso de \"lectura\". */ .ravs-sc-card { pantalla: flex; dirección flexible: columna; fondo: var(--card, #fff); borde: 1px var sólido (--borde); radio del borde: 10px; desbordamiento: oculto; sombra de cuadro: 0 1px 2px rgba(24,24,27,0.04); } /* Ambos encabezados de tarjetas comparten la misma altura fija, por lo que la tarjeta SC y el cuadro de diálogo PR se alinean en el borde superior independientemente del contenido del encabezado. */ .ravs-sc-header, .ravs-pr-head { altura: 36px; tamaño de caja: cuadro de borde; } .ravs-sc-header { pantalla: flex; alinear elementos: centro; justificar contenido: espacio entre; relleno: 0 10px; borde inferior: var sólido de 1 px (--borde); } .ravs-sc-ahead { mostrar: inline-flex; alinear elementos: centro; espacio: 5px; tamaño de fuente: 11px; peso de fuente: 500; color: var(--primer plano, #18181b); } .ravs-sc-ahead svg { color: var(--silenciado-primer plano, #71717a); } .ravs-sc-commit-area { pantalla: flex; dirección flexible: columna; espacio: 6px; relleno: 8px 10px; } .ravs-sc-textarea { posición: relativa; borde: 1px var sólido (--borde); radio del borde: 6px; fondo: var(--editor-surface, var(--card)); relleno: 6px 26px 6px 8px; altura mínima: 56px; tamaño de fuente: 12px; altura de línea: 1,45; color: var(--primer plano, #18181b); espacio en blanco: preenvoltura; salto de palabra: salto de palabra; desbordamiento: oculto; } .ravs-sc-textarea .ravs-placeholder { color: rgba(113,113,122,0.7); } .ravs-sc-sparkle { posición: absoluta; derecha: 6px; arriba: 6px; ancho: 20px; altura: 20 píxeles; pantalla: flexible en línea; alinear elementos: centro; justificar-contenido: centro; radio del borde: 4px; color: var(--primer plano silenciado, #71717a); fondo: transparente; transición: color con 160 ms de facilidad, fondo con 160 ms de facilidad; } .ravs-sc-sparkle.is-scanning { color: rgb(109 40 217); fondo: mezcla de colores (en srgb, rgb(139 92 246) 18%, transparente); } .ravs-sc-split { pantalla: flex; alinear elementos: estirar; } /* Por qué: Commit y crear PR rodean a Chrome; las posibilidades violetas de la IA son los puntos focales. Representelos como botones secundarios silenciosos para que no compitan con las señales de brillo/escaneo. */ .ravs-sc-split .ravs-primary { flex: 1; pantalla: flexible en línea; alinear elementos: centro; justificar-contenido: centro; espacio: 5px; relleno: 5px 10px; fondo: var(--secundario, #f5f5f5); color: var(--primer plano secundario, #171717); tamaño de fuente: 11px; peso de fuente: 500; radio de borde: 6px 0 0 6px; borde: 1px var sólido (--borde); transición: fondo con 240 ms de facilidad, color de borde con 240 ms de facilidad, color con 240 ms de facilidad; } .ravs-sc-split .ravs-chev { pantalla: inline-flex; alinear elementos: centro; justificar-contenido: centro; ancho: 22px; fondo: var(--secundario, #f5f5f5); color: var(--primer plano silenciado, #71717a); radio de borde: 0 6px 6px 0; borde: 1px var sólido (--borde); borde izquierdo: var sólido de 1 px (--borde); transición: fondo con 240 ms de facilidad, color de borde con 240 ms de facilidad, color con 240 ms de facilidad; } /* Por qué: cuando la IA haya completado el mensaje de Commit, tiñe el botón Commit de verde para indicar \"listo para confirmar\". Utiliza la misma familia de éxito verde que el flash PR para que los dos tiempos rimen. La mezcla es intencionalmente fuerte (~28%): al 14% desapareció junto al brillo violeta y el flash PR, por lo que los usuarios solo vieron el PR cambiar de color. */ .ravs-sc-split.is-ready .ravs-primary, .ravs-sc-split.is-ready .ravs-chev { fondo: mezcla de colores (en srgb, rgb(34 197 94) 28%, var(--secundario, #f5f5f5)); color del borde: rgb(34 197 94); color: rgb(21 128 61); transición: fondo con 220 ms de facilidad, color de borde con 220 ms de facilidad, color con 220 ms de facilidad; } .ravs-sc-split.is-ready .ravs-chev { borde-izquierdo-color: rgba(34, 197, 94, 0,55); } .ravs-sc-changes-header { pantalla: flex; alinear elementos: centro; justificar contenido: espacio entre; relleno: 8px 10px 4px; tamaño de fuente: 10px; peso de fuente: 600; transformación de texto: mayúsculas; espacio entre letras: 0,05 em; color: var(--primer plano silenciado, #71717a); } .ravs-sc-changes-count { color: var(--primer plano, #18181b); peso de fuente: 600; margen izquierdo: 2px; } .ravs-sc-view-all { tamaño de fuente: 10px; peso de fuente: 500; transformación de texto: ninguna; espacio entre letras: 0; color: var(--primer plano silenciado, #71717a); } .ravs-sc-files { mostrar: flex; dirección flexible: columna; relleno: 2px 6px 8px; flexión: 1; altura mínima: 0; desbordamiento: oculto; } .ravs-sc-file { mostrar: cuadrícula; columnas-plantilla-cuadrícula: 14px minmax(0,1fr) 12px; alinear elementos: centro; espacio: 6px; relleno: 3px 6px; radio del borde: 4px; tamaño de fuente: 11px; altura de línea: 1,35; color: var(--primer plano, #18181b); posición: relativa; transición: facilidad de fondo 220 ms; } .ravs-sc-ficon { color: rgb(180 83 9); pantalla: flexible en línea; } .ravs-sc-fname { ancho mínimo: 0; desbordamiento: oculto; desbordamiento de texto: puntos suspensivos; espacio en blanco: nowrap; } .ravs-sc-fmark { familia de fuentes: ui-monospace, SFMono-Regular, Menlo, monospace; tamaño de fuente: 10px; alineación de texto: derecha; color: rgb(180 83 9); } .ravs-sc-file.is-reading { fondo: color-mix(en srgb, rgb(139 92 246) 14%, transparente); sombra de cuadro: inserción 0 0 0 1px mezcla de colores (en srgb, rgb (139 92 246) 28%, transparente); } /* Cuadro de diálogo PR: coincide con el cromo de la tarjeta .ravs-sc (mismo borde, radio, elevación) para que las dos tarjetas se lean como un solo lenguaje de diseño. */ .ravs-pr-dialog { fondo: var(--card, #fff); borde: 1px var sólido (--borde); radio del borde: 10px; sombra de cuadro: 0 1px 2px rgba(24,24,27,0.04); pantalla: flexible; dirección flexible: columna; ancho mínimo: 0; desbordamiento: oculto; } .ravs-pr-head { pantalla: flex; alinear elementos: centro; justificar contenido: espacio entre; espacio: 6px; relleno: 0 10px; borde inferior: var sólido de 1 px (--borde); } .ravs-pr-title-text { tamaño de fuente: 11px; peso de fuente: 500; color: var(--primer plano, #18181b); } /* Chip de asistencia de IA de solo íconos: refleja .ravs-sc-sparkle para que la capacidad se lea de manera idéntica en ambas tarjetas. */ .ravs-pr-gen-btn { pantalla: inline-flex; alinear elementos: centro; justificar-contenido: centro; ancho: 22px; altura: 22 píxeles; relleno: 0; radio del borde: 4px; color: var(--primer plano silenciado, #71717a); fondo: transparente; borde: 0; cursor: puntero; transición: color con 160 ms de facilidad, fondo con 160 ms de facilidad; } .ravs-pr-gen-btn:hover { fondo: rgba(24,24,27,0.06); color: var(--primer plano, #18181b); } .ravs-pr-gen-btn.is-scanning { color: rgb(109 40 217); fondo: mezcla de colores (en srgb, rgb(139 92 246) 18%, transparente); } .ravs-pr-body { pantalla: flex; dirección flexible: columna; espacio: 8px; relleno: 10px; } .ravs-pr-field { pantalla: flex; dirección flexible: columna; espacio: 4px; } .ravs-pr-field-label { tamaño de fuente: 10px; peso de fuente: 600; transformación de texto: mayúsculas; espacio entre letras: 0,05 em; color: var(--primer plano silenciado, #71717a); } .ravs-pr-base { mostrar: inline-flex; alinear elementos: centro; espacio: 5px; relleno: 4px 9px; borde: 1px var sólido (--borde); radio del borde: 6px; tamaño de fuente: 11px; familia de fuentes: ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--primer plano, #18181b); fondo: var(--editor-surface, var(--card)); alineación propia: inicio flexible; } .ravs-pr-base svg { color: var(--primer plano silenciado, #71717a); } .ravs-pr-input { posición: relativa; relleno: 6px 8px; borde: 1px var sólido (--borde); radio del borde: 6px; fondo: var(--editor-surface, var(--card)); altura mínima: 28px; tamaño de fuente: 12px; altura de línea: 1,45; color: var(--primer plano, #18181b); espacio en blanco: preenvoltura; salto de palabra: salto de palabra; desbordamiento: oculto; } .ravs-pr-input.is-body { altura mínima: 50px; tamaño de fuente: 11px; altura de línea: 1,4; } .ravs-pr-input .ravs-placeholder { color: rgba(113,113,122,0.7); } .ravs-pr-footer { pantalla: flex; alinear elementos: centro; espacio: 6px; justificar contenido: extremo flexible; margen superior: 2px; } .ravs-pr-btn { tamaño de fuente: 11px; peso de fuente: 500; relleno: 5px 10px; radio del borde: 6px; altura de línea: 1; borde: 1px sólido transparente; esquema: ninguno; } .ravs-pr-btn:focus, .ravs-pr-btn:focus-visible { esquema: ninguno; } /* Cancelar se lee como un botón fantasma silencioso para que no compita con la acción afirmativa Crear PR. */ .ravs-pr-btn.is-outline { fondo: transparente; color: var(--primer plano silenciado, #71717a); color del borde: transparente; } .ravs-pr-btn.is-outline:hover { fondo: rgba(24,24,27,0.05); color: var(--primer plano, #18181b); } /* Relleno secundario silencioso: consulte la nota .ravs-sc-split anterior. El anillo de destello todavía usa el color verde exitoso para que se lea el ritmo \"PR creado\". El botón Crear-PR es ligeramente más grande que Cancelar, por lo que la acción afirmativa sigue siendo el objetivo más importante. */ .ravs-pr-btn.is-solid { fondo: var(--secundario, #f5f5f5); color: var(--primer plano secundario, #171717); color del borde: var(--borde); tamaño de fuente: 12px; relleno: 7px 14px; transición: fondo con 220 ms de facilidad, color de borde con 220 ms de facilidad, color con 220 ms de facilidad, cuadro de sombra con 220 ms de facilidad; } .ravs-pr-btn.is-solid.is-ready { fondo: color-mix(en srgb, rgb(34 197 94) 28%, var(--secundario, #f5f5f5)); color del borde: rgb(34 197 94); color: rgb(21 128 61); } .ravs-pr-btn.is-solid.is-flash { box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.30); } .ravs-cursor { posición: absoluta; índice z: 40; eventos de puntero: ninguno; transición: transformar 600 ms cúbico-bezier (.45, .05, .2,1), opacidad 200 ms facilidad; transformar: traducir (-30px, 220px); opacidad: 0; } .ravs-cursor.is-visible { opacidad: 1; } .ravs-cursor .ravs-ripple { posición: absoluta; izquierda: -6px; arriba: -6px; ancho: 28px; altura: 28 píxeles; radio del borde: 999px; borde: 2px rgba sólido (24,24,27,0.5); opacidad: 0; } .ravs-cursor.is-clicking .ravs-ripple { animación: ravs-ripple 460 ms hacia adelante; } @keyframes ravs-ripple { 0% { transformar: escala(0.4); opacidad: 0,9; } 100% { transformar: escala(1.4); opacidad: 0; } } .ravs-caret { mostrar: bloque en línea; ancho: 1,5 px; altura: 1em; fondo: color actual; alineación vertical: -2px; margen izquierdo: 1px; animación: ravs-caret-blink 1,05s pasos(1) infinito; } @keyframes ravs-caret-blink { 0%, 50% { opacidad: 1 } 51%, 100% { opacidad: 0 } }" + "90cdcd2ecc": ".ravs-ship-root { position: absolute; inset: 0; } .ravs-ship-stack { position: absolute; inset: 0; display: grid; grid-template-columns: 232px minmax(0,1fr); gap: 14px; padding: 4px 2px; /* Why: cards size to their content rather than stretch to the parent's full height, so the two cards don't show empty space below their content. */ align-items: start; } /* Source Control mini-sidebar — ahead-count header, commit textarea + split Commit button, then a CHANGES section with file rows. The file rows are the surface the \"reading\" pulse animates over. */ .ravs-sc-card { display: flex; flex-direction: column; background: var(--card, #fff); border: 1px solid var(--border); border-radius: 10px; overflow: hidden; box-shadow: 0 1px 2px rgba(24,24,27,0.04); } /* Both card headers share the same fixed height so the SC card and PR dialog align across the top edge regardless of header content. */ .ravs-sc-header, .ravs-pr-head { height: 36px; box-sizing: border-box; } .ravs-sc-header { display: flex; align-items: center; justify-content: space-between; padding: 0 10px; border-bottom: 1px solid var(--border); } .ravs-sc-ahead { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; font-weight: 500; color: var(--foreground, #18181b); } .ravs-sc-ahead svg { color: var(--muted-foreground, #71717a); } .ravs-sc-commit-area { display: flex; flex-direction: column; gap: 6px; padding: 8px 10px; } .ravs-sc-textarea { position: relative; border: 1px solid var(--border); border-radius: 6px; background: var(--editor-surface, var(--card)); padding: 6px 26px 6px 8px; min-height: 56px; font-size: 12px; line-height: 1.45; color: var(--foreground, #18181b); white-space: pre-wrap; word-break: break-word; overflow: hidden; } .ravs-sc-textarea .ravs-placeholder { color: rgba(113,113,122,0.7); } .ravs-sc-sparkle { position: absolute; right: 6px; top: 6px; width: 20px; height: 20px; display: inline-flex; align-items: center; justify-content: center; border-radius: 4px; color: var(--muted-foreground, #71717a); background: transparent; transition: color 160ms ease, background 160ms ease; } .ravs-sc-sparkle.is-scanning { color: rgb(109 40 217); background: color-mix(in srgb, rgb(139 92 246) 18%, transparent); } .ravs-sc-split { display: flex; align-items: stretch; } /* Why: Commit + Create PR are surrounding chrome — the violet AI affordances are the focal points. Render them as quiet secondary buttons so they don't compete with the sparkle/scan signals. */ .ravs-sc-split .ravs-primary { flex: 1; display: inline-flex; align-items: center; justify-content: center; gap: 5px; padding: 5px 10px; background: var(--secondary, #f5f5f5); color: var(--secondary-foreground, #171717); font-size: 11px; font-weight: 500; border-radius: 6px 0 0 6px; border: 1px solid var(--border); transition: background 240ms ease, border-color 240ms ease, color 240ms ease; } .ravs-sc-split .ravs-chev { display: inline-flex; align-items: center; justify-content: center; width: 22px; background: var(--secondary, #f5f5f5); color: var(--muted-foreground, #71717a); border-radius: 0 6px 6px 0; border: 1px solid var(--border); border-left: 1px solid var(--border); transition: background 240ms ease, border-color 240ms ease, color 240ms ease; } /* Why: when AI has filled the commit message, tint the Commit button green to signal \"ready to commit\". Uses the same success-green family as the PR flash so the two beats rhyme. Mix is intentionally strong (~28%) — at 14% it disappeared next to the violet sparkle and PR flash, so users only saw the PR change color. */ .ravs-sc-split.is-ready .ravs-primary, .ravs-sc-split.is-ready .ravs-chev { background: color-mix(in srgb, rgb(34 197 94) 28%, var(--secondary, #f5f5f5)); border-color: rgb(34 197 94); color: rgb(21 128 61); transition: background 220ms ease, border-color 220ms ease, color 220ms ease; } .ravs-sc-split.is-ready .ravs-chev { border-left-color: rgba(34, 197, 94, 0.55); } .ravs-sc-changes-header { display: flex; align-items: center; justify-content: space-between; padding: 8px 10px 4px; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted-foreground, #71717a); } .ravs-sc-changes-count { color: var(--foreground, #18181b); font-weight: 600; margin-left: 2px; } .ravs-sc-view-all { font-size: 10px; font-weight: 500; text-transform: none; letter-spacing: 0; color: var(--muted-foreground, #71717a); } .ravs-sc-files { display: flex; flex-direction: column; padding: 2px 6px 8px; flex: 1; min-height: 0; overflow: hidden; } .ravs-sc-file { display: grid; grid-template-columns: 14px minmax(0,1fr) 12px; align-items: center; gap: 6px; padding: 3px 6px; border-radius: 4px; font-size: 11px; line-height: 1.35; color: var(--foreground, #18181b); position: relative; transition: background 220ms ease; } .ravs-sc-ficon { color: rgb(180 83 9); display: inline-flex; } .ravs-sc-fname { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .ravs-sc-fmark { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 10px; text-align: right; color: rgb(180 83 9); } .ravs-sc-file.is-reading { background: color-mix(in srgb, rgb(139 92 246) 14%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, rgb(139 92 246) 28%, transparent); } /* PR dialog — matches the .ravs-sc-card chrome (same border, radius, elevation) so the two cards read as one design language. */ .ravs-pr-dialog { background: var(--card, #fff); border: 1px solid var(--border); border-radius: 10px; box-shadow: 0 1px 2px rgba(24,24,27,0.04); display: flex; flex-direction: column; min-width: 0; overflow: hidden; } .ravs-pr-head { display: flex; align-items: center; justify-content: space-between; gap: 6px; padding: 0 10px; border-bottom: 1px solid var(--border); } .ravs-pr-title-text { font-size: 11px; font-weight: 500; color: var(--foreground, #18181b); } /* Icon-only AI-assist chip — mirrors .ravs-sc-sparkle so the affordance reads identically across both cards. */ .ravs-pr-gen-btn { display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 22px; padding: 0; border-radius: 4px; color: var(--muted-foreground, #71717a); background: transparent; border: 0; cursor: pointer; transition: color 160ms ease, background 160ms ease; } .ravs-pr-gen-btn:hover { background: rgba(24,24,27,0.06); color: var(--foreground, #18181b); } .ravs-pr-gen-btn.is-scanning { color: rgb(109 40 217); background: color-mix(in srgb, rgb(139 92 246) 18%, transparent); } .ravs-pr-body { display: flex; flex-direction: column; gap: 8px; padding: 10px; } .ravs-pr-field { display: flex; flex-direction: column; gap: 4px; } .ravs-pr-field-label { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted-foreground, #71717a); } .ravs-pr-base { display: inline-flex; align-items: center; gap: 5px; padding: 4px 9px; border: 1px solid var(--border); border-radius: 6px; font-size: 11px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--foreground, #18181b); background: var(--editor-surface, var(--card)); align-self: flex-start; } .ravs-pr-base svg { color: var(--muted-foreground, #71717a); } .ravs-pr-input { position: relative; padding: 6px 8px; border: 1px solid var(--border); border-radius: 6px; background: var(--editor-surface, var(--card)); min-height: 28px; font-size: 12px; line-height: 1.45; color: var(--foreground, #18181b); white-space: pre-wrap; word-break: break-word; overflow: hidden; } .ravs-pr-input.is-body { min-height: 50px; font-size: 11px; line-height: 1.4; } .ravs-pr-input .ravs-placeholder { color: rgba(113,113,122,0.7); } .ravs-pr-footer { display: flex; align-items: center; gap: 6px; justify-content: flex-end; margin-top: 2px; } .ravs-pr-btn { font-size: 11px; font-weight: 500; padding: 5px 10px; border-radius: 6px; line-height: 1; border: 1px solid transparent; outline: none; } .ravs-pr-btn:focus, .ravs-pr-btn:focus-visible { outline: none; } /* Cancel reads as a quiet ghost button so it doesn't compete with the affirmative Create PR action. */ .ravs-pr-btn.is-outline { background: transparent; color: var(--muted-foreground, #71717a); border-color: transparent; } .ravs-pr-btn.is-outline:hover { background: rgba(24,24,27,0.05); color: var(--foreground, #18181b); } /* Quiet secondary fill — see the .ravs-sc-split note above. The flash ring still uses success-green so the \"PR created\" beat reads. The Create-PR button is slightly larger than Cancel so the affirmative action remains the bigger target. */ .ravs-pr-btn.is-solid { background: var(--secondary, #f5f5f5); color: var(--secondary-foreground, #171717); border-color: var(--border); font-size: 12px; padding: 7px 14px; transition: background 220ms ease, border-color 220ms ease, color 220ms ease, box-shadow 220ms ease; } .ravs-pr-btn.is-solid.is-ready { background: color-mix(in srgb, rgb(34 197 94) 28%, var(--secondary, #f5f5f5)); border-color: rgb(34 197 94); color: rgb(21 128 61); } .ravs-pr-btn.is-solid.is-flash { box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.30); } .ravs-cursor { position: absolute; z-index: 40; pointer-events: none; transition: transform 600ms cubic-bezier(.45,.05,.2,1), opacity 200ms ease; transform: translate(-30px, 220px); opacity: 0; } .ravs-cursor.is-visible { opacity: 1; } .ravs-cursor .ravs-ripple { position: absolute; left: -6px; top: -6px; width: 28px; height: 28px; border-radius: 999px; border: 2px solid rgba(24,24,27,0.5); opacity: 0; } .ravs-cursor.is-clicking .ravs-ripple { animation: ravs-ripple 460ms ease-out forwards; } @keyframes ravs-ripple { 0% { transform: scale(0.4); opacity: 0.9; } 100% { transform: scale(1.4); opacity: 0; } } .ravs-caret { display: inline-block; width: 1.5px; height: 1em; background: currentColor; vertical-align: -2px; margin-left: 1px; animation: ravs-caret-blink 1.05s steps(1) infinite; } @keyframes ravs-caret-blink { 0%, 50% { opacity: 1 } 51%, 100% { opacity: 0 } }" } } } @@ -10600,35 +10683,35 @@ "agents": { "orchestration": { "OrchestrationPage": { - "30b509a467": "2 niños", - "862605d066": "2 espacios de trabajo infantiles" + "30b509a467": "2 secundarios", + "862605d066": "2 workspaces secundarios" }, "StatusesPage": { "2f549fc0ba": "src/auth/session.test.ts", "139e3d7458": "Actualizado", - "7b26349cb2": "pnpm migrar más reciente", - "78f0318ac1": "quiere correr", + "7b26349cb2": "pnpm migrate latest", + "78f0318ac1": "Quiere ejecutar", "79971d1539": "rediseñar el flujo de autenticación" }, "UsageAccountsCard": { - "6986b36708": "Límites de tasas superficiales y cuentas swap en línea.", - "d90d2e1f6d": "Seguimiento de la sesión y el uso semanal.", - "8919321417": "Error al iniciar sesión en el Codex.", - "c7b90c140b": "Se agregó la cuenta del Codex.", + "6986b36708": "Muestra límites de tasa y cambia cuentas aquí mismo.", + "d90d2e1f6d": "Controla el uso por sesión y semanal.", + "8919321417": "Error al iniciar sesión en Codex.", + "c7b90c140b": "Cuenta de Codex agregada.", "4e71d72912": "Error al iniciar sesión en Claude.", - "9ddeb558f9": "Se agregó la cuenta de Claude.", + "9ddeb558f9": "Cuenta de Claude agregada.", "29d0653961": "Iniciar sesión", - "945865332e": "Iniciar sesión" + "945865332e": "Iniciando sesión" }, "UsagePage": { "64265cb295": "71% 5h", "be5a165875": "Cambiar a", - "277a9c65a9": "Cuenta del Codex", - "4dce5ca3aa": "Se reinicia en 4d 3h", + "277a9c65a9": "Cuenta de Codex", + "4dce5ca3aa": "Se restablece en 4d 3h", "05ce4ecdd3": "62% restante", - "0470aaed99": "Semanalmente", + "0470aaed99": "Semanal", "f421abf962": "Sesión", - "5e45fb1238": "Actualizado hace 1m", + "5e45fb1238": "Actualizado hace 1 min", "6a4b1d3c38": "Codex" }, "orchestration": { @@ -10649,28 +10732,28 @@ }, "FeatureWallBrowserAction": { "5022c43a88": "El navegador no pudo abrir", - "c9eb68b474": "Aún no hay ningún grupo de espacio de trabajo disponible para este árbol de trabajo.", + "c9eb68b474": "Aún no hay ningún grupo de workspace disponible para este worktree.", "c9728107c5": "Pruébalo", "25dd101f15": "La configuración del navegador necesita atención", "e02b11e6b0": "Configuración del navegador lista", - "d6d15077df": "Comando de habilidad copiado e insertado a continuación para su revisión.", + "d6d15077df": "Comando de skill copiado e insertado abajo para revisarlo.", "78e65f19d9": "La configuración del navegador falló", "b7345c18db": "Se produjo un error inesperado.", "5f97caf76b": "Instalando…", - "c2df599513": "Instalar CLI y habilidad" + "c2df599513": "Instalar CLI y skill" }, "ConnectIntegrationsList": { - "3dddb2d565": "connected for tasks", - "33b650af52": "Connect where your team tracks work. Orca starts workspaces with the issue title, link, and context already attached.", - "5b3577a492": "connected for review status", - "list_end": ", and ", - "list_pair": " and ", + "3dddb2d565": "conectado para tareas", + "33b650af52": "Conecta donde tu equipo hace seguimiento del trabajo. Orca inicia workspaces con el título del issue, el enlace y el contexto ya adjuntos.", + "5b3577a492": "conectado para estado de revisión", + "list_end": " y ", + "list_pair": " y ", "list_mid": ", ", - "code_host_tasks_summary": "issues available as tasks · add Linear or Jira if your team plans work there", - "code_host_tasks_caption": "Your code host's issues also work as tasks.", - "review_step_title": "See PR status while agents work", - "review_step_description": "Connect a review provider so Orca can show PR or MR status, checks, and reviews.", - "task_step_title": "Start agents on your tasks without leaving Orca" + "code_host_tasks_summary": "issues disponibles como tareas · agrega Linear o Jira si tu equipo planifica el trabajo allí", + "code_host_tasks_caption": "Los issues de tu proveedor de código también funcionan como tareas.", + "review_step_title": "Ver el estado de PR mientras trabajan los agentes", + "review_step_description": "Conecta un proveedor de revisión para que Orca pueda mostrar el estado de PR o MR, checks y revisiones.", + "task_step_title": "Iniciar agentes en tus tareas sin salir de Orca" }, "connect": { "integration": { @@ -10684,14 +10767,14 @@ }, "FullDiskAccessSetupPrompt": { "bbb3f1e404": "Comprobando", - "48d87edcd2": "Concedido", + "48d87edcd2": "Otorgado", "6db9a69f4e": "Recomendado", "fa809e8ada": "Privacidad y seguridad de macOS abiertas", "bfa3402305": "No se pudo solicitar permiso", - "c566bca278": "Acceso completo al disco", + "c566bca278": "Acceso total al disco", "0d6efe9cf4": "Recomendado en macOS cuando los proyectos o worktrees están en carpetas protegidas.", "dac08ec03e": "Abriendo...", - "6e3d62b816": "Abrir Acceso completo al disco" + "6e3d62b816": "Abrir Acceso total al disco" } }, "tips": { @@ -10700,17 +10783,17 @@ "22e62f3bab": "Sesión de Claude Code iniciada" }, "CliSkillSetupTerminal": { - "1953e90447": "Presione Entrar para instalar la habilidad de orquestación CLI de Orca para sus agents.", - "43b60ec5c3": "Terminal de instalación de habilidades de orquestación y CLI de Orca", - "84e9576dac": "Configuración de habilidades", - "5c3aee22c0": "comando copiar", - "5eca672aac": "Copiar comando de instalación de habilidad", - "6ff813fc1d": "No se pudo copiar el comando de habilidad.", - "b8ad063571": "Copié el comando de instalación de habilidades." + "1953e90447": "Presiona Enter para instalar el skill de orquestación de la CLI de Orca para tus agentes.", + "43b60ec5c3": "Terminal de instalación de CLI de Orca y skill de orquestación", + "84e9576dac": "Configuración de skill", + "5c3aee22c0": "Copiar comando", + "5eca672aac": "Copiar comando de instalación de skill", + "6ff813fc1d": "No se pudo copiar el comando de skill.", + "b8ad063571": "Comando de instalación de skill copiado." }, "CmdJPaletteTipDialog": { "c0bb9f869b": "Configuración → Atajos", - "8241897205": "Vuelva a vincular el acceso directo en cualquier momento en" + "8241897205": "Puedes reasignar el atajo en cualquier momento en" }, "FeatureTipActions": { "eb04abece8": "Quizás más tarde" @@ -10718,21 +10801,21 @@ "FeatureTipsModal": { "c169298e4d": "Hecho", "3c6c478462": "X termina, envíale la tarea de revisión”.", - "298301b7a0": "árbol de trabajo", + "298301b7a0": "worktree", "864e2db28f": "“Cuando el agente en", "7fc6f02099": "y crear PR para cada uno”.", - "27c567a89c": "árboles de trabajo", + "27c567a89c": "worktrees", "55846c7f95": "“Divide este PR en dos", - "4795ac2d4a": "Intenta preguntar:", - "53905bd076": "Vista previa del desarrollo: apertura del terminal de configuración de habilidades.", + "4795ac2d4a": "Prueba a pedir:", + "53905bd076": "Vista previa de desarrollo: abriendo la terminal de configuración de skills.", "1da82af45b": "Orca CLI necesita atención", - "ce13a742d0": "Orca registrada en PATH.", - "d1a86c7eb5": "Abra Configuración para finalizar la configuración de CLI." + "ce13a742d0": "`orca` registrado en PATH.", + "d1a86c7eb5": "Abre Configuración para finalizar la configuración de la CLI." }, "CmdJPaletteFeatureTipVisual": { - "ab94e16d44": "Crear árbol de trabajo \"{{value0}}\"", + "ab94e16d44": "Crear worktree \"{{value0}}\"", "d20ccf1e61": "hecho", - "379d776971": "mecanografía", + "379d776971": "escribiendo", "0418f9becc": "abierto" } } @@ -10740,9 +10823,9 @@ "error": { "boundaries": { "RecoverableRenderErrorBoundary": { - "55001880db": "Rever", - "34a189ae0f": "El resto de la aplicación sigue ejecutándose. Vuelva a intentar esta superficie o cambie y regrese.", - "ab855c11f4": "Esta parte de Orca tuvo un error." + "55001880db": "Reintentar", + "34a189ae0f": "El resto de la app sigue ejecutándose. Reintenta esta vista o cambia a otra y vuelve.", + "ab855c11f4": "Esta parte de Orca encontró un error." } } }, @@ -10755,18 +10838,18 @@ "device": { "frame": { "9406c15775": "Pantalla del emulador", - "8f25ffaf8a": "Pantalla del emulador, teclado capturado. Presione Escape para liberar.", + "8f25ffaf8a": "Pantalla del emulador, teclado capturado. Presiona Escape para liberar.", "0022420df0": "teléfono" } }, "pane": { "toolbar": { "06e10d7356": "Apagar el emulador", - "e7a0d1897e": "Hogar", + "e7a0d1897e": "Inicio", "6bd8dff42a": "Girar", - "3d836b879c": "Elige emulador", + "3d836b879c": "Elegir emulador", "81b3571a07": "Conectar", - "868c0f2938": "Laboral…" + "868c0f2938": "Procesando…" } }, "screen": { @@ -10781,7 +10864,7 @@ }, "unavailable": { "pane": { - "f630b9ca9f": "Mobile Emulator requiere una Mac con Xcode y el tiempo de ejecución del simulador de iOS. En Linux o Windows, utilice un dispositivo físico o un host de compilación Mac remoto.", + "f630b9ca9f": "Mobile Emulator requiere una Mac con Xcode y el runtime de iOS Simulator. En Linux o Windows, usa un dispositivo físico o un host de compilación Mac remoto.", "b2c268a0b9": "Mobile Emulator es solo macOS" } } @@ -10799,17 +10882,17 @@ "agent": { "setup": { "state": { - "fdcca1ec75": "Registrando...", + "fdcca1ec75": "Registrando…", "69fb2c2289": "Habilitado", "c6705092ba": "Corregir PATH", "7c1b6bdb1e": "Habilitar", - "51074ccb05": "Error al cargar el estado del CLI.", - "35dea1ae12": "El control del agent está listo.", - "9dff3a6338": "La skill está instalada. Habilita el CLI de Orca para completar la configuración.", - "15986a1080": "El CLI de Orca está listo. Instala la skill para completar la configuración.", - "4c26913def": "Aún no está configurado. Completa ambos pasos para habilitar el control del agent.", + "51074ccb05": "No se pudo cargar el estado de la CLI.", + "35dea1ae12": "El control de agentes está listo.", + "9dff3a6338": "La skill está instalada. Habilita la CLI de Orca para completar la configuración.", + "15986a1080": "La CLI de Orca está lista. Instala la skill para completar la configuración.", + "4c26913def": "Aún no está configurado. Completa ambos pasos para habilitar el control de agentes.", "c94ff11e91": "No se pudo volver a verificar el estado de la configuración.", - "2b519eed94": "Se registró el CLI de Orca en PATH." + "2b519eed94": "Se registró la CLI de Orca en PATH." } } }, @@ -10824,10 +10907,10 @@ } }, "MobileEmulatorAgentSetupGuide": { - "2fda9ff015": "Configurar control del agent", - "0ac0fef514": "El control del agent está listo.", - "2bdfff8763": "Control del agent (opcional).", - "72736b051f": "Configura el CLI de Orca + skill cuando quieras que los agents controlen este simulador.", + "2fda9ff015": "Configurar control de agentes", + "0ac0fef514": "El control de agentes está listo.", + "2bdfff8763": "Control de agentes (opcional).", + "72736b051f": "Configura la CLI de Orca + skill cuando quieras que los agentes controlen este simulador.", "d10ae98046": "Listo", "3756cbeca7": "Ahora no", "6d950431d2": "Ocultar", @@ -10835,19 +10918,19 @@ "3f003507f4": "Abrir configuración completa en Ajustes" }, "MobileEmulatorAgentSetupGuideSteps": { - "9b49d892e3": "Habilitar CLI de Orca", - "3d8dc52c93": "Registra el comando orca para control del emulador en los shells de los agents.", + "9b49d892e3": "Habilitar la CLI de Orca", + "3d8dc52c93": "Registra el comando orca para controlar el emulador en los shells de los agentes.", "21f5687c07": "Skill del CLI de Orca", - "64fb057667": "Enseña a los agents los comandos del emulador orca para este worktree.", - "5c59ea96ca": "Mobile emulator Orca CLI skill setup", - "bff5341ac3": "Mobile emulator Orca CLI skill install terminal", + "64fb057667": "Enseña a los agentes los comandos orca del emulador para este worktree.", + "5c59ea96ca": "Configuración de la skill de Orca CLI para Mobile Emulator", + "bff5341ac3": "Terminal de instalación de la skill de Orca CLI para Mobile Emulator", "3d34423e88": "Registrando la CLI de Orca", - "3be27641c9": "para que los comandos del emulador se puedan ejecutar desde shells de agents.", - "3941719a56": "Comprobando la CLI de Orca antes de abrir la configuración de la skill." + "3be27641c9": "para que los comandos del emulador se puedan ejecutar desde shells de agentes.", + "3941719a56": "Verificando la CLI de Orca antes de abrir la configuración de la skill." }, "MobileEmulatorTabIntroCallout": { "1924982130": "Descartar", - "5789936d9a": "Vista previa de simuladores iOS mientras los agents controlan la pantalla.", + "5789936d9a": "Vista previa de simuladores de iOS mientras los agentes controlan la pantalla.", "8014b4b80b": "Mantener", "6e051a40b7": "Ocultar" }, @@ -10855,29 +10938,29 @@ "emulator": { "hidden": { "toast": { - "e8f098a870": "Emulador Móvil oculto", - "c46c979c1d": "Vuelve a habilitar el Emulador Móvil en cualquier momento en", - "600f9a745a": "Ajustes › Emulador Móvil" + "e8f098a870": "Mobile Emulator oculto", + "c46c979c1d": "Vuelve a habilitar Mobile Emulator en cualquier momento en", + "600f9a745a": "Ajustes › Mobile Emulator" } } } }, "useEmulatorScreenKeyboard": { - "pasteTooLarge": "Paste is too large for emulator keyboard input.", - "unsupportedPasteText": "Emulator keyboard paste supports US keyboard text only.", - "pasteTargetUnavailable": "Emulator keyboard paste failed because the device is not ready." + "pasteTooLarge": "El texto pegado es demasiado grande para la entrada de teclado del emulador.", + "unsupportedPasteText": "Pegar en el teclado del emulador solo admite texto de teclado de EE. UU.", + "pasteTargetUnavailable": "No se pudo pegar en el teclado del emulador porque el dispositivo no está listo." } } }, "editor": { "ChangesModeView": { "ef25ae2d09": "No hay cambios no confirmados.", - "052c184f24": "La diferenciación de texto no está disponible para este archivo.", - "7dffb0f563": "archivo binario", - "54e0035b15": "Cargando diferencias..." + "052c184f24": "El diff de texto no está disponible para este archivo.", + "7dffb0f563": "Archivo binario", + "54e0035b15": "Cargando diff…" }, "CodeBlockCopyButton": { - "28921f5bf9": "copiado", + "28921f5bf9": "Copiado", "1f9f4def45": "Copiar código" }, "CombinedDiffFileTree": { @@ -10885,87 +10968,87 @@ "eafe1aeb53": "Restablecer filtros", "be119cb9d1": "Archivos vistos", "c00020f081": "Extensiones de archivo", - "cd0e0ed79e": "Filtrar archivos de diferencias", + "cd0e0ed79e": "Filtrar archivos del diff", "4cc7b83ffe": "Filtrar archivos...", "21783df79f": "Contraer árbol de archivos", "481e63ca52": "Archivos", - "d5ac717d65": "no comprometido", - "39b6b9e4e4": "Committed on Branch" + "d5ac717d65": "sin commit", + "39b6b9e4e4": "Commits en la rama" }, "CombinedDiffViewer": { - "35cc27aeb2": "en control de fuente", + "35cc27aeb2": "en Control de código fuente", "e3b9a6ce02": "más", "1da745c551": "Enviado", - "84898c548d": "Claro", + "84898c548d": "Borrar ", "88b70d0ef5": "Copiar", - "bb84b4c374": "notas de IA", + "bb84b4c374": "Notas de IA", "948a5fd6c8": "Borrar notas", "0f806a2ab1": "Cancelar", - "80a286d8f5": "de este árbol de trabajo?", + "80a286d8f5": "de este worktree?", "7e7ca60816": "archivos cambiados", "b6c3b84476": "Mostrar árbol de archivos", "39f8007549": "Revisar conflictos", - "39e73e7181": "fueron excluidos de esta vista diferente.", + "39e73e7181": "se excluyeron de esta vista de diff.", "689b99f8ad": "conflicto no resuelto", "820ec01f24": "Los archivos en conflicto se revisan por separado", "fd8892b120": "No hay cambios para mostrar", - "eb5f40e49c": "Esta vista de diferencias excluye los conflictos no resueltos porque la canalización de diferencias bidireccional normal no es segura para conflictos.", + "eb5f40e49c": "Esta vista de diff excluye los conflictos sin resolver porque el pipeline normal de diff de dos vías no es seguro para conflictos.", "45cf23b418": "No se pudieron borrar las notas.", "0fb870a0fe": "notas", "8ab3248fd8": "nota", - "ec5053c7f5": "Juntos", + "ec5053c7f5": "Lado a lado", "f786fd54e1": "En línea", "ea08dae15b": "Contraer todo", "19c45cfdc0": "Expandir todo", - "982d14bfa5": "Abrir diferencia no confirmada", - "3d909843bb": "Diferencia de rama abierta", + "982d14bfa5": "Abrir todos los cambios", + "3d909843bb": "Abrir diff de rama", "8368d256ec": "rama combinada", "8f68ad9ca9": "Mostrar {{value0}} AI {{value1}}", "724a13568d": "en {{value0}}", "6094135eec": "frente a {{value0}}", - "a4420ca1f7": "Envolver", - "dde325ddfe": "Envolver" + "a4420ca1f7": "Ajuste activado", + "dde325ddfe": "Ajuste desactivado" }, "ConflictComponents": { - "f338288514": "Cargando contenidos conflictivos...", - "90d576adb2": "Refrescar", - "a1ce36f77d": "Instantánea capturada en", + "f338288514": "Cargando contenido del conflicto…", + "90d576adb2": "Actualizar", + "a1ce36f77d": "Instantánea capturada a las ", "4be41eaafc": "conflicto no resuelto", "c8ca989aea": "Mostrar árbol de archivos", - "58ad5ad431": "Despedir", - "28e7db4a90": "Control de fuente", + "58ad5ad431": "Descartar", + "28e7db4a90": "Control de código fuente", "31931dec46": "Esta instantánea de revisión ya no tiene conflictos activos sin resolver.", "992145ff5a": "Todos los conflictos resueltos", - "d5edd81755": "Renombrado de", - "6e459867ad": "Estado de continuidad local de sesión. Git ya no informa que este archivo no está fusionado.", - "9c2901ef8a": "Próximo conflicto", + "d5edd81755": "Renombrado desde ", + "6e459867ad": "Estado de continuidad local de la sesión. Git ya no informa que este archivo está sin merge.", + "9c2901ef8a": "Siguiente conflicto", "41d9af2e7a": "Conflicto anterior", "55d61a0ccd": "conflicto ·", - "da539359b6": "No hay ningún archivo de árbol de trabajo disponible para editar para este conflicto." + "da539359b6": "No hay ningún archivo del worktree disponible para editar este conflicto." }, "ConflictReviewFileTree": { "3449521a8c": "No hay conflictos en esta instantánea.", "a54551c5a6": "Contraer árbol de archivos", "99496bab6e": "Archivos", - "496e28a932": "Desaparecido", + "496e28a932": "No existe", "8528a5eaf5": "Resuelto", - "69d4e210bb": "Irresoluto" + "69d4e210bb": "Sin resolver" }, "CsvViewer": { "eedd0d37a7": "columnas", "ac31d2cd60": "filas", - "a233d55b77": "archivo vacio" + "a233d55b77": "Archivo vacío" }, "DiffNotesSendMenu": { - "f1aa04b5cf": "este archivo", + "f1aa04b5cf": "Este archivo", "8b87612461": "Todas las notas no enviadas" }, "DiffSectionBody": { "35d6afb5be": "Archivo binario cambiado", - "cef4cf0ff5": "Rever", - "f5cf81cec2": "Cargando diferencias...", - "72f71f52eb": "La diferenciación de texto no está disponible para este archivo.", - "7ce8436458": "La diferencia de texto no está disponible para este archivo en la comparación de ramas.", + "cef4cf0ff5": "Reintentar", + "f5cf81cec2": "Cargando diff…", + "72f71f52eb": "El diff de texto no está disponible para este archivo.", + "7ce8436458": "El diff de texto no está disponible para este archivo en la comparación de ramas.", "bdbf02d5df": "binario", "b5675b0694": "Guardar", "593f2193f6": "Este borrador superó el límite seguro de visualización, pero aún se puede guardar." @@ -10975,58 +11058,58 @@ }, "EditorContent": { "56dba34e1a": "(editar en modo fuente)", - "e4b074749d": "Asunto frontal", - "9640d1d3db": "Vista previa de la versión modificada de esta diferencia. Cambie al modo fuente para inspeccionar los cambios.", + "e4b074749d": "Front matter", + "9640d1d3db": "Vista previa de la versión modificada de este diff. Cambia al modo de código fuente para inspeccionar los cambios.", "78541e254e": "Archivo binario cambiado", - "c88c73a0d3": "Cargando diferencias...", - "b9de81ba52": "Archivo binario: no se puede mostrar", - "b2735221f5": "Cargando...", + "c88c73a0d3": "Cargando diff…", + "b9de81ba52": "Archivo binario — no se puede mostrar", + "b2735221f5": "Cargando…", "8608ce4cb1": "La vista previa de Markdown no está disponible para archivos binarios.", - "37a0e81fa6": "Cargando vista previa...", - "8b1a605bae": "Este archivo se encuentra en estado de conflicto, pero no hay ningún archivo de árbol de trabajo disponible para editar.", - "2a512bb46a": "Rever", + "37a0e81fa6": "Cargando vista previa…", + "8b1a605bae": "Este archivo está en estado de conflicto, pero no hay ningún archivo del worktree disponible para editar.", + "2a512bb46a": "Reintentar", "39f018b052": "No se puede cargar el archivo", - "8a0898ae4c": "La diferenciación de texto no está disponible para este archivo.", - "3c6e71df22": "La diferencia de texto no está disponible para este archivo en la comparación de ramas.", + "8a0898ae4c": "El diff de texto no está disponible para este archivo.", + "3c6e71df22": "El diff de texto no está disponible para este archivo en la comparación de ramas.", "d07e4b8553": "rama", "d16e037f40": "rico", - "6c4f1a8d2e": "Los detalles de la comprobación no están disponibles." + "6c4f1a8d2e": "Los detalles del check no están disponibles." }, "EditorPanelHeader": { "fb8331694e": "Abrir vista previa al lado", - "4157f3cbf3": "Abrir vista previa de Markdowns", + "4157f3cbf3": "Abrir vista previa de Markdown", "269ce4842b": "Copiar ruta relativa", "7c08a1f990": "Copiar ruta", - "84cdc0794b": "Rebautizar", + "84cdc0794b": "Renombrar", "1bb1e226ec": "Cambiar el nombre del archivo {{value0}}", "5447c4f68f": "Tabla de contenido", "146cb5473c": "La tabla de contenidos está disponible en modo enriquecido o de vista previa.", - "e836faacfa": "Cambiar a diferenciación lado a lado", - "94756f08ba": "Cambiar a diferencia en línea", - "c98ce191da": "Esta diferenciación no tiene ningún archivo del lado modificado para abrir", + "e836faacfa": "Cambiar a diff lado a lado", + "94756f08ba": "Cambiar a diff inline", + "c98ce191da": "Este diff no tiene ningún archivo del lado modificado para abrir", "9b80bbe1de": "Abrir pestaña de archivo", - "f0fd4174b5": "Abra la pestaña de archivo para utilizar la edición de markdowns enriquecida", + "f0fd4174b5": "Abre la pestaña de archivo para usar la edición enriquecida de Markdown", "a10d9b8337": "Abrir archivo" }, "EditorPanelMarkdownActionsMenu": { "3e0ce48c24": "Exportar como PDF", "561251019a": "Más acciones", - "8c8b7f5ff5": "Mostrar portada", - "10c39d58c1": "Ocultar materia preliminar", - "1eef809708": "Ajuste de palabras" + "8c8b7f5ff5": "Mostrar front matter", + "10c39d58c1": "Ocultar front matter", + "1eef809708": "Ajuste de línea" }, "EditorPanelShell": { - "e2c4dec350": "Cargando editor..." + "e2c4dec350": "Cargando editor…" }, "EditorViewToggle": { - "b3410cd5e0": "Computadora portátil", - "e408aa9cd5": "Mesa", + "b3410cd5e0": "Notebook", + "e408aa9cd5": "Tabla", "167f45888c": "Cambios no confirmados", "4837f3f578": "Cambios", "ac3bb87913": "Editar", - "0d193dc03c": "Avance", - "aff15f94f5": "Editor rico", - "4d6ccb7ba6": "Fuente" + "0d193dc03c": "Vista previa", + "aff15f94f5": "Editor enriquecido", + "4d6ccb7ba6": "Código fuente" }, "ImageDiffViewer": { "a651be62b0": "Modificado", @@ -11034,38 +11117,38 @@ "fb0ae4f3c0": "Sin vista previa" }, "ImageViewer": { - "3c9217f5a6": "Dar un golpe de zoom", + "3c9217f5a6": "Acercar", "6c89c73d9f": "Restablecer zoom", - "be27304574": "alejar", + "be27304574": "Alejar", "77bfc9b35a": "Abrir imagen en ventana emergente", "3ef9551ba2": "Cargando vista previa...", "d9d2944855": "No se pudo cargar la vista previa del archivo" }, "ImageViewerPopup": { "0ef78475e7": "Presione Esc para cerrar", - "535f4e2b56": "Cerca", + "535f4e2b56": "Cerrar", "9e27b2ecaf": "Vista previa de imagen a tamaño completo" }, "IpynbViewer": { "859bf9fc21": "Ejecutar celda", "7f0d7077c6": "Cancelar", - "10ed04a685": "Las celdas del cuaderno ejecutan Python local en esta máquina desde la carpeta del cuaderno. Ejecute únicamente celdas de archivos en los que confíe.", - "9e06ae5d36": "¿Ejecutar el código del cuaderno?", - "d6f37a640b": "cuaderno vacio", - "8c3b21369a": "formato nb", + "10ed04a685": "Las celdas del notebook ejecutan Python localmente en esta máquina desde la carpeta del notebook. Ejecuta solo celdas de archivos en los que confíes.", + "9e06ae5d36": "¿Ejecutar código del notebook?", + "d6f37a640b": "Notebook vacío", + "8c3b21369a": "nbformat", "329764e9fc": "BETA", - "15ec40a735": "guardar cuaderno", - "07e7d96612": "células", + "15ec40a735": "Guardar notebook", + "07e7d96612": "celdas", "c1601b23b2": "No se puede renderizar el cuaderno", - "66a3f7d330": "Salida HTML del cuaderno", + "66a3f7d330": "Salida HTML del notebook", "781abd6926": "Eliminar celda", - "b42f6a9547": "Insertar celda de markdowns a continuación", - "ffc1ac2699": "Insertar celda de markdowns arriba", - "b4208cad7e": "Inserte la celda de código a continuación", - "53b839b8a0": "Insertar celda de código arriba", + "b42f6a9547": "Insertar celda de Markdown debajo", + "ffc1ac2699": "Insertar celda de Markdown encima", + "b4208cad7e": "Insertar celda de código debajo", + "53b839b8a0": "Insertar celda de código encima", "27e064e2db": "Mover celda hacia abajo", "fd8ac707bc": "Mover celda hacia arriba", - "3e4cbf15ea": "Crudo", + "3e4cbf15ea": "Raw", "1833dbbc43": "Markdown", "7005960d73": "Código", "59b6cd874b": "código", @@ -11075,28 +11158,28 @@ "e4683f70c4": "Cancelar", "d737791433": "Agregar nota para la IA", "b1bfc04034": "Texto seleccionado", - "f37b98999e": "esta nota", - "2b2b31382c": "Asunto frontal", + "f37b98999e": "Esta nota", + "2b2b31382c": "Front matter", "bb629de58a": "Copiar notas para el agente", "322afab6ff": "Notas de revisión", "0f9969a159": "Saltar a la primera nota de revisión", "12052c639c": "Cerrar búsqueda", - "b42c41bd0d": "Próximo partido", - "1febd97f5c": "Partido anterior", - "ec77985138": "Buscar en vista previa de markdowns", + "b42c41bd0d": "Coincidencia siguiente", + "1febd97f5c": "Coincidencia anterior", + "ec77985138": "Buscar en vista previa de Markdown", "517aea303b": "Buscar en vista previa", "f961e94057": "Copiar nota para el agente", - "94b520a96a": "nota copiada", + "94b520a96a": "Nota copiada", "13f94d760c": "Agregar nota", "ddf087d12e": "Todas las notas no enviadas", - "d652c87c91": "Ahorro…", + "d652c87c91": "Guardando…", "c5dc92cfe3": "Sin resultados", "6c043947ae": "Archivo no encontrado: {{value0}}", "759463a221": "No se puede abrir el directorio: {{value0}}" }, "MarkdownTableOfContentsPanel": { "de3928b6e4": "Sin encabezados", - "bbe8369097": "Cerrar tabla de contenidos", + "bbe8369097": "Cerrar tabla de contenido", "4680a4b808": "Contraer a H{{value0}}", "a5daadd68b": "Expandir todo", "111e66b85d": "Contraer al nivel de título {{value0}}", @@ -11106,15 +11189,15 @@ "27d0a9c49a": "Tabla de contenido", "65b036a6c8": "Expandir {{value0}}", "97ad46f11f": "Contraer {{value0}}", - "8f4d2c1a9b": "Resize table of contents" + "8f4d2c1a9b": "Redimensionar tabla de contenido" }, "MarkdownTemplatePicker": { "22cd94426f": "sin título.md", "6e2e6c04ad": "Markdown en blanco", - "df667919ca": "No hay plantillas coincidentes.", + "df667919ca": "No hay plantillas que coincidan.", "22fd4890ad": "Buscar plantillas...", "7b458e0b7f": "Elija una plantilla de Markdown.", - "1829437fce": "Nueva Markdown" + "1829437fce": "Nuevo Markdown" }, "MermaidBlock": { "dcc132e691": "Error de diagrama:" @@ -11126,17 +11209,17 @@ }, "MonacoGutterContextMenu": { "7b57b1b468": "Copiar URL remota", - "2e0b1cdc05": "Copiar Rel. Camino a la línea", - "4eaa991bde": "Copiar ruta a línea" + "2e0b1cdc05": "Copiar ruta relativa a la línea", + "4eaa991bde": "Copiar ruta a la línea" }, "NotesSendMenu": { - "44dc5e60a6": "enviar notas", + "44dc5e60a6": "Enviar notas", "433928cd9f": "Enviar {{value0}} a un agente" }, "PdfFind": { - "cd65b1d6b0": "Cerca", - "eeba2547a1": "Próximo partido", - "30de726ad0": "Partido anterior", + "cd65b1d6b0": "Cerrar", + "eeba2547a1": "Coincidencia siguiente", + "30de726ad0": "Coincidencia anterior", "2fc3ba0ea8": "Buscar en la página...", "d080ab37d6": "No hay coincidencias", "db56fcd6d2": "{{value0}} de {{value1}}" @@ -11144,14 +11227,14 @@ "PdfViewer": { "3e98d500d2": "Vista previa de PDF", "069ff59932": "Buscar en PDF ({{value0}})", - "2b6eb1ccd6": "Dar un golpe de zoom", + "2b6eb1ccd6": "Acercar", "c0119616d6": "Ajustar al ancho", - "fa5d096b00": "alejar" + "fa5d096b00": "Alejar" }, "ReviewNotesSendMenuContent": { "a49800405b": "Nuevo agente", "e84705f223": "Sesión de agente activo", - "03378aea75": "enviar notas a", + "03378aea75": "Enviar notas a", "f5096c6e4e": "No se pudieron enviar notas al agente activo.", "bb9c69a0c9": "Notas enviadas al agente activo.", "50f7e753ea": "Enviando notas al agente activo..." @@ -11161,56 +11244,56 @@ "6f2f3a6001": "Agregar nota de revisión" }, "RichMarkdownCodeBlock": { - "232d9ed853": "copiado", + "232d9ed853": "Copiado", "c72beafc0f": "Copiar código", "74eab1d9b2": "YAML", "5ef5605cb7": "XML", - "88d777bc07": "Mecanografiado", - "9e384d48dc": "Rápido", + "88d777bc07": "TypeScript", + "9e384d48dc": "Swift", "3009f722b9": "SQL", - "d01f55be57": "Caparazón", + "d01f55be57": "Shell", "5af8251002": "SCSS", - "e72e6b03f4": "Óxido", - "96182a2f64": "Rubí", - "2391f9cda9": "Pitón", - "89d6cc14fb": "Sirena", + "e72e6b03f4": "Rust", + "96182a2f64": "Ruby", + "2391f9cda9": "Python", + "89d6cc14fb": "Mermaid", "983b9576b4": "Markdown", "bcb236e2d8": "Kotlin", "78eba32de4": "JSON", - "a209c57063": "javascript", + "a209c57063": "JavaScript", "36536ad539": "Java", "8c4a3fa02d": "HTML", - "706fd85738": "GrafoQL", - "edfcc64182": "Ir", - "bf6ee5caaa": "diferencia", + "706fd85738": "GraphQL", + "edfcc64182": "Go", + "bf6ee5caaa": "Diff", "026653f21f": "CSS", "4daed43ae3": "C++", - "4227cf50fe": "Intento", + "4227cf50fe": "Bash", "13822cdfda": "Texto sin formato" }, "RichMarkdownDocLinkMenu": { "e17b987473": "↑↓ navegar  ↵ seleccionar  esc descartar", "90c5f0e1e4": "de", - "2aaf7d9678": "Demostración", + "2aaf7d9678": "Mostrando", "63ced7cb9b": "No se encontraron documentos", - "0e8489bc11": "Enlaces de documentos de Markdowns", + "0e8489bc11": "Enlaces a documentos Markdown", "142a7d51cd": "documento" }, "RichMarkdownErrorBoundary": { - "aad0998127": "Rever", + "aad0998127": "Reintentar", "4a5de9f2f0": "Cambie al modo fuente o haga clic en Reintentar para recargar la vista enriquecida.", - "dfdf1cacd4": "El editor de markdowns enriquecido sufrió un error inesperado y se restableció para que el resto de Orca siguiera respondiendo." + "dfdf1cacd4": "El editor Markdown enriquecido encontró un error inesperado y se restableció para que el resto de Orca siguiera respondiendo." }, "RichMarkdownLinkBubble": { "1c99b726e0": "Quitar enlace", "cdfe166f6f": "Editar enlace", "bfc813e909": "Abrir enlace", - "7b0b945fdc": "Pegue o escriba un enlace..." + "7b0b945fdc": "Pega o escribe un enlace…" }, "RichMarkdownReviewNoteLayer": { - "f3ef92952b": "esta nota", + "f3ef92952b": "Esta nota", "9cde7ad994": "Copiar nota para el agente", - "117432e2c6": "nota copiada", + "117432e2c6": "Nota copiada", "3ababd949d": "Notas de revisión" }, "RichMarkdownReviewRailActions": { @@ -11262,7 +11345,7 @@ "d1bbf9a835": "Sección plegable" }, "UntitledFileRenameDialog": { - "a7dd27b0bc": "Ahorrar", + "a7dd27b0bc": "Guardar", "949711deb4": "Cancelar", "725868c75d": "Explorar carpetas", "5e7f0d8a80": "Selector de carpetas no disponible para archivos remotos", @@ -11351,7 +11434,7 @@ }, "useLocalImagePick": { "175cb8b8ce": "No se pudo insertar la imagen.", - "91d835dc88": "Ruta del árbol de trabajo no disponible." + "91d835dc88": "Ruta del worktree no disponible." }, "useRichMarkdownReviewData": { "f9d2acd6b0": "Todas las notas no enviadas" @@ -11374,26 +11457,26 @@ "593f2193f6": "Este borrador superó el límite seguro de visualización, pero aún se puede guardar." }, "CheckRunDetailsPanel": { - "8f2d0f5a91": "Passed", - "4c8e1b2d73": "Failed", + "8f2d0f5a91": "Aprobado", + "4c8e1b2d73": "Falló", "91a4c7e2b0": "Cancelado", "2f6d8a1c45": "Tiempo agotado", "7b3e9d4f12": "Omitido", "5a1c8e3d67": "Neutral", - "3d9f2b8e14": "Pending", - "b7f5e2c91a": "Refresh", + "3d9f2b8e14": "Pendiente", + "b7f5e2c91a": "Actualizar", "a54ae21c6f": "Estado:", "fd46a70f1a": "Iniciado", - "00e1c1658a": "Completed", + "00e1c1658a": "Completado", "aa8494ae3c": "check #", "2dd5ddabc4": "workflow #", "1f2b980522": "Cargando detalles de la comprobación…", "d098e5529a": "Salida", "f2fe8a4e8f": "Anotaciones", - "cdbfda4dec": "Annotation", + "cdbfda4dec": "Anotación", "066fedd446": "Tareas fallidas", "49731703ea": "Tareas", - "ee07b33924": "unknown", + "ee07b33924": "desconocido", "07eccfa397": "No hay detalles disponibles para esta comprobación.", "a916648574": "Abrir detalles", "834cb3f23d": "Corregir con IA", @@ -11411,11 +11494,11 @@ "fix": { "with": { "ai": { - "1a8c4e2b90": "Select a workspace before launching an AI action.", - "4f2d9a8c17": "Select a repository before launching an AI action.", - "7c3e1b5d42": "Open a PR or MR before launching an AI fix.", - "9b2f6d4a81": "This check is not failing.", - "2ef90c9819": "Started an AI agent for this check." + "1a8c4e2b90": "Selecciona un workspace antes de lanzar una acción de IA.", + "4f2d9a8c17": "Selecciona un repositorio antes de lanzar una acción de IA.", + "7c3e1b5d42": "Abre un PR o MR antes de lanzar un fix con IA.", + "9b2f6d4a81": "Este check no está fallando.", + "2ef90c9819": "Se inició un agente de IA para este check." } } } @@ -11436,8 +11519,8 @@ "diff": { "comments": { "DiffCommentCard": { - "109a791e7b": "Ahorrar", - "bb0a55f856": "Ahorro…", + "109a791e7b": "Guardar", + "bb0a55f856": "Guardando…", "0203bed775": "Cancelar", "6978871a3d": "Abierto", "cce596969e": "Eliminar nota", @@ -11491,16 +11574,16 @@ "88fea8e84e": "No enviar", "50b00dc327": "Copiar detalles", "6d3ebe216a": "Texto de diagnóstico", - "835037edc9": "· Orcas", + "835037edc9": "· Orca", "56a3dfa283": "No se pudo enviar el informe de fallas.", "8e24fe4f75": "Informe de fallo enviado.", "8b8473c544": "Se copió el informe de fallos.", "b175e90213": "No hay ningún informe de fallo disponible.", "765591798d": "Comprobando informes de fallos...", - "b2e36f53a1": "Failed to send crash report. Diagnostic ticket {{value0}} was uploaded but not linked.", - "ead6fc0510": "No automatic crash report was captured. You can still send details and include recent diagnostic logs when available.", - "b082f27490": "Attach recent diagnostic logs", - "e59f0b9427": "Sends a capped redacted log bundle with the report." + "b2e36f53a1": "No se pudo enviar el informe de fallo. Se subió el ticket de diagnóstico {{value0}}, pero no se vinculó.", + "ead6fc0510": "No se capturó ningún informe de fallo automático. Aun así puedes enviar detalles e incluir logs de diagnóstico recientes cuando estén disponibles.", + "b082f27490": "Adjuntar logs de diagnóstico recientes", + "e59f0b9427": "Envía un paquete limitado de logs redactados junto con el informe." } } }, @@ -11536,39 +11619,39 @@ "j": { "quick": { "actions": { - "c884a6398e": "Cree un comando de terminal guardado.", + "c884a6398e": "Crea un comando de terminal guardado.", "a43ab56fc1": "Agregar comando rápido", - "54853d52a2": "Eliminar el árbol de trabajo actual.", - "9537b910fe": "Eliminar árbol de trabajo", - "0b1f25f796": "Inicie un nuevo árbol de trabajo.", - "52ac9da671": "Crear árbol de trabajo", - "f70812764a": "Abra una pestaña de terminal en el espacio de trabajo activo.", + "54853d52a2": "Elimina el worktree actual.", + "9537b910fe": "Eliminar worktree", + "0b1f25f796": "Inicia un nuevo worktree.", + "52ac9da671": "Crear worktree", + "f70812764a": "Abre una pestaña de terminal en el workspace activo.", "34980395d4": "Nueva pestaña de terminal", - "f2a1b33f8d": "Cree un archivo de markdowns sin título en el espacio de trabajo activo.", - "25349b66fc": "Nuevo archivo de Markdowns", - "784812ca24": "Abra una pestaña del navegador en el espacio de trabajo activo.", + "f2a1b33f8d": "Crea un archivo Markdown sin título en el workspace activo.", + "25349b66fc": "Nuevo archivo Markdown", + "784812ca24": "Abre una pestaña del navegador en el workspace activo.", "892bfa9339": "Nueva pestaña del navegador", "verbs": { "newBrowser": "nuevo navegador", "newBrowserTab": "nueva pestaña del navegador", "openBrowser": "abrir el navegador", "browserTab": "pestaña del navegador", - "newMarkdown": "nueva markdown", - "newMarkdownFile": "nuevo archivo de markdowns", - "newMark": "nueva marca", + "newMarkdown": "nuevo markdown", + "newMarkdownFile": "nuevo archivo markdown", + "newMark": "nuevo markdown", "newFile": "nuevo archivo", - "markdownFile": "archivo de markdowns", + "markdownFile": "archivo markdown", "newTerminal": "nueva terminal", "newTerminalTab": "nueva pestaña de terminal", - "newShell": "nuevo caparazón", - "terminalTab": "pestaña terminal", - "createWorktree": "crear árbol de trabajo", - "addWorktree": "agregar árbol de trabajo", - "newWorktree": "nuevo árbol de trabajo", - "deleteWorktree": "eliminar árbol de trabajo", - "deleteCurrentWorktree": "eliminar el árbol de trabajo actual", - "removeWorktree": "eliminar árbol de trabajo", - "trashWorktree": "árbol de trabajo de basura", + "newShell": "nuevo shell", + "terminalTab": "pestaña de terminal", + "createWorktree": "crear worktree", + "addWorktree": "agregar worktree", + "newWorktree": "nuevo worktree", + "deleteWorktree": "eliminar worktree", + "deleteCurrentWorktree": "eliminar el worktree actual", + "removeWorktree": "quitar worktree", + "trashWorktree": "mandar worktree a la papelera", "addQuickCommand": "agregar comando rápido", "newQuickCommand": "nuevo comando rápido" } @@ -11630,13 +11713,13 @@ "93be92f8d1": "Copiar dirección", "3c085f638d": "Copiar la URL de la página fallida", "c6be71329e": "Refrescar", - "781d6459ad": "Rever", - "2fdca7df09": "Despedir", - "8b6fab9ffa": "Ahorrar", + "781d6459ad": "Reintentar", + "2fdca7df09": "Descartar", + "8b6fab9ffa": "Guardar", "0f41bf80c7": "Abrir en el navegador predeterminado", - "ec75d0c412": "Abrir herramientas de desarrollo del navegador", - "fc9be38f6f": "Anotar elemento de página", - "fdfc7fe0ef": "Tomar elemento de página", + "ec75d0c412": "Abrir DevTools del navegador", + "fc9be38f6f": "Anotar elemento de la página", + "fdfc7fe0ef": "Capturar elemento de la página", "a8f37f70c3": "Inspeccionar página", "1b179ab561": "Copiar URL de página", "f7ab83f7ed": "Abrir página en el navegador predeterminado", @@ -11645,36 +11728,36 @@ "40edfa75cb": "Atrás", "efb0e8f7f3": "Copiar dirección de enlace", "8ce4f6b12e": "Abrir enlace en el navegador predeterminado", - "b5b87d6cbb": "Abrir enlace en el navegador Orca", + "b5b87d6cbb": "Abrir enlace en el navegador de Orca", "87eb75f7d2": "Introduzca una URL http(s) o localhost válida.", "27d863542c": "Anotaciones del navegador", "e48569ac6d": "No se pudo acceder a este sitio.", "bbe8f15e83": "Este panel se representa desde el servidor de ejecución activo.", "8b7e6d1f5a": "Las anotaciones del navegador solo están disponibles en las pestañas del navegador local.", - "deb5293610": "Las anotaciones del navegador no están disponibles en el tiempo de ejecución remoto", + "deb5293610": "Anotaciones del navegador no disponibles en runtime remoto", "90d021f2ad": "Agregar", "0cb3bd6221": "Intención de anotación", "8f87e6c2e5": "Intención", "532bac48c5": "Describe aquí lo que el agente debería cambiar...", - "d2a7092e6e": "comentario de anotación", + "d2a7092e6e": "Comentario de anotación", "b472c5fe03": "Agregar anotación del navegador", "b5ba6085de": "Pregunta", "143204e423": "Cambiar", - "e7ca5a098c": "éxito", + "e7ca5a098c": "correcto", "d51ef37351": "Copiar", - "6f4ab3592b": "copiado", + "6f4ab3592b": "Copiado", "b2856516e2": "No se puede cargar esta página", - "db325a7eeb": "No puedo comunicarme con {{value0}}", + "db325a7eeb": "No se puede acceder a {{value0}}", "499b31b84e": "Copiar todo", "e72dfa268a": "anotar", - "168350ae6a": "Haga clic o coloque el cursor sobre un elemento, luego presione C para copiar o S para hacer una captura de pantalla.", - "e852e20cea": "Copiado: presione S para hacer una captura de pantalla o seleccione otro elemento", + "168350ae6a": "Haz clic o pon el cursor sobre un elemento, luego presiona C para copiar o S para hacer una captura de pantalla.", + "e852e20cea": "Copiado — presiona S para hacer una captura de pantalla o selecciona otro elemento", "a5dcd0fd1d": "confirmando", - "777b5bc4ec": "Haga clic en un elemento para agregar comentarios para el agente.", - "b733a91bd9": "Agregue comentarios para el elemento seleccionado.", - "4328a0a062": "Grabación fallida: {{value0}}", + "777b5bc4ec": "Haz clic en un elemento para agregar comentarios para el agente.", + "b733a91bd9": "Agrega comentarios para el elemento seleccionado.", + "4328a0a062": "Captura fallida: {{value0}}", "26615e116b": "error", - "8aec5bc044": "idle", + "8aec5bc044": "inactivo", "759f32af29": "Descargando", "c8bc7f1f9e": "solicitado", "4300f38145": "Descargando desde {{value0}}{{value1}}", @@ -11687,15 +11770,16 @@ "c13693fe27": "{{value0}} anotaciones", "074f0ed10b": "{{value0}} anotación lista. Seleccione otro elemento o copie todos los comentarios.", "a2164a6e5a": "{{value0}} anotaciones listas. Seleccione otro elemento o copie todos los comentarios.", - "9f6f2e8c19": "The downloaded file path is unavailable.", - "0c79b7634d": "Could not open the downloaded file. It may have been moved or deleted.", - "397d9dc923": "Could not show the downloaded file. It may have been moved or deleted.", - "39c04fed61": "Downloading paused", - "5c3d530a68": "Downloaded", - "4bb7424d6b": "Canceled", - "6e776f9ef9": "Download failed", + "9f6f2e8c19": "La ruta del archivo descargado no está disponible.", + "0c79b7634d": "No se pudo abrir el archivo descargado. Puede haberse movido o eliminado.", + "397d9dc923": "No se pudo mostrar el archivo descargado. Puede haberse movido o eliminado.", + "39c04fed61": "Descarga pausada", + "5c3d530a68": "Descargado", + "4bb7424d6b": "Cancelado", + "6e776f9ef9": "Falló la descarga", "756bfc25c9": "Abrir", - "09a9489aa5": "Show" + "09a9489aa5": "Mostrar", + "2a4c4b8e1f": "Copiar" }, "BrowserToolbarMenu": { "429ef481f9": "Cancelar", @@ -11786,20 +11870,20 @@ "AutomationEditorDialog": { "fb1896a5e7": "Cancelar", "57b722cbba": "Agente", - "6ff66f9012": "Nueva carrera", - "a2e688226d": "árbol de trabajo", - "6f9610e667": "Worktree se ejecuta en el espacio de trabajo seleccionado. La nueva ejecución crea un espacio de trabajo nuevo a partir de la rama seleccionada cada vez.", - "2c3fd9bfa1": "Ayuda del modo espacio de trabajo", - "b28b140eaf": "Espacio de trabajo", + "6ff66f9012": "Nueva ejecución", + "a2e688226d": "Worktree", + "6f9610e667": "Worktree se ejecuta en el workspace seleccionado. Nueva ejecución crea un workspace nuevo desde la rama seleccionada cada vez.", + "2c3fd9bfa1": "Ayuda del modo workspace", + "b28b140eaf": "Workspace", "0d17f4ca8f": "Seleccionar proyecto", "02d351877e": "Proyecto", - "a4ac8fcc62": "/meta", - "827b25a81e": "Admite habilidades, rutas de archivos y comandos integrados como", + "a4ac8fcc62": "/goal", + "827b25a81e": "Admite skills, rutas de archivos y comandos integrados como", "6d778190b7": "Ejecute la auditoría de dependencia semanal y resuma los cambios riesgosos.", - "058c23cb3f": "Inmediato", - "c4b19094c2": "Cronograma", + "058c23cb3f": "Prompt", + "c4b19094c2": "Programación", "e46c1aa9ad": "Crear", - "a9d9dccf77": "Ahorrar", + "a9d9dccf77": "Guardar", "777548c2d6": "Guardar cambios", "ff5db28639": "existente" }, @@ -11808,7 +11892,7 @@ "7e35393632": "Hermes", "6f309eef8d": "Orca", "58f56b73d9": "Nombre de la automatización", - "1d9826933e": "Auditoría de repos entre semana", + "1d9826933e": "Auditoría de repositorios entre semana", "4133d33862": "Crear automatización", "0a75e5e2fa": "Crear automatización Hermes", "03142e7721": "Editar la automatización de Hermes", @@ -11937,18 +12021,18 @@ "7b2e285552": "Las conexiones SSH no están disponibles en este cliente.", "d441032f7e": "pausa", "5918020edc": "correr", - "a21f6c33ad": "Automation source refreshed.", - "53f06f0ad5": "Retry source", + "a21f6c33ad": "Origen de automatización actualizado.", + "53f06f0ad5": "Reintentar origen", "pendingAutomationMissing": "La automatización ya no está disponible.", "pendingAutomationRunMissing": "El historial de ejecuciones ya no está disponible." }, "CreateFromPicker": { - "f061f49e3f": "Buscar sucursales de repo...", - "dd3841b442": "Derivarse de", + "f061f49e3f": "Buscar ramas del repositorio...", + "dd3841b442": "Crear desde", "ef6d762538": "Valor predeterminado del proyecto", "e53d306056": "{{value0}} (predeterminado)", - "79512f22a7": "No se encontraron sucursales.", - "9ce96621f4": "Buscando sucursales..." + "79512f22a7": "No se encontraron ramas.", + "9ce96621f4": "Buscando ramas..." }, "ExternalAutomationManagers": { "e02f970595": "No se encontraron administradores de automatización externos.", @@ -12010,10 +12094,10 @@ "513401db93": "Prepare un resumen semanal del riesgo de liberación del estado actual del proyecto.", "39ed39280a": "Preparación para el lanzamiento", "a7fbd32ddb": "Verifique las dependencias, las pruebas fallidas y los cambios abiertos riesgosos todos los días de la semana.", - "b84757677d": "Auditoría de repos entre semana", + "b84757677d": "Auditoría de repositorios entre semana", "repoHealth": { - "category": "Salud del Repo", - "name": "Auditoría de repos entre semana", + "category": "Salud del Repositorio", + "name": "Auditoría de repositorios entre semana", "prompt": "Revisar el estado del repositorio. Verifique las actualizaciones de dependencia, las pruebas fallidas, el estado de pelusa/verificación de tipos y los cambios abiertos riesgosos. Resuma los hallazgos y sugiera la siguiente acción." }, "releasePrep": { @@ -12043,16 +12127,16 @@ } }, "AutomationProjectCombobox": { - "search": "Search projects/folders...", - "empty": "No projects/folders match your search.", - "chooseHost": "Choose automation host", - "adding": "Adding project…", - "addProject": "Add project" + "search": "Buscar proyectos/carpetas...", + "empty": "Ningún proyecto/carpeta coincide con tu búsqueda.", + "chooseHost": "Elegir host de automatización", + "adding": "Agregando proyecto…", + "addProject": "Agregar proyecto" }, "AutomationSetupDecisionField": { "5a7863909c": "Ejecutar la configuración para cada espacio de trabajo nuevo", - "18f000ad4e": "Advanced", - "874b72195b": "When this automation creates a workspace, prepare it the same way creating a worktree by hand does — run the project's setup and open its terminal tabs." + "18f000ad4e": "Avanzado", + "874b72195b": "Cuando esta automatización crea un workspace, lo prepara igual que al crear un worktree manualmente: ejecuta la configuración del proyecto y abre sus pestañas de terminal." } }, "agent": { @@ -12071,7 +12155,7 @@ }, "activity": { "ActivityPrototypePage": { - "cf780197a1": "Seleccione un agente para ver su actividad", + "cf780197a1": "Selecciona un agente para ver su actividad", "e3db9892f6": "Aún no hay actividad.", "1b633f5c1e": "Terminal de conexión...", "8de7c5beaa": "Terminal no disponible", @@ -12085,22 +12169,22 @@ "db8a1878b5": "Opciones de lista de hilos", "d1a88df9a8": "Mostrar solo hilos no leídos", "f6396e1f85": "Agente", - "b29191b3e0": "árbol de trabajo", + "b29191b3e0": "worktree", "8c3b621ddf": "Proyecto", "4a3986b200": "Estado", - "770d458144": "Actividad del agente de grupo por", + "770d458144": "Agrupar actividad del agente por", "795cbf26e2": "Filtrar...", - "4616ea39fd": "Saltar al espacio de trabajo", + "4616ea39fd": "Ir al workspace", "59b131fbd9": "Marcar hilo como no leído", "beb2c19173": "No leído", "5651b216c6": "Proyecto desconocido", "22b22034bc": "Terminal independiente no disponible en Actividad.", - "afdc2139a8": "Terminal de Agent cerrada. Abra una nueva terminal en este espacio de trabajo para continuar." + "afdc2139a8": "Terminal de Agent cerrada. Abre una nueva terminal en este workspace para continuar." }, "ActivityTitlebarControls": { "f915168c8e": "no leído", "d6a8de3934": "agentes", - "dc708f3eff": "Agentes cercanos" + "dc708f3eff": "Agents cercanos" } }, "confirmation": { @@ -12113,45 +12197,45 @@ "connect": { "dialog": { "63ce735809": "Conectar", - "4a2ab52781": "Verifying…", + "4a2ab52781": "Verificando…", "79e7aaed39": "Cancelar", - "fdd26d81cc": "Atlassian account settings", - "8090504a3e": "Create a token in", - "7b3967c12f": "Atlassian API token", + "fdd26d81cc": "Ajustes de cuenta de Atlassian", + "8090504a3e": "Crea un token en", + "7b3967c12f": "Token API de Atlassian", "3d81bf3ab3": "API token", "e91b9a4073": "you@example.com", - "2849ddb295": "Atlassian email", + "2849ddb295": "Email de Atlassian", "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" + "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" } } }, "rightSidebar": { "FolderWorkspaceWorktreesPanel": { - "unavailable": "Workspaces are only shown for folder workspaces.", + "unavailable": "Los workspaces solo se muestran para workspaces de carpeta.", "label": "Workspaces", - "description": "Shows worktrees attached to this folder workspace.", - "countOne": "1 attached worktree", - "countMany": "{{value0}} attached worktrees", - "emptyTitle": "No attached worktrees yet", - "emptyCopy": "Worktrees created from this workspace will show up here." + "description": "Muestra worktrees adjuntos a este workspace de carpeta.", + "countOne": "1 worktree adjunto", + "countMany": "{{value0}} worktrees adjuntos", + "emptyTitle": "Aún no hay worktrees adjuntos", + "emptyCopy": "Los worktrees creados desde este workspace aparecerán aquí." }, "FolderWorkspacePrChecksPanel": { "unavailable": "Las comprobaciones de PR solo se muestran en espacios de trabajo de carpeta.", "refresh": "Actualizar comprobaciones de PR", - "emptyTitle": "Aún no hay árboles de trabajo adjuntos", - "emptyCopy": "Las comprobaciones de PR aparecerán aquí cuando se adjunten árboles de trabajo a este espacio de trabajo de carpeta.", - "openChecksTab": "Abrir la pestaña Comprobaciones de {{value0}}", + "emptyTitle": "Aún no hay worktrees adjuntos", + "emptyCopy": "Los checks de PR aparecerán aquí cuando se adjunten worktrees a este workspace de carpeta.", + "openChecksTab": "Abrir pestaña Checks de {{value0}}", "openReviewExternally": "Abrir {{value0}} externamente", "summary": "{{value0}} adjuntos · {{value1}} con PR/MR · {{value2}} requieren atención · {{value3}} pendientes · {{value4}} correctos · {{value5}} sin PR · {{value6}} desconocidos", - "showDetails": "Mostrar detalles de comprobaciones de PR de {{value0}}", - "hideDetails": "Ocultar detalles de comprobaciones de PR de {{value0}}", - "reviewChecks": "Comprobaciones de revisión", - "allChecksPassing": "todas las comprobaciones correctas", - "oneWorktree": "1 árbol de trabajo", - "worktreeCount": "{{value0}} árboles de trabajo", + "showDetails": "Mostrar detalles de checks de PR de {{value0}}", + "hideDetails": "Ocultar detalles de checks de PR de {{value0}}", + "reviewChecks": "Checks de revisión", + "allChecksPassing": "todos los checks correctos", + "oneWorktree": "1 worktree", + "worktreeCount": "{{value0}} worktrees", "oneFailing": "1 fallando", "failingCount": "{{value0}} fallando", "onePending": "1 pendiente", @@ -12161,21 +12245,21 @@ "rowSummary": { "failingCount": "{{value0}} fallando", "pendingCount": "{{value0}} pendientes", - "checksFailing": "Comprobaciones fallando", - "mergeConflicts": "Conflictos de fusión", - "checksPending": "Comprobaciones pendientes", - "checksPassing": "Comprobaciones correctas", - "merged": "Fusionado", - "closedWithoutMerge": "Cerrado sin fusionar", + "checksFailing": "Checks fallando", + "mergeConflicts": "Conflictos de merge", + "checksPending": "Checks pendientes", + "checksPassing": "Checks correctos", + "merged": "Merged", + "closedWithoutMerge": "Cerrado sin merge", "draftReview": "Revisión en borrador", - "noCheckSignal": "Sin señal de comprobaciones", + "noCheckSignal": "Sin señal de checks", "reviewUnavailable": "Estado de revisión no disponible", "noPrLinked": "Sin PR vinculado", "detailsUnavailable": "Detalles de revisión no disponibles", "refreshFailed": "Error al actualizar", "checking": "Comprobando estado de revisión…", "notFetched": "Estado aún no obtenido", - "unavailableWorktree": "No disponible para este árbol de trabajo" + "unavailableWorktree": "No disponible para este worktree" }, "groups": { "needsAttention": "Requiere atención", @@ -12230,49 +12314,49 @@ "project": { "source": { "combobox": { - "noProjects": "No projects", - "allProjects": "All projects", + "noProjects": "Sin proyectos", + "allProjects": "Todos los proyectos", "hostCount": "{{value0}} hosts", - "searchProjects": "Search projects...", - "noMatches": "No projects match your search.", + "searchProjects": "Buscar proyectos...", + "noMatches": "Ningún proyecto coincide con tu búsqueda.", "chooseSource": "Elige el origen de tareas" } } } }, "taskPageEmptyState": { - "noProjectSourcesTitle": "No project sources selected", - "noProjectSourcesDescription": "Select at least one project source so Orca knows which host/account to fetch tasks from.", - "noMatchingGitHubWorkTitle": "No matching GitHub work", - "changeQueryDescription": "Change the query or clear it.", - "noGitLabIssuesTitle": "No GitLab issues", - "noGitLabIssuesDescription": "No GitLab issues match this filter.", - "noGitLabMrsTitle": "No GitLab merge requests", - "noGitLabMrsDescription": "No GitLab MRs match this filter.", - "noGitLabWorkTitle": "No GitLab work", - "noGitLabWorkDescription": "No GitLab work matches this filter." + "noProjectSourcesTitle": "No hay orígenes de proyecto seleccionados", + "noProjectSourcesDescription": "Selecciona al menos un origen de proyecto para que Orca sepa desde qué host/cuenta obtener tareas.", + "noMatchingGitHubWorkTitle": "No hay trabajo de GitHub coincidente", + "changeQueryDescription": "Cambia la consulta o bórrala.", + "noGitLabIssuesTitle": "No hay issues de GitLab", + "noGitLabIssuesDescription": "Ningún issue de GitLab coincide con este filtro.", + "noGitLabMrsTitle": "No hay merge requests de GitLab", + "noGitLabMrsDescription": "Ningún MR de GitLab coincide con este filtro.", + "noGitLabWorkTitle": "No hay trabajo de GitLab", + "noGitLabWorkDescription": "Ningún trabajo de GitLab coincide con este filtro." }, "taskSourceContextSummary": { - "sourceUnavailable": "{{value0}} source unavailable: {{value1}}", - "someSourceHostsUnavailable": "Some {{value0}} source hosts unavailable: {{value1}}", - "reconnectOrUpdateTitle": "Reconnect or update {{value0}} to load this source." + "sourceUnavailable": "Origen {{value0}} no disponible: {{value1}}", + "someSourceHostsUnavailable": "Algunos hosts de origen de {{value0}} no están disponibles: {{value1}}", + "reconnectOrUpdateTitle": "Reconecta o actualiza {{value0}} para cargar este origen." }, "ShortcutKeyCombo": { - "07eb4985a1": "Double-tap {{value0}}" + "07eb4985a1": "Doble toque en {{value0}}" }, "star": { "nag": { "StarNagToastHost": { - "starredThanks": "Starred — thank you!", - "githubOpened": "GitHub opened", - "opening": "Opening…", - "starring": "Starring…", - "openGithub": "Open GitHub", - "starOnGithub": "Star on GitHub", - "onboardingCompleted": "Onboarding completed!", - "body": "If you’re enjoying Orca so far, a GitHub star helps other developers discover it.", + "starredThanks": "Star enviada — ¡gracias!", + "githubOpened": "GitHub abierto", + "opening": "Abriendo…", + "starring": "Enviando star…", + "openGithub": "Abrir GitHub", + "starOnGithub": "Dar star en GitHub", + "onboardingCompleted": "¡Onboarding completado!", + "body": "Si Orca te está gustando, una star en GitHub ayuda a que otros desarrolladores lo descubran.", "dismiss": "Descartar", - "later": "Later" + "later": "Más tarde" } } }, @@ -12286,7 +12370,7 @@ "hostedReview": { "copy": { "f0a4b8c2d1": "PR", - "e9f3a7b1c0": "solicitud de extracción", + "e9f3a7b1c0": "pull request", "d8e2f6a0b9": "Solicitud de extracción", "c7d1e5f9a8": "GitHub", "c4e8f1a2b9": "SEÑOR", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 77bb1e33f49..eb9a5b68647 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -25,6 +25,7 @@ "geminiToggleDescription": "アクティブなワークスペースの Gemini トークンとコストの使用状況を表示します。", "opencodeGoToggleDescription": "アクティブなワークスペースの OpenCode Go トークンと使用コストを表示します。", "kimiToggleDescription": "Kimi サブスクリプション", + "minimaxToggleDescription": "アクティブなワークスペースの MiniMax サブスクリプション使用状況を表示します。", "sshToggleDescription": "設定済み SSH ホストとリモート Orca ホストがある場合に表示します。", "resourceUsageToggleDescription": "リソースマネージャーを表示します。これをクリックすると、CPU、メモリ、セッション、デーモン コントロール、およびワークスペース ディスク スキャンが行われます。", "portsToggleDescription": "ライブワークスペースポートを表示します。ワークスペーススコープのポートと外部リスナーの場合はこれをクリックします。" @@ -57,7 +58,7 @@ "openWorktreePalette": "ワークツリーパレットを開く", "window": "ウィンドウ", "help": "ヘルプ", - "paste": "Paste" + "paste": "貼り付け" }, "tray": { "openOrca": "Orcaを開く", @@ -652,7 +653,7 @@ "devDescription": "Dev-only tools for exercising UI states.", "devBadge": "Dev", "devSearchNotificationPlayground": "Notification playground", - "devSearchNotificationPlaygroundDescription": "Trigger representative toast and notification UI states.", + "devSearchNotificationPlaygroundDescription": "代表的なトーストと通知の UI 状態をトリガーします。", "devSearchKeywordDev": "dev", "devSearchKeywordToast": "toast", "devSearchKeywordSonner": "sonner", @@ -876,7 +877,7 @@ "moved": "moved this", "from": "from", "to": "to", - "activity": "Activity", + "activity": "アクティビティ", "noActivity": "No activity yet." }, "checkActionRequiredHint": "This check needs a manual action on GitHub (for example, approving the workflow run) before merging is unblocked." @@ -1170,7 +1171,9 @@ "ephemeralVm": "ワークスペースごとの環境", "chooseRunTarget": "ターゲットを選択", "noRunTargets": "このプロジェクトには実行ターゲットが準備されていません。", - "perWorkspaceEnvHint": "レシピからオンデマンド環境をプロビジョニングする" + "perWorkspaceEnvHint": "レシピからオンデマンド環境をプロビジョニングする", + "branchName": "ブランチ名", + "branchNamePlaceholder": "feature/my-branch" }, "NewWorkspaceComposerModal": { "createWorktree": "ワークツリーを作成する", @@ -2012,7 +2015,7 @@ "13b3ac0a84": "状態", "79c54552f7": "フィルター", "8a2ffbf9b3": "{{value0}} フィルターを削除します", - "19bb6f115f": "reviewed-by" + "19bb6f115f": "レビュー済" }, "PRFilterPickers": { "fdf387297c": "クリア (", @@ -2586,7 +2589,9 @@ "openLocalWorkspace": "セッションを再開する前にローカルワークスペースを開いてください。", "sessionQueued": "セッションをキューに追加しました", "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", - "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session." + "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.", + "localSessionSshWorkspaceUnsupported": "このセッションの履歴はこのマシンに保存されているため、SSH ワークスペースでは再開できません。代わりにローカルワークスペースにドロップしてください。", + "sessionHostMismatchUnsupported": "このセッションは別のホストに属しています。同じホスト上のワークスペースにドロップしてください。" }, "TabGroupDropOverlay": { "paneColumnLabel": "新しい分割" @@ -2691,7 +2696,7 @@ "39676a184c": "任意のファイル、URL、agent などを開きます..." }, "TabBarQuickCommandsButton": { - "a2c7a33831": "クイックコマンドを追加…", + "a2c7a33831": "コマンド", "20bbd75896": "コマンドはありません", "b82e237a4b": "よりクイックなコマンド", "85482c57bc": "クイックコマンドを実行する", @@ -2841,10 +2846,7 @@ "41ae4fa725": "終了中…", "138b99bd80": "このセッション", "888dad8c55": "読み込み中…", - "56b6888304": "ランタイム サーバーではローカル リソースの使用量が非表示になります。", - "14ff448686": "ランタイムサーバーでは使用できません", "6d9793d4bc": "リソースマネージャー - Terminals", - "6a822b06a7": "リソースマネージャー", "ca95d077db": "デーモンに到達できません", "a82253b458": "ワークスペースを削除します。", "946724a70a": "メインワークスペースは削除できません。", @@ -2903,7 +2905,9 @@ "c35af53b73": "サインイン", "f19a63e7cd": "サインインして使用状況を確認する", "5c938d39ac": "%週", + "54e8d6bb2d": "% Fable", "d79c3362c4": "% 5時間", + "a79c64f87e": "Fable", "8295903d17": "切り替え後に古い会話を続ける前に、ライブ Claude terminals を再起動します。", "c98ea88392": "他のアカウントはありません", "9332ba8684": "に切り替えます", @@ -2932,7 +2936,9 @@ "972a1ff497": "Codex の制限をリセットしますか?", "6d1042aa6f": "アクティブなアカウントの Codex レート制限リセット枠を 1 回使用し、対象の使用量ウィンドウを直ちにリセットします。", "f077f586db": "今後表示しない", - "c0e972d726": "キャンセル" + "c0e972d726": "キャンセル", + "06741a2f3d": "MiniMax の使用量詳細を開く", + "3bbf140864": "MiniMax 使用量" }, "StatusBarUsageEmptyCta": { "828c764a79": "アカウントを接続する", @@ -3067,6 +3073,7 @@ "6d6df77f41": "利用可能なデータがありません", "7f7f208060": "毎月", "252c096536": "毎週", + "a79c64f87e": "Fable", "94038ad2fa": "セッション", "2c35eca8d4": "使用状況を取得できません", "1292d4f2ee": "利用不可", @@ -3485,7 +3492,9 @@ "8401a7a0d0": "1 個の repos", "d4f1df62ef": "{{value0}} 個の repos", "b4263a2ac4": "{{value1}} で {{value0}} が見つかりました。", - "24eda6c8b2": "スキャン中... {{value0}}" + "24eda6c8b2": "スキャン中... {{value0}}", + "6149d5203f": "選択されている repos がありません。代わりに親フォルダーを開くと、Git 機能なしでエディター、terminal、検索を利用できます。", + "e52454b7f6": "フォルダーとして開く" }, "AddRepoRemoteStep": { "5b205b5281": "スキャンの停止", @@ -4073,7 +4082,8 @@ "hostAuthNeeded": "認証が必要です", "hostDisconnected": "切断済み", "failedNestWorkspace": "ワークスペースをネストできませんでした", - "sidebarRowMissing": "対象はもう存在しません" + "sidebarRowMissing": "対象はもう存在しません", + "failedUnnestWorkspace": "Failed to unnest workspace" }, "WorktreeMetaDialog": { "3db0a2a593": "キャンセル", @@ -4229,7 +4239,7 @@ "6c1efa2cf8": "レビュー中", "6b8285b8dd": "完了", "93ac840dcb": "ブロック中", - "2c19d1db33": "Play", + "2c19d1db33": "再生", "111db162bf": "一時停止中", "642da473f2": "アラート", "6380517b10": "フラグ", @@ -4387,7 +4397,7 @@ "5e6f7a8b9c": "This removes the saved SSH host and its credentials from this computer. Remote files are not deleted.", "6f7a8b9c0d": "キャンセル", "7a8b9c0d1e": "Open settings", - "8b9c0d1e2f": "Remove host" + "8b9c0d1e2f": "ホストを削除" }, "HostRenameDialog": { "1a2b3c4d5e": "Rename host", @@ -4409,7 +4419,7 @@ "63f36455cc": "Reconnect", "59b553e2aa": "切断", "2d3e4f5a6b": "Check connection", - "3c4d5e6f7a": "Manage host…", + "3c4d5e6f7a": "ホストを管理…", "6e7f8a9b0c": "Remove host…" }, "WorkspaceKanbanDrawer": { @@ -4547,6 +4557,7 @@ "ae3b21eb6c": "opencode.ai/workspace/wrk_…/go", "51c9104e13": "opencode.ai にログインした後、URL でこれを見つけます (例:", "b398b834c9": "クリア", + "316ca4e610": "Cookie を削除", "a122332371": "wrk_… (自動検索の場合は空白のままにします)", "dbdb0b0bd8": "ワークスペースIDのオーバーライド", "d70a5287a4": "自動検索が失敗した場合は、オプションのワークスペース ID をオーバーライドします。", @@ -4612,7 +4623,47 @@ "75ca9b718e": "Codex は、アクティブなアカウントには新たにサインインする必要があると報告しました。新規 Codex セッションを開始する前に、再認証してください。", "b11078a9c2": "wsl", "350b2a1aa7": "Use your current", - "e05d0ff737": "現在の {{value0}} Claude ログインを使用します。" + "e05d0ff737": "現在の {{value0}} Claude ログインを使用します。", + "2f24f244a4": "MiniMax Cookie が必要です。", + "8e6f0cb1d8": "MiniMax Cookie は保存されませんでした。", + "8d61637a77": "MiniMax Cookie を保存しました。", + "b43e761fe5": "MiniMax Cookie の更新に失敗しました。", + "5d63bbfbec": "MiniMax", + "15e831350e": "platform.minimax.io から MiniMax の使用量追跡を設定します。", + "21d6eb141e": "MiniMax セッション Cookie", + "33bba5ad83": "ローカルのレート制限取得用に MiniMax セッション Cookie を貼り付けます。", + "73ea15f24b": "保存済み", + "23afe8f226": "未保存", + "566d9a99ab": "_token=…; minimax_group_id_v2=…", + "f38b9cc4bd": "置き換え", + "590a3130f9": "保存", + "79418c782a": "ブラウザで platform.minimax.io/console/usage を開き、サインインして、DevTools(ネットワーク→remains のリクエスト→Cookie)から Cookie リクエストヘッダーをコピーしてください。", + "9dd50d3f75": "詳細設定", + "174fb408f9": "MiniMax の使用量更新が誤ったワークスペースやモデルを指す場合のみ、これらの既定値を変更してください。", + "bf160bb6c0": "グループ ID の上書き", + "b1e2743313": "任意。空白にすると Cookie の minimax_group_id_v2 が使われます。", + "0747d6391a": "Cookie からグループ ID を使用", + "4ff2af7524": "使用モデルの名前", + "5cf4b0f85f": "任意のコンマ区切りのモデル名。MiniMax がモデル固有のエラーを返す場合を除き、general のままにしてください。", + "3c92b0d31c": "general", + "0d8e77bc40": "コンソールを開く", + "0b8c1c7e02": "ローカルに保存済み", + "1fd1b1b6b4": "Cookie は未設定です", + "5e08b0fe57": "ローカルに保存され、使用量の更新のために platform.minimax.io のみに送信されます。", + "43d7a45b97": "コピー方法", + "b8a4f21c3e": "DevTools から Cookie ヘッダーを貼り付け", + "53f7b8c7a2": "最終更新: {{value0}}", + "31d24a4e87": "ブラウザでサインアウトすると Cookie は無効になります。", + "3a30aaf526": "たった今", + "f5d8d2a6a1": "ブラウザで platform.minimax.io/console/usage を開き、サインインします。", + "24560fe830": "DevTools を開きます。", + "4cab0fa42d": "ネットワークタブを開き、Preserve log を有効にします。", + "bee4e63e1c": "ページを再読み込みします。", + "87f814af6f": "remains でフィルタリングし、coding_plan/remains のリクエストを選択します。", + "435df0ee51": "リクエストヘッダーの Cookie の値をコピーします。", + "7492fb3bba": "ここに貼り付けて保存をクリックします。", + "9fec52de4b": "Cookie のコピー方法", + "4e32e030b2": "ローカルに保存されます。Orca は使用量の更新のために platform.minimax.io のみに送信します。" }, "AdvancedPane": { "40b29e0bf3": "再起動", @@ -4860,7 +4911,8 @@ "d420c43729": "{{value0}} Cookie を {{value1}}{{value2}} から {{value3}} にインポートしました。", "b4c167764d": "ファイルから {{value0}} 個の Cookie を {{value1}} にインポートしました。", "a3f8c2d1e0b4": "{{value1}} ({{value2}}) から {{value0}} 件の Cookie を {{value3}} にインポートしました。", - "b4e9d3f2a1c5": "{{value1}} から {{value0}} 件の Cookie を {{value2}} にインポートしました。" + "b4e9d3f2a1c5": "{{value1}} から {{value0}} 件の Cookie を {{value2}} にインポートしました。", + "c5a273a809": "{{value0}} から" }, "BrowserUseComputerUseNotice": { "15b5e680ba": "Open Computer Use", @@ -4884,7 +4936,7 @@ "be6df68384": "ファイルから…", "e44c5d681e": "から", "67d9a53f47": "個別のログインのプロファイルを管理する", - "112f70adc4": "最後にインポートされたのは", + "112f70adc4": "{{value0}} から最後にインポート", "72d4815523": "既存のログインを Orca に取り込み、agents が認証されたページにアクセスできるようにします。デフォルトのプロファイルにインポートします。", "2eb906706c": "ブラウザの Cookie をインポートする", "af8c83ed61": "Chrome、Edge、またはその他のブラウザから Cookie をインポートすると、agents がログインを再利用できるようになります。", @@ -4906,7 +4958,8 @@ "ad8cb0ee22": "パスを修正する", "0289434ed6": "有効", "8b3054dac7": "登録中...", - "8f2675c2f3": "ファイルから {{value0}} 個の Cookie をインポートしました。" + "8f2675c2f3": "ファイルから {{value0}} 個の Cookie をインポートしました。", + "5301857d88": "{{value0}} から" }, "BrowserUseSkillStep": { "0871b6998d": "agents が Orca のブラウザでページに移動して確認できるようにします。", @@ -5144,7 +5197,9 @@ "8f1afdfbd8": "差分ワードラップ", "4aa4d9fb73": "水平スクロールを必要とせずに、差分エディターで長い行を折り返します。", "bf16ef0af2": "オフ", - "3f6892f307": "オン" + "3f6892f307": "オン", + "b82f86d7d2": "Rich Markdown Spellcheck", + "5195f0b9ef": "Show browser spelling underlines and suggestions while editing rich Markdown." }, "AdvancedNetworkSettingsSection": { "3e431564b5": "ローカルホスト、127.0.0.1、*.internal", @@ -5878,13 +5933,13 @@ "hostSetupStateNotSetUp": "Not set up", "hostSetupStateSettingUp": "Setting up", "hostSetupStateError": "Error", - "hostSetupStateUnsupported": "Unsupported", + "hostSetupStateUnsupported": "未対応", "setupPathPending": "Path pending", "openSetup": "オープン", "removeSetup": "削除", "hostSetupBlockedVersion": "Orca server version is incompatible", "hostSetupMissingCapability": "Update Orca on this host to set up projects", - "setupProjectOnHost": "Set up on another host", + "setupProjectOnHost": "別のホストで設定", "setupProjectOnHostHelp": "Choose a host, then import an existing checkout, clone the repository there, or track a setup that will be provisioned later.", "setupExistingFolder": "既存フォルダをインポート", "setupExistingFolderHelp": "Make this project available on another host by linking a checkout that already exists there.", @@ -5913,7 +5968,7 @@ "cloneFromUrl": "Clone from URL", "cloneFromUrlHelp": "Clone this repository onto the selected host.", "addPlannedHost": "Add host placeholder", - "addPlannedHostHelp": "Remember this host and finish adding the project later.", + "addPlannedHostHelp": "このホストを記憶し、後でプロジェクトの追加を完了します。", "existingFolder": "Existing folder", "addPlannedHostToHost": "Add {{host}}", "addPlannedHostConfirm": "This only records that the project should be available on this host. You can add the folder or clone later.", @@ -6385,7 +6440,11 @@ "29af933cd5": "新規 SSH ターゲット", "f2331ce599": "SSH ターゲットの編集", "7c13f58c91": "リセットまで", - "55c56cf2c7": "切断後のタイムアウト(秒)" + "55c56cf2c7": "切断後のタイムアウト(秒)", + "4a342f44c1": "詳細接続", + "e9609ddca6": "プロキシ、ジャンプホスト、接続の再利用", + "8c922dffba": "より速いセットアップのため SSH 接続を再利用", + "53e9aabfc0": "利用可能な場合は OpenSSH の多重化を使用します。独自の SSH 制限があるホストではオフにしてください。" }, "TasksPane": { "3a72b9745e": "[タスク] ページとサイドバーのショートカットに表示するタスク プロバイダーを選択します。", @@ -6534,7 +6593,7 @@ "ab20575a8a": "オフ", "ab3a1f9068": "wsl.exe", "ask_before_closing_running_terminals_title": "Ask Before Closing Running Terminals", - "ask_before_closing_running_terminals_description": "Show a confirmation before closing a terminal that has a running command or agent.", + "ask_before_closing_running_terminals_description": "実行中のコマンドやエージェントがあるターミナルを閉じる前に確認を表示します。", "scrollSpeed": { "title": "スクロール速度", "description": "通常のターミナルスクロールバック、修飾キーでの高速スクロール、全画面 TUI のホイール速度を調整します。", @@ -6772,7 +6831,9 @@ "bdbd1e668e": "窓", "593720c17f": "場所", "b84a5b0c8a": "プロバイダー アカウントをこのデバイスまたは WSL で検査および追加するかどうかを選択します。", - "d09fb5ca92": "アカウントの場所" + "d09fb5ca92": "アカウントの場所", + "733f9e2a93": "MiniMax 使用量", + "f8374c3151": "ローカルのレート制限取得用に platform.minimax.io のセッション Cookie を貼り付けます。" } }, "advanced": { @@ -6942,6 +7003,11 @@ "40e5c3c285": "kimi", "c927a155d5": "ステータス バーに Kimi サブスクリプションの使用状況を表示します。", "3a6c028ea8": "Kimi 使用量", + "25e51b62ee": "レート制限", + "d9e7cef86f": "Cookie", + "d16378a88f": "minimax", + "e46178eb1b": "ステータスバーに MiniMax サブスクリプションの使用状況を表示します。", + "0f08f6b483": "MiniMax 使用量", "edbf0f63a0": "料金", "afbb6a3767": "トークン", "d77537b580": "opencode-go", @@ -7430,7 +7496,9 @@ "161a86a9da": "ピン留めしたタブを閉じる前に確認", "8e593f04fc": "ピン留めしたタブを閉じる前に確認ダイアログを表示します。", "defaultProjectRuntime": "Default Project Runtime", - "defaultProjectRuntimeDescription": "Choose the runtime inherited by local Windows projects." + "defaultProjectRuntimeDescription": "Choose the runtime inherited by local Windows projects.", + "d2d2d929c0": "Rich Markdown Spellcheck", + "4497e2e2bb": "Show browser spelling underlines and suggestions while editing rich Markdown." } }, "git": { @@ -8212,7 +8280,7 @@ "0fe0073f0c": "新規ペインとライブ アップデートのデフォルトの terminal フォント サイズ。", "5930244899": "フォントサイズ", "ask_before_closing_running_terminals_title": "Ask Before Closing Running Terminals", - "ask_before_closing_running_terminals_description": "Show a confirmation before closing a terminal that has a running command or agent.", + "ask_before_closing_running_terminals_description": "実行中のコマンドやエージェントがあるターミナルを閉じる前に確認を表示します。", "scrollSpeed": { "title": "スクロール速度", "description": "通常のターミナルスクロールバック、修飾キーでの高速スクロール、全画面 TUI のホイール速度を調整します。" @@ -8343,7 +8411,7 @@ "integration": { "cards": { "d5b3be8ecd": "Re-check", - "8cbc39f862": "Learn more", + "8cbc39f862": "詳しく見る", "707180d09c": "glab auth login", "4be0616873": "The GitLab CLI is installed but not authenticated. Run this command in a terminal:", "54a640af7a": "Install GitLab CLI", @@ -8375,7 +8443,7 @@ "8c20e76308": "Each connected Jira site has one token stored by the active runtime.", "c24e56c532": "Test", "3e7c10d286": "Testing...", - "a2c0015fb8": "Verified", + "a2c0015fb8": "確認済み", "e2ff968276": "Connect Jira", "60996beda6": "Add Jira site", "7ca5ffffdb": "Browse, create, and start work from Jira Cloud issues.", @@ -8400,7 +8468,7 @@ "integration": { "cards": { "793a06e899": "Re-check", - "1a9475dace": "Learn more", + "1a9475dace": "詳しく見る", "19fb419c12": "Gitea credentials are configured but could not authenticate. Check the token, API base URL, and repository permissions, then restart Orca if environment variables changed.", "60708f23da": "only when Orca cannot derive the API URL from the remote.", "709057ad91": "ORCA_GITEA_API_BASE_URL", @@ -8558,12 +8626,12 @@ "07939d084e": "Reset {{value0}} to default", "9b02917027": "Reset to default", "a799f90f82": "Disable {{value0}}", - "25e6e76618": "Disable shortcut", - "035a822ef0": "Add shortcut", + "25e6e76618": "ショートカットを無効にする", + "035a822ef0": "ショートカットを追加", "a0e2ef0e61": "Add another shortcut for {{value0}}", - "245c83af24": "Add another shortcut", + "245c83af24": "別のショートカットを追加", "482a60225d": "Enable {{value0}}", - "6287677c37": "Enable", + "6287677c37": "有効にする", "01481b964c": "Add shortcut for {{value0}}" }, "ShortcutRecorderButton": { @@ -8571,10 +8639,10 @@ "3732775d74": "Add shortcut for {{value0}}", "88764af2c1": "Change shortcut for {{value0}}", "30feb099d6": "Change shortcut {{value0}} of {{value1}} for {{value2}}", - "5d982a2a1f": "Listening for shortcut", - "152e0bcd64": "Add shortcut", - "5bd56445da": "Change shortcut", - "f5ed5dcbf6": "Press keys…" + "5d982a2a1f": "ショートカットを待機中", + "152e0bcd64": "ショートカットを追加", + "5bd56445da": "ショートカットを変更", + "f5ed5dcbf6": "キーを押してください…" }, "ShortcutRemoveButton": { "9e29aff18b": "{{value0}} ショートカット {{value1}} を削除", @@ -8623,21 +8691,21 @@ "forceDeleteClicked": "Force Delete clicked", "devOnlyCallback": "Dev-only callback.", "infoToast": "Info toast", - "infoToastDescription": "Long informational copy with no explicit action.", + "infoToastDescription": "明示的なアクションのない長い情報コピー。", "localCanaryBehind": "Local canary is behind origin/canary", "successToast": "Success toast", - "successToastDescription": "Short confirmation state.", + "successToastDescription": "短い確認状態。", "settingsSaved": "Settings saved", - "shortSuccessCopy": "Short success copy.", + "shortSuccessCopy": "短い成功コピー。", "errorToast": "Error toast", - "errorToastDescription": "Long error copy without recovery actions.", + "errorToastDescription": "復元アクションのない長いエラーコピー。", "failedToSyncWorkspaceMetadata": "Failed to sync workspace metadata", "nativeActionToast": "Native action toast", - "nativeActionToastDescription": "Uses Sonner action and cancel slots.", + "nativeActionToastDescription": "Sonner アクションとキャンセルスロットを使用。", "deleteFailureToast": "Delete failure toast", - "deleteFailureToastDescription": "Custom footer with View and Force Delete.", + "deleteFailureToastDescription": "表示と強制削除を含むカスタムフッター。", "behindBaseRefToast": "Behind base ref toast", - "behindBaseRefToastDescription": "Persistent prompt with an inline Settings link and footer action.", + "behindBaseRefToastDescription": "インラインの設定リンクとフッターアクション付きの永続プロンプト。", "notificationPlayground": "Notification playground", "notificationPlaygroundDescription": "Dev-only triggers for checking toast layout, recovery actions, and long-copy wrapping.", "devOnly": "Dev only" @@ -8694,6 +8762,18 @@ "ephemeralVmsExperimentalSetting": { "description": "repo が所有するオンデマンド環境のセットアップ コントロールとワークスペース実行ターゲットを示します。", "toggleLabel": "ワークスペースごとの環境を切り替える" + }, + "SourceControlActionRepoOverrideNote": { + "agent": "Agent", + "agentArgs": "CLI arguments", + "commandTemplate": "Command template", + "more": "+{{count}} more", + "plural": "Global saves won't change {{count}} repositories with their own recipes.", + "recipe": "Recipe", + "review": "Review", + "reviewFirst": "Review first", + "singular": "Global saves won't change 1 repository with its own recipe.", + "tooltipTitle": "Repository overrides" } }, "right": { @@ -8812,7 +8892,8 @@ "98a79948b3": "コピー", "b234ab25b4": "ファイルをクリップボードにコピーできませんでした", "f9d7ca753d": "パスのコピー", - "3161c4e425": "フォルダ" + "3161c4e425": "フォルダ", + "e887fa4b2e": "ターミナルで開く" }, "FileExplorerToolbar": { "d238264654": "Git で無視されたファイルを表示", @@ -9159,7 +9240,8 @@ "b715ef615b": "{{value1}}より{{value0}}commits 進んでいます", "c1a8f3e204": "{{value0}}より1commit 遅れています", "d2b9g4f315": "{{value1}}より{{value0}}commits 遅れています", - "e9c2a5d416": "{{value0}}と同じ状態" + "e9c2a5d416": "{{value0}}と同じ状態", + "createPrIntentCommitBlockedSummary": "Commit blocked: {{value0}} Fix the issue, then retry Create PR." }, "SourceControlAgentActionDialog": { "8e856842d1": "選択した agent を開始できませんでした。", @@ -9190,7 +9272,8 @@ "ea4788705e": "キャンセル", "b0da3a4d3e": "起動レシピはすでに保存されています", "bff4795a6d": "保存済みのレシピを更新するには、agent、引数、またはプロンプトテンプレートを変更してください。", - "5c75b24735": "Orca が起動する前に agent が受け取る内容をカスタマイズします。" + "5c75b24735": "Orca が起動する前に agent が受け取る内容をカスタマイズします。", + "repoAgentOverrideNote": "This repository overrides your global default ({{global}}) and currently runs {{effective}}. Save to this repository to change what runs here." }, "SourceControlTextGenerationDialog": { "c5b7fa7cb6": "グローバルデフォルトとして保存", @@ -9289,7 +9372,7 @@ "f5bc5c4cf1": "The hosting provider reports conflicts, but local Git did not reproduce them. Refresh the review or push the branch to recalculate mergeability.", "5bc9bda2af": "Run from this worktree", "e87fb3d929": "Copy mergeability refresh commands", - "1e53e45072": "Copied", + "1e53e45072": "コピーしました", "084c516efb": "Copy commands", "5dc3af25c0": "コメントを選択", "d7a2f9c401": "Send unresolved {{value0}} comments", @@ -9304,10 +9387,10 @@ "e8b4c1a903": "Resolved · {{value0}}", "c3a8e5d710": "Needs review · {{value0}}", "a7f0c7e8d1": "Queue", - "8a621a2c4f": "Grouped", - "b13f85d75c": "Timeline", + "8a621a2c4f": "グループ化", + "b13f85d75c": "タイムライン", "f5cf324efa": "Comment display options", - "5e6e5a13fa": "View", + "5e6e5a13fa": "表示", "actionRequiredHint": "This check needs a manual action on GitHub (for example, approving the workflow run) before merging is unblocked." }, "empty": { @@ -9485,7 +9568,7 @@ "d8a4c0e369": "Authenticate before creating a {{value0}}.", "e9b5d1f470": "Check out a branch before creating a {{value0}}.", "f0c6e2a581": "This branch is not ready for a {{value0}} yet.", - "8f9a0b1c2d": "Nothing to commit. Branch is up to date.", + "8f9a0b1c2d": "コミットするものがありません。ブランチは最新です。", "h3i4j5k607": "Checking whether this branch can create a {{value0}}…" } } @@ -9559,7 +9642,9 @@ "worktreeUnavailable": "ワークツリーは利用できなくなりました。", "runtimeBrowseLocalHistory": "Runtime-hosted workspaces can browse local history. Resume actions are available in local and SSH workspaces.", "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", - "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session." + "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.", + "localSessionSshWorkspaceUnsupported": "このセッションの履歴はこのマシンに保存されているため、SSH ワークスペースでは再開できません。代わりにローカルワークスペースを開いてください。", + "sessionHostMismatchUnsupported": "このセッションは別のホストに属しています。再開するには同じホスト上のワークスペースを開いてください。" }, "AiVaultPanelControls": { "scanningSessions": "Scanning sessions", @@ -9569,14 +9654,14 @@ "allSessionsLower": "all sessions", "thisScope": "This", "allScope": "All", - "scope": "Scope", + "scope": "スコープ", "currentWorkspace": "Current workspace", "allSessions": "All sessions", "viewOptionsAriaLabel": "Session History view options", - "viewOptions": "View options", + "viewOptions": "表示オプション", "agents": "エージェント", - "sort": "Sort", - "lastUpdated": "Last updated", + "sort": "並び替え", + "lastUpdated": "最終更新", "created": "Created", "group": "Group", "folder": "フォルダ", @@ -9588,7 +9673,9 @@ "globalScope": "グローバル", "projectScope": "プロジェクト", "currentProjectLower": "現在のプロジェクト", - "project": "プロジェクト" + "project": "プロジェクト", + "hostScopeAriaLabel": "Session History host: {{value0}}", + "host": "Host" }, "AiVaultSessionDetails": { "updated": "更新日時", @@ -9776,10 +9863,6 @@ "a84d5677ff": "OpenCode", "2528586aa7": "クラウディーノ" } - }, - "PetOverlay": { - "de932b0e8f": "@keyframes ペットボブ { 0%,100% { 変換:translateY(0); } 50% { 変換:translateY(-4px); } }", - "4712d196c6": "@keyframes pet-{{value0}} { from { 背景位置: {{value1}}px {{value2}}px; } から { 背景位置: {{value3}}px {{value4}}px; } }" } }, "onboarding": { @@ -10201,8 +10284,8 @@ "title": "カスタムネットワークアドレス", "description": "同じ Wi-Fi に接続していないときにスマートフォンから到達できるアドレスを指定します。たとえば Tailscale のホスト名や静的 IP です。", "label": "アドレス", - "placeholder": "my-mac.ts.net または 192.168.1.50", - "hint": "IP アドレスまたは Tailscale のホスト名(.ts.net で終わる)を入力してください。", + "placeholder": "my-mac.ts.net、home.example.com、または 192.168.1.50", + "hint": "IP アドレスまたはホスト名(Tailscale の MagicDNS 名、DDNS ドメイン、LAN のホスト名など)を入力してください。必要に応じて :ポート を付けられます。", "cancel": "キャンセル", "use": "アドレスを使用" }, @@ -11695,7 +11778,8 @@ "4bb7424d6b": "Canceled", "6e776f9ef9": "Download failed", "756bfc25c9": "開く", - "09a9489aa5": "Show" + "09a9489aa5": "表示", + "2a4c4b8e1f": "コピー" }, "BrowserToolbarMenu": { "429ef481f9": "キャンセル", @@ -11781,7 +11865,7 @@ "51a470b966": "ssh", "b09b2384fd": "一時停止中", "eaa02014f8": "有効", - "29baf8f4c2": "Source" + "29baf8f4c2": "ソース" }, "AutomationEditorDialog": { "fb1896a5e7": "キャンセル", @@ -12051,7 +12135,7 @@ }, "AutomationSetupDecisionField": { "5a7863909c": "新しいワークスペースごとにセットアップを実行", - "18f000ad4e": "Advanced", + "18f000ad4e": "詳細設定", "874b72195b": "When this automation creates a workspace, prepare it the same way creating a worktree by hand does — run the project's setup and open its terminal tabs." } }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 88a7d4cf236..18c7669642f 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -25,6 +25,7 @@ "geminiToggleDescription": "활성 워크스페이스에 대한 Gemini 토큰 및 비용 사용량을 표시합니다.", "opencodeGoToggleDescription": "활성 워크스페이스에 대한 OpenCode Go 토큰 및 비용 사용량을 표시합니다.", "kimiToggleDescription": "활성 워크스페이스의 Kimi 구독 사용량을 표시합니다.", + "minimaxToggleDescription": "활성 워크스페이스의 MiniMax 구독 사용량을 표시합니다.", "sshToggleDescription": "사용 가능한 SSH 및 원격 Orca 호스트가 있으면 표시합니다.", "resourceUsageToggleDescription": "리소스 관리자를 표시합니다. 클릭하면 CPU, 메모리, 세션, 데몬 제어, 워크스페이스 디스크 스캔을 볼 수 있습니다.", "portsToggleDescription": "라이브 워크스페이스 포트를 표시합니다. 워크스페이스 범위 포트 및 외부 수신기를 보려면 클릭하세요." @@ -57,7 +58,7 @@ "openWorktreePalette": "워크트리 팔레트 열기", "window": "창", "help": "도움말", - "paste": "Paste" + "paste": "붙여넣기" }, "tray": { "openOrca": "Orca 열기", @@ -652,7 +653,7 @@ "devDescription": "Dev-only tools for exercising UI states.", "devBadge": "Dev", "devSearchNotificationPlayground": "Notification playground", - "devSearchNotificationPlaygroundDescription": "Trigger representative toast and notification UI states.", + "devSearchNotificationPlaygroundDescription": "대표적인 토스트 및 알림 UI 상태를 트리거합니다.", "devSearchKeywordDev": "dev", "devSearchKeywordToast": "toast", "devSearchKeywordSonner": "sonner", @@ -876,7 +877,7 @@ "moved": "moved this", "from": "from", "to": "to", - "activity": "Activity", + "activity": "활동", "noActivity": "No activity yet." }, "checkActionRequiredHint": "This check needs a manual action on GitHub (for example, approving the workflow run) before merging is unblocked." @@ -1170,7 +1171,9 @@ "ephemeralVm": "워크스페이스별 환경", "chooseRunTarget": "대상 선택", "noRunTargets": "이 프로젝트에는 실행 대상이 준비되어 있지 않습니다.", - "perWorkspaceEnvHint": "레시피에서 온디맨드 환경 프로비저닝" + "perWorkspaceEnvHint": "레시피에서 온디맨드 환경 프로비저닝", + "branchName": "브랜치 이름", + "branchNamePlaceholder": "feature/my-branch" }, "NewWorkspaceComposerModal": { "createWorktree": "작업 트리 만들기", @@ -2012,7 +2015,7 @@ "13b3ac0a84": "상태", "79c54552f7": "필터", "8a2ffbf9b3": "{{value0}} 필터 제거", - "19bb6f115f": "reviewed-by" + "19bb6f115f": "리뷰 완료" }, "PRFilterPickers": { "fdf387297c": "지우기 (", @@ -2586,7 +2589,9 @@ "openLocalWorkspace": "세션을 재개하기 전에 로컬 워크스페이스를 여세요.", "sessionQueued": "세션이 대기열에 추가됨", "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", - "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session." + "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.", + "localSessionSshWorkspaceUnsupported": "이 세션의 기록은 이 컴퓨터에 저장되어 있어 SSH 워크스페이스에서는 재개할 수 없습니다. 대신 로컬 워크스페이스에 놓으세요.", + "sessionHostMismatchUnsupported": "이 세션은 다른 호스트에 속합니다. 같은 호스트의 워크스페이스에 놓으세요." }, "TabGroupDropOverlay": { "paneColumnLabel": "새 분할" @@ -2691,7 +2696,7 @@ "39676a184c": "모든 파일, URL, agent를 엽니다..." }, "TabBarQuickCommandsButton": { - "a2c7a33831": "빠른 명령 추가...", + "a2c7a33831": "명령", "20bbd75896": "명령 없음", "b82e237a4b": "빠른 명령 더 보기", "85482c57bc": "빠른 명령 실행", @@ -2841,10 +2846,7 @@ "41ae4fa725": "종료 중…", "138b99bd80": "이번 세션", "888dad8c55": "로드 중…", - "56b6888304": "런타임 서버에 대한 로컬 리소스 사용량이 숨겨졌습니다.", - "14ff448686": "런타임 서버에서는 사용할 수 없습니다.", "6d9793d4bc": "리소스 관리자 - Terminals", - "6a822b06a7": "자원 관리자", "ca95d077db": "데몬에 연결할 수 없음", "a82253b458": "워크스페이스를 삭제합니다.", "946724a70a": "기본 워크스페이스는 삭제할 수 없습니다.", @@ -2903,7 +2905,9 @@ "c35af53b73": "로그인", "f19a63e7cd": "사용량을 보려면 로그인하세요.", "5c938d39ac": "%주간", + "54e8d6bb2d": "% Fable", "d79c3362c4": "% 5h", + "a79c64f87e": "Fable", "8295903d17": "전환 후 이전 대화를 계속하기 전에 라이브 Claude terminals을 다시 시작하십시오.", "c98ea88392": "다른 계정 없음", "9332ba8684": "다음으로 전환", @@ -2932,7 +2936,9 @@ "972a1ff497": "Codex 한도를 재설정할까요?", "6d1042aa6f": "활성 계정의 Codex rate-limit 재설정 크레딧 1회를 사용해 재설정 가능한 사용량 창을 즉시 초기화합니다.", "f077f586db": "다시 묻지 않기", - "c0e972d726": "취소" + "c0e972d726": "취소", + "06741a2f3d": "MiniMax 사용량 세부 정보 열기", + "3bbf140864": "MiniMax 사용량" }, "StatusBarUsageEmptyCta": { "828c764a79": "계정 연결", @@ -3067,6 +3073,7 @@ "6d6df77f41": "데이터가 없습니다", "7f7f208060": "월간", "252c096536": "주간", + "a79c64f87e": "Fable", "94038ad2fa": "세션", "2c35eca8d4": "사용량을 가져올 수 없습니다.", "1292d4f2ee": "사용할 수 없음", @@ -3485,7 +3492,9 @@ "8401a7a0d0": "repos 1개", "d4f1df62ef": "repos {{value0}}개", "b4263a2ac4": "{{value1}}에서 {{value0}}을(를) 찾았습니다.", - "24eda6c8b2": "스캔 중... {{value0}}" + "24eda6c8b2": "스캔 중... {{value0}}", + "6149d5203f": "선택된 repos가 없습니다. 대신 상위 폴더를 열면 Git 기능 없이 편집기, terminal, 검색을 사용할 수 있습니다.", + "e52454b7f6": "폴더로 열기" }, "AddRepoRemoteStep": { "5b205b5281": "스캔 중지", @@ -4073,7 +4082,8 @@ "hostAuthNeeded": "인증 필요", "hostDisconnected": "연결 끊김", "failedNestWorkspace": "워크스페이스를 중첩하지 못했습니다", - "sidebarRowMissing": "대상이 더 이상 존재하지 않습니다" + "sidebarRowMissing": "대상이 더 이상 존재하지 않습니다", + "failedUnnestWorkspace": "Failed to unnest workspace" }, "WorktreeMetaDialog": { "3db0a2a593": "취소", @@ -4547,6 +4557,7 @@ "ae3b21eb6c": "opencode.ai/workspace/wrk_…/go", "51c9104e13": "opencode.ai에 로그인한 후 URL에서 이를 찾으세요(예:", "b398b834c9": "지우기", + "316ca4e610": "쿠키 잊기", "a122332371": "wrk_… (자동 조회를 위해 비워두세요)", "dbdb0b0bd8": "워크스페이스 ID 재정의", "d70a5287a4": "자동 조회가 실패할 때 사용할 선택적 워크스페이스 ID 재정의입니다.", @@ -4612,7 +4623,47 @@ "75ca9b718e": "Codex는 활성 계정에 새로 로그인해야 한다고 보고했습니다. 새 Codex 세션을 시작하기 전에 다시 인증하세요.", "b11078a9c2": "wsl", "350b2a1aa7": "현재 사용 중인", - "e05d0ff737": "현재 {{value0}} Claude 로그인을 사용하세요." + "e05d0ff737": "현재 {{value0}} Claude 로그인을 사용하세요.", + "2f24f244a4": "MiniMax Cookie가 필요합니다.", + "8e6f0cb1d8": "MiniMax Cookie가 저장되지 않았습니다.", + "8d61637a77": "MiniMax Cookie가 저장되었습니다.", + "b43e761fe5": "MiniMax Cookie 업데이트에 실패했습니다.", + "5d63bbfbec": "MiniMax", + "15e831350e": "platform.minimax.io에서 MiniMax 사용량 추적을 설정합니다.", + "21d6eb141e": "MiniMax 세션 Cookie", + "33bba5ad83": "로컬 rate-limit 가져오기에 사용할 MiniMax 세션 Cookie를 붙여넣으세요.", + "73ea15f24b": "저장됨", + "23afe8f226": "저장되지 않음", + "566d9a99ab": "_token=…; minimax_group_id_v2=…", + "f38b9cc4bd": "교체", + "590a3130f9": "저장", + "79418c782a": "브라우저에서 platform.minimax.io/console/usage 를 열고 로그인한 다음 DevTools(네트워크 → 임의의 remains 요청 → Cookie)에서 Cookie 요청 헤더를 복사하세요.", + "9dd50d3f75": "고급", + "174fb408f9": "MiniMax 사용량 새로 고침이 잘못된 워크스페이스나 모델을 가리키는 경우에만 이 기본값을 변경하세요.", + "bf160bb6c0": "Group ID 재정의", + "b1e2743313": "선택 항목입니다. 비워두면 Cookie의 minimax_group_id_v2가 사용됩니다.", + "0747d6391a": "Cookie의 Group ID 사용", + "4ff2af7524": "사용 모델 이름", + "5cf4b0f85f": "선택적인 쉼표로 구분된 모델 이름. MiniMax가 모델 관련 오류를 반환하지 않는 한 general로 두세요.", + "3c92b0d31c": "general", + "0d8e77bc40": "콘솔 열기", + "0b8c1c7e02": "로컬에 저장됨", + "1fd1b1b6b4": "Cookie가 설정되지 않음", + "5e08b0fe57": "로컬에 저장되며 사용량 새로 고침을 위해 platform.minimax.io로만 전송됩니다.", + "43d7a45b97": "복사 방법", + "b8a4f21c3e": "DevTools에서 Cookie 헤더 붙여넣기", + "53f7b8c7a2": "마지막 새로 고침: {{value0}}", + "31d24a4e87": "브라우저에서 로그아웃하면 Cookie가 만료됩니다.", + "3a30aaf526": "방금 전", + "f5d8d2a6a1": "브라우저에서 platform.minimax.io/console/usage를 열고 로그인합니다.", + "24560fe830": "DevTools를 엽니다.", + "4cab0fa42d": "네트워크 탭으로 이동하여 Preserve log를 활성화합니다.", + "bee4e63e1c": "페이지를 새로 고침합니다.", + "87f814af6f": "remains로 필터링하고 coding_plan/remains 요청을 선택합니다.", + "435df0ee51": "Request Headers에서 Cookie 값을 복사합니다.", + "7492fb3bba": "여기에 붙여넣고 저장을 클릭합니다.", + "9fec52de4b": "Cookie 복사 방법", + "4e32e030b2": "로컬에 저장됩니다. Orca는 사용량 새로 고침을 위해 platform.minimax.io로만 전송합니다." }, "AdvancedPane": { "40b29e0bf3": "다시 시작", @@ -4860,7 +4911,8 @@ "d420c43729": "{{value1}}{{value2}}에서 {{value0}}개의 쿠키를 {{value3}}(으)로 가져왔습니다.", "b4c167764d": "파일에서 {{value0}}개의 쿠키를 {{value1}}에 가져왔습니다.", "a3f8c2d1e0b4": "{{value1}} ({{value2}})에서 {{value0}}개의 쿠키를 {{value3}}(으)로 가져왔습니다.", - "b4e9d3f2a1c5": "{{value1}}에서 {{value0}}개의 쿠키를 {{value2}}(으)로 가져왔습니다." + "b4e9d3f2a1c5": "{{value1}}에서 {{value0}}개의 쿠키를 {{value2}}(으)로 가져왔습니다.", + "c5a273a809": "{{value0}}에서" }, "BrowserUseComputerUseNotice": { "15b5e680ba": "컴퓨터 사용 열기", @@ -4884,7 +4936,7 @@ "be6df68384": "파일에서…", "e44c5d681e": "에서", "67d9a53f47": "별도의 로그인을 위한 프로필 관리", - "112f70adc4": "마지막으로 가져온 위치", + "112f70adc4": "{{value0}}에서 마지막으로 가져옴", "72d4815523": "agents가 인증된 페이지에 접근할 수 있도록 기존 로그인을 Orca로 가져오세요. 기본 프로필로 가져옵니다.", "2eb906706c": "브라우저 쿠키 가져오기", "af8c83ed61": "agents가 로그인을 재사용할 수 있도록 Chrome, Edge 또는 기타 브라우저에서 쿠키를 가져옵니다.", @@ -4906,7 +4958,8 @@ "ad8cb0ee22": "경로 수정", "0289434ed6": "활성화됨", "8b3054dac7": "등록 중...", - "8f2675c2f3": "파일에서 {{value0}}개의 쿠키를 가져왔습니다." + "8f2675c2f3": "파일에서 {{value0}}개의 쿠키를 가져왔습니다.", + "5301857d88": "{{value0}}에서" }, "BrowserUseSkillStep": { "0871b6998d": "agents가 Orca 브라우저에서 페이지를 탐색하고 확인할 수 있습니다.", @@ -5144,7 +5197,9 @@ "8f1afdfbd8": "차이점 단어 줄 바꿈", "4aa4d9fb73": "가로 스크롤을 요구하는 대신 diff 편집기에서 긴 줄을 줄 바꿈합니다.", "bf16ef0af2": "끄다", - "3f6892f307": "~에" + "3f6892f307": "~에", + "b82f86d7d2": "Rich Markdown Spellcheck", + "5195f0b9ef": "Show browser spelling underlines and suggestions while editing rich Markdown." }, "AdvancedNetworkSettingsSection": { "3e431564b5": "로컬호스트, 127.0.0.1, *.internal", @@ -5278,7 +5333,7 @@ "aefa1ecb59": "Git 사용자 이름이 구성되지 않았습니다.", "f35007e6e8": "git-username", "3d172725cc": "없음", - "1f32ba27a6": "Custom", + "1f32ba27a6": "사용자 정의", "a182c5125e": "Git 사용자 이름", "sourceControlGroupOrderTitle": "소스 제어 그룹 순서", "sourceControlGroupOrderDescription": "소스 제어에서 변경 사항, 스테이징된 변경 사항 또는 추적되지 않는 파일 중 무엇을 먼저 표시할지 선택합니다.", @@ -6348,7 +6403,11 @@ "29af933cd5": "새로운 SSH 대상", "f2331ce599": "SSH 대상 편집", "7c13f58c91": "재설정할 때까지", - "55c56cf2c7": "연결 해제 후 시간 제한(초)" + "55c56cf2c7": "연결 해제 후 시간 제한(초)", + "4a342f44c1": "고급 연결", + "e9609ddca6": "프록시, 점프 호스트, 연결 재사용", + "8c922dffba": "더 빠른 설정을 위해 SSH 연결 재사용", + "53e9aabfc0": "사용 가능한 경우 OpenSSH 멀티플렉싱을 사용합니다. 사용자 지정 SSH 제한이 있는 호스트에서는 끄세요." }, "TasksPane": { "3a72b9745e": "작업 페이지와 사이드바 바로가기에 표시할 작업 제공자를 선택합니다.", @@ -6735,7 +6794,9 @@ "bdbd1e668e": "Windows", "593720c17f": "위치", "b84a5b0c8a": "이 장치 또는 WSL에서 공급자 계정을 검사하고 추가할지 선택합니다.", - "d09fb5ca92": "계정 위치" + "d09fb5ca92": "계정 위치", + "733f9e2a93": "MiniMax 사용량", + "f8374c3151": "로컬 rate-limit 가져오기에 사용할 platform.minimax.io 세션 Cookie를 붙여넣으세요." } }, "advanced": { @@ -6905,6 +6966,11 @@ "40e5c3c285": "kimi", "c927a155d5": "상태 표시줄에 Kimi 구독 사용량을 표시합니다.", "3a6c028ea8": "Kimi 사용량", + "25e51b62ee": "사용 한도", + "d9e7cef86f": "쿠키", + "d16378a88f": "minimax", + "e46178eb1b": "상태 표시줄에 MiniMax 구독 사용량을 표시합니다.", + "0f08f6b483": "MiniMax 사용량", "edbf0f63a0": "비용", "afbb6a3767": "토큰", "d77537b580": "opencode-go", @@ -7393,7 +7459,9 @@ "161a86a9da": "고정된 탭을 닫기 전에 확인", "8e593f04fc": "고정된 탭을 닫기 전에 확인 대화상자를 표시합니다.", "defaultProjectRuntime": "기본 프로젝트 런타임", - "defaultProjectRuntimeDescription": "로컬 Windows 프로젝트가 상속할 런타임을 선택합니다." + "defaultProjectRuntimeDescription": "로컬 Windows 프로젝트가 상속할 런타임을 선택합니다.", + "d2d2d929c0": "Rich Markdown Spellcheck", + "4497e2e2bb": "Show browser spelling underlines and suggestions while editing rich Markdown." } }, "git": { @@ -8558,12 +8626,12 @@ "07939d084e": "Reset {{value0}} to default", "9b02917027": "Reset to default", "a799f90f82": "Disable {{value0}}", - "25e6e76618": "Disable shortcut", - "035a822ef0": "Add shortcut", + "25e6e76618": "단축키 비활성화", + "035a822ef0": "단축키 추가", "a0e2ef0e61": "Add another shortcut for {{value0}}", - "245c83af24": "Add another shortcut", + "245c83af24": "다른 단축키 추가", "482a60225d": "Enable {{value0}}", - "6287677c37": "Enable", + "6287677c37": "활성화", "01481b964c": "Add shortcut for {{value0}}" }, "ShortcutRecorderButton": { @@ -8571,10 +8639,10 @@ "3732775d74": "Add shortcut for {{value0}}", "88764af2c1": "Change shortcut for {{value0}}", "30feb099d6": "Change shortcut {{value0}} of {{value1}} for {{value2}}", - "5d982a2a1f": "Listening for shortcut", - "152e0bcd64": "Add shortcut", - "5bd56445da": "Change shortcut", - "f5ed5dcbf6": "Press keys…" + "5d982a2a1f": "단축키 대기 중", + "152e0bcd64": "단축키 추가", + "5bd56445da": "단축키 변경", + "f5ed5dcbf6": "키를 누르세요…" }, "ShortcutRemoveButton": { "9e29aff18b": "{{value0}} 바로가기 {{value1}} 제거", @@ -8623,21 +8691,21 @@ "forceDeleteClicked": "Force Delete clicked", "devOnlyCallback": "Dev-only callback.", "infoToast": "Info toast", - "infoToastDescription": "Long informational copy with no explicit action.", + "infoToastDescription": "명시적 작업이 없는 긴 정보 메시지입니다.", "localCanaryBehind": "Local canary is behind origin/canary", "successToast": "Success toast", - "successToastDescription": "Short confirmation state.", + "successToastDescription": "짧은 확인 상태입니다.", "settingsSaved": "Settings saved", - "shortSuccessCopy": "Short success copy.", + "shortSuccessCopy": "짧은 성공 메시지입니다.", "errorToast": "Error toast", - "errorToastDescription": "Long error copy without recovery actions.", + "errorToastDescription": "복구 작업이 없는 긴 오류 메시지입니다.", "failedToSyncWorkspaceMetadata": "Failed to sync workspace metadata", "nativeActionToast": "Native action toast", - "nativeActionToastDescription": "Uses Sonner action and cancel slots.", + "nativeActionToastDescription": "Sonner 작업 및 취소 슬롯을 사용합니다.", "deleteFailureToast": "Delete failure toast", - "deleteFailureToastDescription": "Custom footer with View and Force Delete.", + "deleteFailureToastDescription": "보기 및 강제 삭제가 포함된 사용자 정의 바닥글입니다.", "behindBaseRefToast": "Behind base ref toast", - "behindBaseRefToastDescription": "Persistent prompt with an inline Settings link and footer action.", + "behindBaseRefToastDescription": "인라인 설정 링크와 바닥글 작업이 있는 영구 프롬프트입니다.", "notificationPlayground": "Notification playground", "notificationPlaygroundDescription": "Dev-only triggers for checking toast layout, recovery actions, and long-copy wrapping.", "devOnly": "Dev only" @@ -8694,6 +8762,18 @@ "ephemeralVmsExperimentalSetting": { "description": "repo 소유 온디맨드 환경에 대한 설정 제어 및 워크스페이스 실행 대상을 표시합니다.", "toggleLabel": "워크스페이스별 환경 전환" + }, + "SourceControlActionRepoOverrideNote": { + "agent": "Agent", + "agentArgs": "CLI arguments", + "commandTemplate": "Command template", + "more": "+{{count}} more", + "plural": "Global saves won't change {{count}} repositories with their own recipes.", + "recipe": "Recipe", + "review": "Review", + "reviewFirst": "Review first", + "singular": "Global saves won't change 1 repository with its own recipe.", + "tooltipTitle": "Repository overrides" } }, "right": { @@ -8812,7 +8892,8 @@ "98a79948b3": "복사", "b234ab25b4": "파일을 클립보드에 복사하지 못했습니다", "f9d7ca753d": "경로 복사", - "3161c4e425": "폴더" + "3161c4e425": "폴더", + "e887fa4b2e": "터미널에서 열기" }, "FileExplorerToolbar": { "d238264654": "Git이 무시한 파일 표시", @@ -9159,7 +9240,8 @@ "b715ef615b": "{{value1}}보다 {{value0}} commits 앞서 있음", "c1a8f3e204": "{{value0}}보다 1 commit 뒤처짐", "d2b9g4f315": "{{value1}}보다 {{value0}} commits 뒤처짐", - "e9c2a5d416": "{{value0}}와 동일한 상태" + "e9c2a5d416": "{{value0}}와 동일한 상태", + "createPrIntentCommitBlockedSummary": "Commit blocked: {{value0}} Fix the issue, then retry Create PR." }, "SourceControlAgentActionDialog": { "8e856842d1": "선택한 agent를 시작할 수 없습니다.", @@ -9190,7 +9272,8 @@ "ea4788705e": "취소", "b0da3a4d3e": "실행 레시피가 이미 저장되었습니다", "bff4795a6d": "저장된 레시피를 업데이트하려면 agent, 인수 또는 프롬프트 템플릿을 변경하세요.", - "5c75b24735": "Orca가 시작하기 전에 agent가 받을 내용을 사용자 지정합니다." + "5c75b24735": "Orca가 시작하기 전에 agent가 받을 내용을 사용자 지정합니다.", + "repoAgentOverrideNote": "This repository overrides your global default ({{global}}) and currently runs {{effective}}. Save to this repository to change what runs here." }, "SourceControlTextGenerationDialog": { "c5b7fa7cb6": "전역 기본값으로 저장", @@ -9289,7 +9372,7 @@ "f5bc5c4cf1": "The hosting provider reports conflicts, but local Git did not reproduce them. Refresh the review or push the branch to recalculate mergeability.", "5bc9bda2af": "Run from this worktree", "e87fb3d929": "Copy mergeability refresh commands", - "1e53e45072": "Copied", + "1e53e45072": "복사됨", "084c516efb": "Copy commands", "5dc3af25c0": "댓글 선택", "d7a2f9c401": "해결되지 않은 댓글 {{value0}}개 보내기", @@ -9304,10 +9387,10 @@ "e8b4c1a903": "Resolved · {{value0}}", "c3a8e5d710": "Needs review · {{value0}}", "a7f0c7e8d1": "Queue", - "8a621a2c4f": "Grouped", - "b13f85d75c": "Timeline", + "8a621a2c4f": "그룹화", + "b13f85d75c": "타임라인", "f5cf324efa": "Comment display options", - "5e6e5a13fa": "View", + "5e6e5a13fa": "보기", "actionRequiredHint": "This check needs a manual action on GitHub (for example, approving the workflow run) before merging is unblocked." }, "empty": { @@ -9485,7 +9568,7 @@ "d8a4c0e369": "{{value0}}을(를) 만들기 전에 인증하세요.", "e9b5d1f470": "{{value0}}을(를) 만들기 전에 브랜치를 체크아웃하세요.", "f0c6e2a581": "이 브랜치는 아직 {{value0}}을(를) 만들 준비가 되지 않았습니다.", - "8f9a0b1c2d": "Nothing to commit. Branch is up to date.", + "8f9a0b1c2d": "커밋할 항목이 없습니다. 브랜치가 최신 상태입니다.", "h3i4j5k607": "이 브랜치에서 {{value0}}을(를) 만들 수 있는지 확인 중…" } } @@ -9559,7 +9642,9 @@ "worktreeUnavailable": "워크트리는 더 이상 사용할 수 없습니다.", "runtimeBrowseLocalHistory": "Runtime-hosted workspaces can browse local history. Resume actions are available in local and SSH workspaces.", "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", - "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session." + "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session.", + "localSessionSshWorkspaceUnsupported": "이 세션의 기록은 이 컴퓨터에 저장되어 있어 SSH 워크스페이스에서는 재개할 수 없습니다. 대신 로컬 워크스페이스를 여세요.", + "sessionHostMismatchUnsupported": "이 세션은 다른 호스트에 속합니다. 재개하려면 같은 호스트의 워크스페이스를 여세요." }, "AiVaultPanelControls": { "scanningSessions": "세션 스캔 중", @@ -9588,7 +9673,9 @@ "globalScope": "전역", "projectScope": "프로젝트", "currentProjectLower": "현재 프로젝트", - "project": "프로젝트" + "project": "프로젝트", + "hostScopeAriaLabel": "Session History host: {{value0}}", + "host": "Host" }, "AiVaultSessionDetails": { "updated": "업데이트됨", @@ -9776,10 +9863,6 @@ "a84d5677ff": "OpenCode", "2528586aa7": "클라우디노" } - }, - "PetOverlay": { - "de932b0e8f": "@keyframes pet-bob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-4px); } }", - "4712d196c6": "@keyframes pet-{{value0}} { from { background-position: {{value1}}px {{value2}}px; } to { background-position: {{value3}}px {{value4}}px; } }" } }, "onboarding": { @@ -10201,8 +10284,8 @@ "title": "사용자 지정 네트워크 주소", "description": "같은 Wi-Fi에 있지 않을 때 휴대폰이 연결할 수 있는 주소를 알립니다. 예: Tailscale 호스트 이름 또는 고정 IP.", "label": "주소", - "placeholder": "my-mac.ts.net 또는 192.168.1.50", - "hint": "IP 주소 또는 Tailscale 호스트 이름(.ts.net으로 끝남)을 입력하세요.", + "placeholder": "my-mac.ts.net, home.example.com 또는 192.168.1.50", + "hint": "IP 주소 또는 호스트 이름(Tailscale MagicDNS 이름, DDNS 도메인 또는 LAN 호스트 이름)을 입력하세요. 필요하면 :포트를 붙일 수 있습니다.", "cancel": "취소", "use": "주소 사용" }, @@ -10308,7 +10391,7 @@ "4b2fc4b80c": "사고 강도", "be8917699e": "모델", "4d9b6d84df": "지원되지 않습니다. Claude, Codex 또는 Custom을 선택하세요.", - "560d4feb00": "Custom", + "560d4feb00": "사용자 정의", "29d119fe95": "에이전트", "f9382b48a1": "AI author 활성화", "1c0cb4fabb": "AI 작성자", @@ -10516,7 +10599,7 @@ "ca2cfbf188": "Terminal을 아래로 분할", "e370fa8c2b": "Terminal을 오른쪽으로 분할", "b85eab49dd": "src/auth/session.ts", - "99f5224f1e": "Edit", + "99f5224f1e": "편집", "0d93c298a7": "src/auth 던지기", "17cfdc3344": "Grep", "9923847785": "Read", @@ -11695,7 +11778,8 @@ "4bb7424d6b": "Canceled", "6e776f9ef9": "Download failed", "756bfc25c9": "열기", - "09a9489aa5": "Show" + "09a9489aa5": "표시", + "2a4c4b8e1f": "복사" }, "BrowserToolbarMenu": { "429ef481f9": "취소", @@ -12051,7 +12135,7 @@ }, "AutomationSetupDecisionField": { "5a7863909c": "새 워크스페이스마다 설정 실행", - "18f000ad4e": "Advanced", + "18f000ad4e": "고급", "874b72195b": "When this automation creates a workspace, prepare it the same way creating a worktree by hand does — run the project's setup and open its terminal tabs." } }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 897a993208a..23ed90c8f24 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -20,11 +20,12 @@ "spanish": "Español" }, "statusBar": { - "claudeToggleDescription": "显示 Claude 令牌和成本使用情况。", - "codexToggleDescription": "显示 Codex 令牌和成本使用情况。", - "geminiToggleDescription": "显示 Gemini 令牌和成本使用情况。", - "opencodeGoToggleDescription": "显示 OpenCode Go 令牌和成本使用情况。", + "claudeToggleDescription": "显示当前工作区的 Claude token和成本使用情况。", + "codexToggleDescription": "显示当前工作区的 Codex token和成本使用情况。", + "geminiToggleDescription": "显示当前工作区的 Gemini token和成本使用情况。", + "opencodeGoToggleDescription": "显示当前工作区的 OpenCode Go token和成本使用情况。", "kimiToggleDescription": "显示当前工作区的 Kimi 订阅使用情况。", + "minimaxToggleDescription": "显示当前工作区的 MiniMax 订阅使用情况。", "sshToggleDescription": "当有可用的 SSH 和远程 Orca 主机时显示它们。", "resourceUsageToggleDescription": "显示资源管理器。点击可查看 CPU、内存、会话、守护进程控制和工作区磁盘扫描。", "portsToggleDescription": "显示实时工作区端口。单击它可获取工作区范围的端口和外部侦听器。" @@ -46,7 +47,7 @@ "showStatusBar": "显示状态栏", "showTasksButton": "显示任务按钮", "showAutomationsButton": "显示自动化按钮", - "showMobileButton": "显示 Orca手机端 按钮", + "showMobileButton": "显示 Orca 手机端按钮", "showTitlebarAppName": "显示标题栏应用名称", "view": "查看", "reload": "重新加载", @@ -57,7 +58,7 @@ "openWorktreePalette": "打开工作树面板", "window": "窗口", "help": "帮助", - "paste": "Paste" + "paste": "粘贴" }, "tray": { "openOrca": "打开 Orca", @@ -94,8 +95,8 @@ "1468601e7b": "工作区列表出现错误。", "bdc71dddc9": "活动工作区保持打开状态。重试列表或切换视图。", "c1cf0b0e4a": "折叠窗格", - "8504ddf267": "应用仍在运行。请重试 shell,或通过菜单报告崩溃详情。", - "df1d56bf87": "工作区外壳遇到错误。", + "8504ddf267": "应用仍在运行。请重试工作区界面,或通过菜单报告崩溃详情。", + "df1d56bf87": "工作区界面遇到错误。", "9e0b441a91": "切换右侧边栏", "e81217c1b7": "隐藏应用名称", "5096cbbc86": "Orca", @@ -141,7 +142,7 @@ "api": { "31bfe8ae1a": "在网络客户端中不可用。", "67ec964791": "Web 客户端不支持 Cookie 导入。", - "275a776357": "抓取元素在 Web 客户端中不可用。", + "275a776357": "悬停提取在 Web 客户端中不可用。", "8dfcb7a351": "选择屏幕截图在 Web 客户端中不可用。", "31bea294d5": "抓取模式在 Web 客户端中不可用。", "b8a1618172": "PR详细信息生成在 Web 客户端中不可用。", @@ -448,7 +449,7 @@ "action": { "plan": { "3f0ea9aa0d": "无法构建智能体启动命令。", - "46f1a2c9bd": "命令输入为空。", + "46f1a2c9bd": "提示词输入为空。", "8eb541cc83": "此工作区主机上未检测到所选智能体。", "b96e091fc9": "选定的智能体在“设置”中被禁用。", "a7ac8717c7": "在开始之前选择一个智能体。" @@ -457,7 +458,7 @@ }, "generation": { "plan": { - "dc480d5897": "命令输入为空。" + "dc480d5897": "提示词输入为空。" } } } @@ -537,10 +538,10 @@ "wslDefault": "WSL 默认" }, "sidebarWorktreeActivation": { - "wakeEphemeralVmFailed": "Failed to wake ephemeral VM workspace" + "wakeEphemeralVmFailed": "唤醒临时虚拟机工作区失败" }, "ephemeralVmWorkspaceTarget": { - "projectRootRegistrationFailed": "Failed to register the recipe-created project root on the runtime." + "projectRootRegistrationFailed": "在运行时上注册环境模板创建的项目根目录失败" } }, "hooks": { @@ -559,8 +560,8 @@ "chooseOrAddProjectBeforeWorkspace": "创建工作区前,请选择或添加项目。", "folderWorkspaceCreateFailedTitle": "文件夹工作区创建失败", "folderWorkspaceCreateFailedMessage": "无法创建文件夹工作区。请查看上方错误详情,然后重试。", - "setupAgentStartupPolicySaveFailed": "Failed to save setup startup behavior.", - "5f3d2c8a1b": "Failed to resolve MR base." + "setupAgentStartupPolicySaveFailed": "保存设置启动行为失败", + "5f3d2c8a1b": "解析 MR 基础分支失败" }, "useGlobalFileDrop": { "38c9f034ff": "无法上传删除的文件。", @@ -625,7 +626,7 @@ "a9fb10afca": "终端", "5235c215ca": "选择在“任务”页面和侧栏中显示的任务提供者。", "85f4fd7710": "任务来源", - "ab4b21b58e": "分支命名、基础引用、归因和 Git AI Author。", + "ab4b21b58e": "分支命名、基础引用、署名和 Git AI Author。", "09607cb0fe": "Git 和源代码控制", "33a5e1d597": "连接 GitHub、GitLab、Linear 和源托管服务。", "2b043783ef": "集成", @@ -648,11 +649,11 @@ "f70ac54d38": "AI 提供商账户", "4121f7a0a2": "管理 AI 智能体、设置默认值并自定义命令。", "b49abbd2f7": "智能体", - "dev": "Dev Tools", - "devDescription": "Dev-only tools for exercising UI states.", + "dev": "开发工具", + "devDescription": "仅用于调试 UI 状态的开发工具。", "devBadge": "Dev", - "devSearchNotificationPlayground": "Notification playground", - "devSearchNotificationPlaygroundDescription": "Trigger representative toast and notification UI states.", + "devSearchNotificationPlayground": "通知测试面板", + "devSearchNotificationPlaygroundDescription": "触发有代表性的 toast 和通知 UI 状态。", "devSearchKeywordDev": "dev", "devSearchKeywordToast": "toast", "devSearchKeywordSonner": "sonner", @@ -801,7 +802,7 @@ "d1fa2cf888": "没有PR负责人 SHA 无法发表评论。", "829674460a": "Diff 不可用,因为 PR commit SHA 丢失。", "af924014f8": "已查看", - "2d89a38d9d": "{{value0}} {{value1}} 如图所示", + "2d89a38d9d": "{{value0}} {{value1}} 为已查看", "70e84e3d0b": "没有匹配的评审人。", "1ffce94a8b": "其他人", "c2b21818e1": "建议", @@ -864,22 +865,22 @@ "5c1c973855": "移除评审人", "commentTooLarge": "评论过长,无法安全提交。", "timeline": { - "completed": "as completed", - "notPlanned": "as not planned", - "someone": "someone", - "assigned": "assigned", - "unassigned": "unassigned", - "mentioned": "mentioned this", - "in": "in", - "closed": "closed this", - "reopened": "reopened this", - "moved": "moved this", - "from": "from", - "to": "to", - "activity": "Activity", - "noActivity": "No activity yet." + "completed": "已完成", + "notPlanned": "未计划", + "someone": "某人", + "assigned": "已分配", + "unassigned": "已取消分配", + "mentioned": "提到了这个", + "in": "在", + "closed": "关闭了这个", + "reopened": "重新打开了这个", + "moved": "移动了这个", + "from": "从", + "to": "到", + "activity": "活动", + "noActivity": "暂无活动。" }, - "checkActionRequiredHint": "This check needs a manual action on GitHub (for example, approving the workflow run) before merging is unblocked." + "checkActionRequiredHint": "此检查需要在 GitHub 上执行手动操作(例如批准工作流运行)后才能解除合并阻止。" }, "GitLabItemDialog": { "65e784c1f1": "重新打开", @@ -1170,7 +1171,9 @@ "ephemeralVm": "工作区专属环境", "chooseRunTarget": "选择目标", "noRunTargets": "此项目还没有可用的运行目标。", - "perWorkspaceEnvHint": "根据配方按需创建环境" + "perWorkspaceEnvHint": "通过环境模板按需创建环境", + "branchName": "分支名称", + "branchNamePlaceholder": "feature/my-branch" }, "NewWorkspaceComposerModal": { "createWorktree": "创建工作树", @@ -1217,7 +1220,7 @@ "3912daf310": "此PR尚未报告检查。", "45877f5089": "未找到检查", "85e62c5266": "已启动 AI 智能体处理失败的检查。", - "ddfd42f460": "选择智能体并在启动前编辑完整的命令输入。", + "ddfd42f460": "启动智能体前请查看提示词。", "a053bdd082": "用 AI 修复失败的检查", "1b14d0a69c": "在 GitHub 中打开", "1550675e5f": "此检查没有可用的内联输出。", @@ -1286,7 +1289,7 @@ "d8c3ba91c4": "没有PR负责人 SHA 无法发表评论。", "74660bd80b": "Diff 不可用,因为 PR commit SHA 丢失。", "2e528e1c2d": "已查看", - "ff84e1f54c": "{{value0}} {{value1}} 如图所示", + "ff84e1f54c": "{{value0}} {{value1}} 为已查看", "5ad00c7a0e": "没有匹配的评审人。", "2760fa29a4": "其他人", "828f045847": "建议", @@ -1352,11 +1355,11 @@ "71a3c0f9d2": "启动工作区" }, "QuickOpen": { - "1dbd3f59ff": "手机", + "1dbd3f59ff": "移动", "73b2c581f1": "关闭", - "95fccbae88": "Esc键", - "61b1c871a6": "进行中", - "250e5b2dfb": "进入", + "95fccbae88": "Esc", + "61b1c871a6": "打开", + "250e5b2dfb": "回车", "74e2e1b3e4": "没有匹配的文件。", "722a21e1a8": "正在加载文件...", "1cb6ef47b7": "转到文件...", @@ -1393,13 +1396,13 @@ "TaskPage": { "513cddfa7a": "正在验证...", "ff69a30681": "取消", - "2abe22ef76": "您的令牌通过操作系统钥匙串加密并存储在本地。", + "2abe22ef76": "您的token通过操作系统钥匙串加密并存储在本地。", "246c2b3dd3": "Atlassian 账户设置", - "59c14d34a2": "创建一个令牌", - "b95623e93f": "Atlassian API 令牌", + "59c14d34a2": "创建一个token", + "b95623e93f": "Atlassian API token", "68df347677": "你@example.com", "163df31e0e": "https://example.atlassian.net", - "33fc2bcb30": "使用 Jira Cloud 站点 URL、Atlassian 电子邮件和 API 令牌来浏览议题。", + "33fc2bcb30": "使用 Jira Cloud 站点 URL、Atlassian 电子邮件和 API token来浏览议题。", "60f806ce99": "连接 Jira 站点", "8ff6fdc368": "创建中…", "fc0d8a1fa4": "提交。", @@ -1456,9 +1459,9 @@ "be8cf68d9f": "查看议题", "67662ade50": "项目议题", "6244a02f46": "在 Linear 中打开", - "606a85c774": "进行中", + "606a85c774": "开放", "5e8061b088": "从 {{value0}} {{value1}} 开始工作区", - "592a55611b": "尝试选择更多的团队或者刷新;团队搜索器适用于当前获取的议题集。", + "592a55611b": "尝试选择更多的团队或者刷新;团队筛选条件适用于当前获取的议题集。", "618107fab3": "没有获取到与所选团队匹配的议题", "903c7af49f": "未找到 Linear 议题", "5ed38a49e5": "查看下面的工作区错误,然后刷新。", @@ -1643,10 +1646,10 @@ "e224d76876": "MR", "bbec4717ee": "mr", "d6d08c1650": "选择一个项目以查看 GitLab 工作项。", - "f294c500ef": "没有 GitLab 工作与此搜索器匹配。", - "cd7dc432a3": "没有 GitLab MR 与此搜索器匹配。", + "f294c500ef": "没有 GitLab 工作与此筛选条件匹配。", + "cd7dc432a3": "没有 GitLab MR 与此筛选条件匹配。", "171b7739d8": "mrs", - "a9f256ecea": "没有与此搜索器匹配的 GitLab 议题。", + "a9f256ecea": "没有与此筛选条件匹配的 GitLab 议题。", "2007e14d95": "GitLab", "456b8512da": "合并请求", "03da966159": "选择一个项目,以便我们可以向 GitLab 进行身份验证。", @@ -1657,7 +1660,7 @@ "e104fa3d3d": "从议题开始工作区", "2193a99ec1": "打开议题所附的工作区", "7deb9e59a5": "更多 PR 操作", - "7753652524": "恢复", + "7753652524": "继续", "e4b29c5bcf": "从 PR 启动工作区", "67d881244c": "恢复关联 PR 的工作区", "6430594b18": "作者不详", @@ -1765,11 +1768,11 @@ "ac037cfac2": "手机", "75499e01d9": "关闭", "66b5a67bee": "Esc键", - "45def60329": "进行中", + "45def60329": "开放", "f65d992a11": "进入", "c5081f2814": "当前工作树", "52404f8096": "当前选项卡", - "739bda980c": "主工作树", + "739bda980c": "主要", "556e7232ca": "当前", "684e8d7bc2": "收集您最近的工作树和打开的选项卡。", "ff908adfe9": "加载跳跃目标", @@ -1831,7 +1834,7 @@ "820fd21663": "此PR已关闭", "4f976d3450": "已关闭", "62eb8d39da": "此PR已合并", - "83ecdbb4a6": "合并", + "83ecdbb4a6": "已合并", "331ebe1170": "将此PR添加到 GitHub 合并队列", "b169f943e1": "准备好后合并", "62703b1dc4": "为此PR启用了 GitHub 自动合并", @@ -1875,7 +1878,7 @@ "8d669084f6": "受限", "6efdc0d920": "草稿", "d0d0e13a5a": "PR", - "af5d8c912a": "限制物品", + "af5d8c912a": "受限条目", "bb7ebc11e3": "添加编号", "9cb1a0c984": "添加文字", "2e26a06c70": "添加标签", @@ -1920,10 +1923,10 @@ "c3b81ddea2": "DRAFT_ISSUE" }, "ProjectViewList": { - "989f81dc2a": "专栏", + "989f81dc2a": "列", "f949f5b2b7": "配置列", "eddfc7a794": "按 {{value0}} 排序", - "4f57d2e0b1": "没有项目与此视图的搜索器匹配。" + "4f57d2e0b1": "没有项目与此视图的筛选条件匹配。" }, "ProjectViewWrapper": { "463f1205c0": "加载项目视图", @@ -1933,7 +1936,7 @@ "55de4fb57a": "{{value0}}。 {{value1}} 在 {{value2}} 提交功能请求。", "2edf5e7e77": "{{value0}} — Orca 尚不支持 {{value1}} 项目视图。在 {{value2}} 提交功能请求。", "7245c3d7ac": "清除搜索", - "c5bc7ec007": "查看搜索器:{{value0}}", + "c5bc7ec007": "视图筛选条件:{{value0}}", "840c268665": "添加仓库", "dffa899f36": "取消", "7037c8f5f1": "存储库不在 Orca 中", @@ -1941,7 +1944,7 @@ "71fb69926c": "刷新", "a8fa0d2bf5": "刷新中", "fd15491034": "在 GitHub 中打开视图", - "22df63c393": "您的令牌无法获取子议题数据。", + "22df63c393": "您的token无法获取子议题数据。", "067119985c": "GitHub 搜索,例如 assignee:@me is:open", "1850fceac8": "{{value0}}/{{value1}} 未添加到 Orca。添加它以开始工作,或在 GitHub 中打开。", "1aa7c952b9": "项目视图", @@ -2010,9 +2013,9 @@ "9d0f2eda6d": "标签", "01f3f3d161": "作者", "13b3ac0a84": "状态", - "79c54552f7": "搜索器", - "8a2ffbf9b3": "删除 {{value0}} 搜索器", - "19bb6f115f": "reviewed-by" + "79c54552f7": "筛选条件", + "8a2ffbf9b3": "删除 {{value0}} 筛选条件", + "19bb6f115f": "已审查" }, "PRFilterPickers": { "fdf387297c": "清除 (", @@ -2026,7 +2029,7 @@ "de26e2eb06": "无标签", "458ea3602b": "没有作者", "b69fa4fa20": "返回", - "30ebb6ca44": "清除所有搜索器", + "30ebb6ca44": "清除所有筛选条件", "8177eda37e": "筛选", "ea3416d646": "负责人", "b1d9fdea08": "标签", @@ -2038,7 +2041,7 @@ "e0002f1eba": "已选择", "2b2f019091": "任意状态", "0fd3249e2e": "已关闭", - "d78b60b5c2": "进行中", + "d78b60b5c2": "开放", "bd162b7d5a": "合并", "2e639b84fa": "评审人", "712c5abdbf": "标签", @@ -2056,11 +2059,11 @@ "d12d3d6f33": "刷新 GitHub API 预算", "d5e5de9070": "Orca 通过 GitHub CLI 使用 REST、搜索和 GraphQL。", "58c5f88216": "GitHub API 预算", - "6da1858354": "左·重置于", + "6da1858354": "剩余 · 重置于", "f42790d150": "的", "01f7323e58": "GraphQL API", "1daf0f22a9": "GraphQL", - "1f2f28a4de": "搜索接口", + "1f2f28a4de": "搜索 API", "c377a4f06a": "搜索", "c392c749a6": "REST API", "bb227706a6": "REST", @@ -2201,13 +2204,13 @@ "sound": { "options": { "e38b0a2e68": "嘟", - "0acd3d384e": "唠叨", + "0acd3d384e": "咔嗒", "79919c832d": "丁", "2b44847d8d": "布洛普", "020826ef17": "声纳", "588c90487d": "光点", "1e4b81d892": "扑通", - "86af8d938c": "奉", + "86af8d938c": "咚", "80f7cc95b3": "二音", "017abebfa6": "系统默认值" } @@ -2224,7 +2227,7 @@ "767951265d": "创建工作树时出现问题。", "vmProvisioningTitle": "正在预配虚拟机", "cancelProvisioning": "取消", - "vmProvisioningLogEmpty": "正在等待配方输出…" + "vmProvisioningLogEmpty": "正在等待环境模板输出…" } } }, @@ -2371,7 +2374,7 @@ "ca414324ee": "命令文本", "dc921c17ee": "提示", "5b3f634a55": "添加快捷命令", - "f9b184fc16": "编辑快速命令", + "f9b184fc16": "编辑快捷命令", "6751598542": "编辑" }, "TerminalQuickCommandDialogFooter": { @@ -2443,16 +2446,16 @@ "98bccf4fa2": "向下拆分终端", "20e565d865": "向右拆分终端", "8a7ddb8b8a": "分叉智能体会话...", - "0a82b0608c": "添加快速命令...", - "9528a65ef8": "没有快速命令", - "3ce594a4a0": "全球的", + "0a82b0608c": "添加快捷命令...", + "9528a65ef8": "没有快捷命令", + "3ce594a4a0": "全局的", "ec85df5914": "快捷命令", "0a917b591a": "粘贴", "f3eeb1de13": "复制", "c2f0b72b8d": "插入", "925f49f210": "展开窗格", "df766809e0": "折叠窗格", - "cff67afad1": "Copy Context" + "cff67afad1": "复制上下文" }, "TerminalErrorToast": { "e4aa243f8c": "重新启动守护进程", @@ -2500,7 +2503,7 @@ "88e34d00eb": "顶级会话分支在新工作区中打开", "fd3d12a1e1": "无法创建 fork 工作区。", "38e41edc6e": "该工作区无法分叉为 git 工作树。", - "f867385bb5": "找不到此分支的源工作区。", + "f867385bb5": "找不到此 fork 的源工作区。", "046e8d853c": "没有要 fork 的终端上下文", "c00421d320": "已复制分叉上下文。启动智能体并粘贴它以启动分叉。", "f62b40e2c7": "没有可复制的终端上下文", @@ -2585,8 +2588,10 @@ "localWorkspacesOnly": "从历史恢复仅支持本地工作区。", "openLocalWorkspace": "恢复会话前请先打开一个本地工作区。", "sessionQueued": "会话已排队", - "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", - "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session." + "runtimeWorkspacesUnsupported": "在远程运行时托管的工作区中无法从历史记录恢复会话。", + "openSupportedWorkspace": "在恢复会话之前,请先打开一个本地或 SSH 工作区。", + "localSessionSshWorkspaceUnsupported": "此会话的历史记录存储在本机上,因此无法在 SSH 工作区中恢复。请改为将其拖放到本地工作区。", + "sessionHostMismatchUnsupported": "此会话属于其他主机。请将其拖放到同一主机上的工作区。" }, "TabGroupDropOverlay": { "paneColumnLabel": "新建拆分" @@ -2654,11 +2659,11 @@ "845576bed1": "青色", "be905e9b0a": "绿色的", "69682e2ce4": "黄色的", - "a47629b3cf": "橙子", + "a47629b3cf": "橙色", "620aec6729": "红色的", "03cf6dab1a": "粉色的", "c2d8b0991f": "紫色的", - "cb3eadefd2": "Blue", + "cb3eadefd2": "蓝色", "20baa43c05": "没有任何", "60f958ec75": "引脚标签", "417722e9c2": "取消固定标签", @@ -2691,16 +2696,16 @@ "39676a184c": "打开任何文件、URL、智能体..." }, "TabBarQuickCommandsButton": { - "a2c7a33831": "添加快速命令...", + "a2c7a33831": "命令", "20bbd75896": "无命令", "b82e237a4b": "更快捷的命令", - "85482c57bc": "运行快速命令", - "b775303755": "运行快速命令:{{value0}}", + "85482c57bc": "运行快捷命令", + "b775303755": "运行快捷命令:{{value0}}", "196593b6a9": "删除 {{value0}}", "15529ede69": "编辑 {{value0}}", - "1d411fb6a5": "为此仓库保存快速命令", + "1d411fb6a5": "为此仓库保存快捷命令", "8f1e971966": "添加快捷命令", - "3220e2da27": "该快速命令将从您保存的列表中删除。", + "3220e2da27": "该快捷命令将从您保存的列表中删除。", "e8e1a52edb": "删除“{{value0}}”?", "37e1bb90ce": "运行:{{value0}}", "77ac113df0": "开始 {{value0}}: {{value1}}", @@ -2841,17 +2846,14 @@ "41ae4fa725": "正在结束…", "138b99bd80": "本次会话", "888dad8c55": "加载中…", - "56b6888304": "运行时服务器隐藏本地资源使用情况。", - "14ff448686": "不可用于运行时服务器", "6d9793d4bc": "资源管理器 - 终端", - "6a822b06a7": "资源管理器", "ca95d077db": "守护进程无法访问", "a82253b458": "删除工作区。", "946724a70a": "主工作区无法删除。", "16bc3c998a": "删除工作区 {{value0}}", "0f9e50eb07": "其他", "d406915b78": "渲染器", - "81cd37af99": "主要的", + "81cd37af99": "主进程", "fa6d36758d": "终止会话 {{value0}}", "b8f4a2c1d0e3": "{{value0}} 个孤立项", "c7e3b1a0d9f2": "终止 {{value0}} 个孤立终端", @@ -2903,7 +2905,9 @@ "c35af53b73": "登录", "f19a63e7cd": "登录查看使用情况", "5c938d39ac": "% 周", + "54e8d6bb2d": "% Fable", "d79c3362c4": "% 5小时", + "a79c64f87e": "Fable", "8295903d17": "切换后继续旧对话之前,请重新启动实时 Claude 终端。", "c98ea88392": "没有其他账户", "9332ba8684": "切换到", @@ -2932,7 +2936,9 @@ "972a1ff497": "重置 Codex 限额?", "6d1042aa6f": "这会为当前账户使用一次 Codex 速率限制重置额度,并立即重置所有符合条件的用量窗口。", "f077f586db": "不再询问", - "c0e972d726": "取消" + "c0e972d726": "取消", + "06741a2f3d": "打开 MiniMax 使用量详情", + "3bbf140864": "MiniMax 使用量" }, "StatusBarUsageEmptyCta": { "828c764a79": "连接账户", @@ -3019,7 +3025,7 @@ "ec7b076a75": "保留:git 未检查", "7ab8d7e2d7": "保留:更改的文件", "7f7895514e": "保持:活跃", - "2b501ee391": "保留:主要", + "2b501ee391": "保留:主工作树", "39801484e0": "失败的", "33653dbac2": "正在删除", "52b629eb84": "已更新", @@ -3067,6 +3073,7 @@ "6d6df77f41": "无可用数据", "7f7f208060": "每月", "252c096536": "每周", + "a79c64f87e": "Fable", "94038ad2fa": "会话", "2c35eca8d4": "无法获取使用情况", "1292d4f2ee": "不可用", @@ -3103,7 +3110,7 @@ "61c58f8976": "缓存读取", "7d2efeff5e": "输出", "d7fb787e6b": "输入", - "a7902d3c1d": "令牌", + "a7902d3c1d": "token", "059945f71d": "按天汇总的输入、输出、缓存读取和缓存写入。", "c9f7cd30e9": "每日使用量" }, @@ -3119,19 +3126,19 @@ "7e76c84153": "最近的会话", "32176e1d44": "轮次", "02a046792e": "会话 •", - "f97435845c": "主要项目:", + "f97435845c": "用量最高的项目:", "7dc9e5613b": "按项目", "c3fdbc5474": "常用模型:", "0f394c24e3": "按模型", - "51ae85fa00": "缓存复用率 = 缓存读取令牌 /(输入令牌 + 缓存读取令牌)。", + "51ae85fa00": "缓存复用率 = 缓存读取token /(输入token + 缓存读取token)。", "b26d4ddb58": "预估 API 等效成本", "0f3e696ca9": "会话/轮次", "8cc23be4a3": "零缓存读取轮次", "1634c4f404": "缓存复用率", "b786fb4a70": "缓存写入", "268cf0af51": "缓存读取", - "2b8a2f14aa": "输出令牌", - "ea71fae8fc": "输入令牌", + "2b8a2f14aa": "输出token", + "ea71fae8fc": "输入token", "7dde9331fd": "此范围内尚无本地 Claude 使用记录。", "424cd50412": "启用 Claude 使用量分析", "8d18bbb771": "刷新", @@ -3141,7 +3148,7 @@ "dd29209b21": "筛选", "e9bf9fce0e": "Claude 使用量选项", "6afacbee37": "Claude 使用量跟踪", - "0cb1a36d7d": "读取本地 Claude 使用日志,显示令牌、模型和会话统计。", + "0cb1a36d7d": "读取本地 Claude 使用日志,显示token、模型和会话统计。", "5ce4842c2c": "所有本地 Claude 使用情况", "4f8368c272": "仅 Orca 工作树", "cfe2282ffa": "未知", @@ -3157,7 +3164,7 @@ "c646e1783c": "缓存输入", "7b596a88b2": "输出", "99a91d3143": "输入", - "e4bdcf0071": "令牌", + "e4bdcf0071": "token", "c756cda6a8": "按天汇总的输入、缓存输入、输出和推理。", "609aa96e8b": "每日使用量" }, @@ -3173,17 +3180,17 @@ "0cb0983c07": "最近的会话", "79a69522a5": "事件", "bf1bf2f674": "会话 •", - "829ee743f2": "主要项目:", + "829ee743f2": "用量最高的项目:", "b98718aaab": "按项目", "95d2d89285": "常用模型:", "5a0d1d69cd": "按模型", - "94ac1f1ee7": "显示推理令牌便于查看,但成本仅按未缓存输入、缓存输入和输出计算。", + "94ac1f1ee7": "显示推理token便于查看,但成本仅按未缓存输入、缓存输入和输出计算。", "1a18fbd56b": "预估 API 等效成本", "907b31865f": "会话/事件", "6e18146e9b": "推理输出", "a9ac0f423a": "缓存输入", - "5d8eba87bd": "输出令牌", - "e365eaa6fd": "输入令牌", + "5d8eba87bd": "输出token", + "e365eaa6fd": "输入token", "4c865393b4": "此范围内尚无本地 Codex 使用记录。", "f7c1affbd5": "启用 Codex 使用量分析", "3022cda443": "刷新", @@ -3193,12 +3200,12 @@ "1af1a39b2f": "筛选", "70b5b8581f": "Codex 使用量选项", "408210470c": "Codex 使用量跟踪", - "13badcd8f2": "读取本地 Codex 使用日志,显示令牌、模型和会话统计。", + "13badcd8f2": "读取本地 Codex 使用日志,显示token、模型和会话统计。", "4fe8820098": "所有本地 Codex 使用情况", "201766b754": "仅 Orca 工作树", "bf6cf2d4dd": "未知", "ae255c3dba": "不适用", - "247c93ca92": "• 推断定价", + "247c93ca92": "• 推理定价", "8a6655f7a2": "• 上次扫描错误:{{value0}}", "rangeLast7Days": "过去 7 天", "rangeLast30Days": "过去 30 天", @@ -3226,18 +3233,18 @@ "7e9433469a": "会话/活动", "5a65d68b77": "推理输出", "603504ee3b": "缓存输入", - "7aa4d8ce35": "输出令牌", - "d637a892ed": "输入令牌", + "7aa4d8ce35": "输出token", + "d637a892ed": "输入token", "bb6363e08c": "尚未找到此范围的本地 OpenCode 用法。", "f04131b3be": "启用 OpenCode 使用情况分析", "603cd138dc": "刷新", "bed558df0b": "刷新 OpenCode 使用情况", "b5ed5c9fd0": "范围", "40d283c837": "范围", - "01583b30aa": "搜索器", + "01583b30aa": "筛选条件", "230d6de108": "OpenCode 使用选项", "bea80ceae0": "OpenCode 使用情况跟踪", - "b8b3522436": "读取本地 OpenCode 使用日志以显示令牌、模型和会话统计信息。", + "b8b3522436": "读取本地 OpenCode 使用日志以显示token、模型和会话统计信息。", "144a6050e9": "所有本地 OpenCode 使用情况", "e04c58327c": "仅 Orca 工作树", "362231082f": "未知", @@ -3257,10 +3264,10 @@ }, "ShareUsageCard": { "4a4c6c79a3": "会话·", - "66c83284cf": "每日令牌", - "b760c0b622": "顶级模特", - "2d9eb39264": "令牌总数", - "beb6f24f37": "预计。成本", + "66c83284cf": "每日token", + "b760c0b622": "顶级模型", + "2d9eb39264": "token总数", + "beb6f24f37": "预计成本", "da62578d9d": "用法", "0eb31e79ee": "Orca IDE", "960324e9b8": "事件", @@ -3286,16 +3293,16 @@ "444585cb41": "有数据", "ecb0cd8a4c": "已启用 -", "33f7b043d2": "供应商", - "60002bb22f": "尚未找到本地 Claude、Codex 或 OpenCode 使用情况。概览将在下一个智能体会话写入令牌日志后填充。", + "60002bb22f": "尚未找到本地 Claude、Codex 或 OpenCode 使用情况。概览将在下一个智能体会话写入token日志后填充。", "70f36452d4": "缓存共享", "327603fe8b": "活跃天数", - "0eaf937335": "预计。成本", - "3887b94ce5": "令牌总数", + "0eaf937335": "预计成本", + "3887b94ce5": "token总数", "2d13e57f72": "启用OpenCode", "2f1ee2878b": "启用 Codex", "0ea0cae435": "启用 Claude", - "6c00c46815": "启用提供商以扫描本地智能体日志并构建组合的令牌账本。", - "49405ccc8d": "开始跟踪令牌", + "6c00c46815": "启用提供商以扫描本地智能体日志并构建组合的token账本。", + "49405ccc8d": "开始跟踪token", "ca6bc5fded": "刷新", "e06d1baf5c": "刷新使用概览", "c760c481c5": "使用概述", @@ -3343,23 +3350,23 @@ }, "sections": { "9564a3b21b": "会话 -", - "32330a6e66": "{{value0}}:{{value1}} 令牌", + "32330a6e66": "{{value0}}:{{value1}} token", "57d1448ef8": "启用", "f6df0d7d6d": "更多的", "1dd166c920": "较少的", - "52d9221dc0": "近期令牌活动热图", + "52d9221dc0": "近期token活动热图", "c424eb3f8e": "最好的:", - "f28ff1f852": "近期 Claude、Codex 和 OpenCode 的综合令牌活动。", + "f28ff1f852": "近期 Claude、Codex 和 OpenCode 的综合token活动。", "69e2b50427": "每日强度", - "3a795542fa": "综合令牌分布", + "3a795542fa": "综合token分布", "e65084cb4b": "推理", - "3bc4a01b24": "汇总已启用提供商的输入、输出和缓存令牌。", - "4ff104da47": "令牌分布", + "3bc4a01b24": "汇总已启用提供商的输入、输出和缓存token。", + "4ff104da47": "token分布", "0015facc1f": "缓存", "7f270458af": "输出", "9365b14a4e": "新输入", "3de9bf87fc": "还没有型号", - "6762f6a682": "令牌", + "6762f6a682": "token", "a7f937fb29": "{{value0}} 个会话 - {{value1}} {{value2}}", "c8f3a2d1e0b4": "轮次", "d9a4b3e2f1c5": "事件" @@ -3368,7 +3375,7 @@ }, "UsageBreakdownSection": { "7765a4c3e1": "n/a", - "247c93ca92": "• 推断价格" + "247c93ca92": "• 推理价格" }, "UsageSessionsTable": { "1afc25eb06": "轮次", @@ -3433,7 +3440,7 @@ "9963dff6d3": "没有找到描述。", "995fde8337": "无法显示技能文件", "ab5b777350": "检查本地主目录、存储库、捆绑和插件技能文件夹。", - "08a321a984": "调整搜索或搜索器。", + "08a321a984": "调整搜索或筛选条件。", "4acd6d68ec": "未找到本地技能", "6a62a0168c": "没有匹配项", "cd7893fbc1": "正在扫描技能", @@ -3485,7 +3492,9 @@ "8401a7a0d0": "1 个仓库", "d4f1df62ef": "{{value0}} 个仓库", "b4263a2ac4": "在 {{value1}} 中找到 {{value0}}。", - "24eda6c8b2": "正在扫描... {{value0}}" + "24eda6c8b2": "正在扫描... {{value0}}", + "6149d5203f": "未选择任何仓库。改为打开父文件夹,即可在没有 Git 功能的情况下使用编辑器、终端和搜索。", + "e52454b7f6": "作为文件夹打开" }, "AddRepoRemoteStep": { "5b205b5281": "停止扫描", @@ -3732,7 +3741,7 @@ }, "SidebarFilter": { "e3b3898218": "添加项目", - "92a23e6d07": "重置搜索器", + "92a23e6d07": "重置筛选条件", "81ded53722": "SSH", "b9e8802e73": "没有匹配的项目", "779b7ba05d": "清除", @@ -3741,9 +3750,9 @@ "e5cb32a898": "隐藏默认分支", "638a2d221d": "隐藏休眠项", "f506a1262a": "搜索工作区", - "75405270ed": "编辑搜索器({{value0}} 活动)", + "75405270ed": "编辑筛选条件({{value0}} 个已启用)", "489d1c8c9f": "搜索项目...", - "ee240a39eb": "编辑搜索器", + "ee240a39eb": "编辑筛选条件", "automationCreated": "隐藏自动化创建的工作区" }, "SidebarHeader": { @@ -3771,7 +3780,7 @@ "SidebarRepositoryFilterSection": { "d3a9c4cea1": "清除", "7679f0c268": "项目", - "f10ca29601": "删除 {{value0}} 搜索器", + "f10ca29601": "删除 {{value0}} 筛选条件", "2656053db4": "SSH", "83a820fa71": "筛选项目...", "5a273fbfce": "添加项目...", @@ -3806,18 +3815,18 @@ "SidebarWorkspaceFilterSection": { "c3fa13dc2e": "隐藏默认分支", "ed1611b65b": "隐藏休眠项", - "82594419ba": "搜索器", + "82594419ba": "筛选条件", "automationCreated": "隐藏自动化创建的工作区" }, "SidebarWorkspaceOptionsMenu": { "95c9754653": "智能体活动布局", - "3d4b9c4997": "徘徊", + "3d4b9c4997": "悬停", "ba87080fb7": "显示属性", "320b675c9a": "卡片布局", "newCardDisplay": { "title": "卡片显示" }, - "09faabd875": "项目订单", + "09faabd875": "项目排序", "7bada3b1ab": "排序方式", "dc0bb670bc": "分组依据", "9919ae1082": "工作区选项", @@ -3872,7 +3881,7 @@ "4afcca1d24": "重新连接", "11552bf786": "SSH 已断开", "cb5938ae79": "正在重新连接...", - "disconnected": "This remote repository is not connected.", + "disconnected": "此 SSH 主机未连接。", "reconnecting": "正在重新连接远程主机...", "reconnectionFailed": "重新连接到远程主机失败。", "authFailed": "远程主机身份验证失败。" @@ -3920,9 +3929,9 @@ "a88c92d0e3": "{{value0}}/{{value1}} 已存在。", "6f09f58541": "删除工作区", "0777de5970": "主工作树(原始克隆目录)", - "0f33af979b": "部分结帐。这些路径之外的文件不在磁盘上。", - "4f964d5e8c": "疏", - "7d517f82e2": "主工作树", + "0f33af979b": "部分检出。这些路径之外的文件不在磁盘上。", + "4f964d5e8c": "稀疏", + "7d517f82e2": "主要", "4eba2ea99e": "自动命名失败。点击查看详情。", "74522ee457": "重命名失败", "02e19349f4": "自动重命名失败:查看错误", @@ -3933,8 +3942,8 @@ "8cb634cda6": "显示子工作区", "01f45d3d8a": "侧边栏", "93aebe4529": "文件夹", - "0d224eff10": "主要工作树", - "ca74db7550": "通过 SSH 远程项目", + "0d224eff10": "主工作树", + "ca74db7550": "SSH 主机上的项目", "021538e1d1": "SSH 已断开连接", "runtimeHostDisconnected": "服务器已断开连接", "runtimeHostProject": "Orca 服务器上的项目", @@ -4029,7 +4038,7 @@ "changeParentWorkspace": "更改父工作树...", "setParentWorkspace": "设置父工作树...", "workspaceSection": "工作区", - "primaryDeleteDisabled": "主要工作树无法删除,请改为删除项目。", + "primaryDeleteDisabled": "主工作树不能删除。请改为移除项目。", "deleteWorktree": "删除工作树" }, "WorktreeList": { @@ -4037,7 +4046,7 @@ "bc1460beb3": "更新侧栏中显示的组名称。", "13757c053c": "新项目组", "f9dc6cc5d3": "重命名项目组", - "370c6a55dd": "清除搜索器", + "370c6a55dd": "清除筛选条件", "b7acbf038b": "未找到工作区", "0c6ee14f23": "孩子", "5fc9d1891b": "正在删除...", @@ -4073,7 +4082,8 @@ "hostAuthNeeded": "需要身份验证", "hostDisconnected": "已断开连接", "failedNestWorkspace": "无法嵌套工作区", - "sidebarRowMissing": "目标不再存在" + "sidebarRowMissing": "目标不再存在", + "failedUnnestWorkspace": "Failed to unnest workspace" }, "WorktreeMetaDialog": { "3db0a2a593": "取消", @@ -4188,7 +4198,7 @@ "669e12dd97": "本地文件夹和 Git 存储库", "ffc769ca29": "删除文件夹以添加项目", "740e8d0d46": "使用“添加项目”作为服务器路径", - "e344666fb8": "服务器运行时处于活动状态", + "e344666fb8": "服务器运行时处于激活状态", "d0f8943f8b": "准备项目添加流程", "18d3cf40e9": "检查文件夹" } @@ -4229,10 +4239,10 @@ "6c1efa2cf8": "评审中", "6b8285b8dd": "完成", "93ac840dcb": "已阻塞", - "2c19d1db33": "Play", + "2c19d1db33": "播放", "111db162bf": "已暂停", "642da473f2": "警报", - "6380517b10": "Flag", + "6380517b10": "标记", "251c817bdd": "定时器", "409528031f": "评审", "5f9ca31a84": "等待中", @@ -4243,14 +4253,14 @@ "1a9383112b": "Conductor 进度", "caebe3c10f": "Conductor 评审", "895f381714": "Conductor 完成", - "caabd5ca85": "Zinc", - "7adb43ecf0": "Rose", - "ddf25b6262": "Emerald", - "7cebab6d4a": "Amber", - "1b81da243a": "Violet", - "6437a8c253": "Sky", - "fc3b92756c": "Blue", - "52e3c6e2a4": "Neutral" + "caabd5ca85": "锌", + "7adb43ecf0": "玫瑰", + "ddf25b6262": "翡翠", + "7cebab6d4a": "琥珀", + "1b81da243a": "紫罗兰", + "6437a8c253": "天空", + "fc3b92756c": "蓝色", + "52e3c6e2a4": "中性" } }, "worktree": { @@ -4479,7 +4489,7 @@ "saving": "正在保存...", "save": "保存", "label": "标签", - "sshLabelPlaceholder": "Dev box", + "sshLabelPlaceholder": "开发机", "sshHost": "主机或别名", "sshHostPlaceholder": "deploy@server:22", "username": "用户名", @@ -4488,7 +4498,7 @@ "identityFile": "身份文件", "identityFilePlaceholder": "~/.ssh/id_ed25519(可选)", "serverName": "服务器名称", - "serverNamePlaceholder": "Dev box", + "serverNamePlaceholder": "开发机", "pairingCode": "配对码", "pairingCodePlaceholder": "orca://pair?code=...", "pairingHelpPrefix": "在服务器上运行", @@ -4547,6 +4557,7 @@ "ae3b21eb6c": "opencode.ai/workspace/wrk_…/go", "51c9104e13": "登录 opencode.ai 后在 URL 中找到此内容(例如", "b398b834c9": "清除", + "316ca4e610": "忘记 Cookie", "a122332371": "wrk_…(留空自动查找)", "dbdb0b0bd8": "工作区 ID 覆盖", "d70a5287a4": "如果自动查找失败,可选的工作区 ID 覆盖。", @@ -4555,7 +4566,7 @@ "8951c5309f": "auth=Fe26.2**…", "338820326a": ")或完整 cookie 请求头(例如", "922b51e02d": "Fe26.2**…", - "0023cc336e": "粘贴原始令牌值(例如", + "0023cc336e": "粘贴原始token值(例如", "a7e38affcd": "Fe26.2**… token 或 auth=Fe26.2**… header", "67e3c33670": "OpenCode Go 会话 cookie", "b2b1aa936d": "粘贴您的 opencode.ai 会话 cookie 以进行速率限制获取。", @@ -4595,7 +4606,7 @@ "b15ce90870": "{{value0}} -> {{value1}}。在继续旧会话之前重新启动实时 Claude 终端。", "f921d32606": "Claude 账户已更新。", "5bf8764953": "Codex 账户更新失败。", - "9baf45d071": "此设备", + "9baf45d071": "当前设备", "2358ac71d2": "WSL 默认值", "ad47a33f72": "正在加载 WSL", "8619f9afa9": "WSL", @@ -4612,7 +4623,47 @@ "75ca9b718e": "Codex 报告活动账户需要重新登录。在开始新的 Codex 会话之前重新对其进行身份验证。", "b11078a9c2": "wsl", "350b2a1aa7": "用你已有的", - "e05d0ff737": "使用您当前 {{value0}} Claude 登录名。" + "e05d0ff737": "使用您当前 {{value0}} Claude 登录名。", + "2f24f244a4": "需要 MiniMax Cookie。", + "8e6f0cb1d8": "MiniMax Cookie 未保存。", + "8d61637a77": "MiniMax Cookie 已保存。", + "b43e761fe5": "MiniMax Cookie 更新失败。", + "5d63bbfbec": "MiniMax", + "15e831350e": "从 platform.minimax.io 配置 MiniMax 使用量跟踪。", + "21d6eb141e": "MiniMax 会话 Cookie", + "33bba5ad83": "粘贴 MiniMax 会话 Cookie 以在本地获取速率限制。", + "73ea15f24b": "已保存", + "23afe8f226": "未保存", + "566d9a99ab": "_token=…; minimax_group_id_v2=…", + "f38b9cc4bd": "替换", + "590a3130f9": "保存", + "79418c782a": "在浏览器中打开 platform.minimax.io/console/usage 并登录,然后从 DevTools(网络 → 任一 remains 请求 → Cookie)复制 Cookie 请求头。", + "9dd50d3f75": "高级", + "174fb408f9": "除非 MiniMax 使用量刷新指向了错误的工作区或模型,否则请保持这些默认值。", + "bf160bb6c0": "Group ID 覆盖", + "b1e2743313": "可选。留空将使用 Cookie 中的 minimax_group_id_v2。", + "0747d6391a": "使用 Cookie 中的 Group ID", + "4ff2af7524": "使用模型名称", + "5cf4b0f85f": "可选的逗号分隔的模型名称。除非 MiniMax 返回模型特定错误,否则请保持为 general。", + "3c92b0d31c": "general", + "0d8e77bc40": "打开控制台", + "0b8c1c7e02": "已存储在本地", + "1fd1b1b6b4": "未设置 Cookie", + "5e08b0fe57": "存储在本地,仅发送到 platform.minimax.io 以刷新使用量。", + "43d7a45b97": "如何复制", + "b8a4f21c3e": "粘贴来自 DevTools 的 Cookie 请求头", + "53f7b8c7a2": "上次刷新: {{value0}}", + "31d24a4e87": "在浏览器中退出登录后,Cookie 将过期。", + "3a30aaf526": "刚刚", + "f5d8d2a6a1": "在浏览器中打开 platform.minimax.io/console/usage 并登录。", + "24560fe830": "打开 DevTools。", + "4cab0fa42d": "转到“网络”选项卡并启用“保留日志”。", + "bee4e63e1c": "重新加载页面。", + "87f814af6f": "按 remains 筛选并选择 coding_plan/remains 请求。", + "435df0ee51": "在请求头中,复制 Cookie 的值。", + "7492fb3bba": "在此粘贴并点击保存。", + "9fec52de4b": "如何复制 Cookie", + "4e32e030b2": "存储在本地。Orca 仅将其发送到 platform.minimax.io 以刷新使用量。" }, "AdvancedPane": { "40b29e0bf3": "重新启动", @@ -4703,9 +4754,9 @@ "3057983501": "未分配", "0cd9b8228f": "选择 Dock 和窗口切换器中显示的应用程序图标。", "ca1590d42f": "应用程序图标", - "61d842eca0": "在侧边栏中显示 Orca手机端 快捷方式。它仍然可以从 Toolbox 中获取。", - "9da1020447": "显示 Orca手机端 按钮", - "5db6ba961f": "在左侧边栏顶部显示 Orca手机端 按钮。", + "61d842eca0": "在侧边栏中显示 Orca 手机端 快捷方式。它仍然可以从工具箱中获取。", + "9da1020447": "显示 Orca 手机端按钮", + "5db6ba961f": "在左侧边栏顶部显示 Orca 手机端按钮。", "fa882a3e6b": "在左侧边栏顶部显示“自动化”按钮。", "511f270ebb": "显示自动化按钮", "661942ab7f": "在左侧边栏顶部显示“任务”按钮。", @@ -4727,7 +4778,7 @@ "f687711a9b": "缩放整个应用程序界面。使用", "5e6d7aba8d": "用户界面缩放", "622e1c3465": "缩放整个应用程序界面。", - "fd89b5487c": "光", + "fd89b5487c": "浅色", "7d26ccabe8": "深色", "fb0e0b4453": "系统", "932ff1fbff": "主题", @@ -4755,12 +4806,12 @@ "windowSidebarSummary": "侧边栏、状态栏和文件浏览器", "statusBarCount": "显示 {{value0}} 个指示器。", "gitIgnoredGlossary": "与 .gitignore 匹配的文件。", - "statusBarDescription": "Choose which indicators appear in the status bar." + "statusBarDescription": "选择状态栏中显示哪些指示器。" }, "AutoRenameBranchFromWorkSetting": { "1626524572": "Nautilus", "0de9fda203": "放弃", - "c71770c455": "{基本提示}", + "c71770c455": "{basePrompt}", "f19a56498d": ";您的分支前缀设置仍然适用。", "800edb1e54": "修复登录流程", "5d569f5199": "。 Orca 仅生成最后一段,例如", @@ -4769,7 +4820,7 @@ "9c9b54e4ea": "内置分支名称提示", "69bf4830c2": "包括 Orca 的", "9241b59bf5": "使用", - "a869d0edd8": "分支名称命令模板", + "a869d0edd8": "分支名称提示词模板", "e784ea62dc": "高级", "d9b65054ef": ")到总结任务的简短名称。只有 Orca 命名的分支才会被重命名,并且在推送后不会被重命名。", "12ea4a408d": "当智能体开始在新工作区中工作时,Orca 会重命名其自动生成的分支(例如", @@ -4779,8 +4830,8 @@ "cfd82406dd": "保存中...", "40e7be7850": "已保存", "7c7e34a66d": "未保存的更改", - "a4fa380b67": "{助理消息}", - "2ee2779c05": "{第一提示}" + "a4fa380b67": "{assistantMessage}", + "2ee2779c05": "{firstPrompt}" }, "AutoRenameBranchPromptEditor": { "63121132c0": "放弃", @@ -4790,7 +4841,7 @@ "af2d9a2cc6": "。 Orca 仅生成最后一段,例如", "182d419b97": "内置分支名称提示", "2f5dc661fe": "附加到 Orca 的", - "7d6176f506": "迅速的", + "7d6176f506": "提示词", "5968112152": "保存", "54ac229ad4": "保存中...", "af0831a590": "已保存", @@ -4801,9 +4852,9 @@ "d166ff883d": "当前", "a4a9372eb2": "搜索分支...", "7db7fb87e5": "按名称搜索分支...", - "773a5687a3": "使用主要", + "773a5687a3": "跟随主分支", "ade9a5bb03": ") 来确定结果范围。", - "b468f46726": "上游/主要", + "b468f46726": "upstream/main", "80f7c82303": ")或完整的参考(例如", "915ad97875": "上游", "a5c16712c1": "已检测多个遥控器。输入远程名称(例如", @@ -4860,7 +4911,8 @@ "d420c43729": "将 {{value0}} cookie 从 {{value1}}{{value2}} 导入到 {{value3}} 中。", "b4c167764d": "已从文件将 {{value0}} 个 Cookie 导入到 {{value1}}。", "a3f8c2d1e0b4": "已从 {{value1}} ({{value2}}) 导入 {{value0}} 个 Cookie 到 {{value3}}。", - "b4e9d3f2a1c5": "已从 {{value1}} 导入 {{value0}} 个 Cookie 到 {{value2}}。" + "b4e9d3f2a1c5": "已从 {{value1}} 导入 {{value0}} 个 Cookie 到 {{value2}}。", + "c5a273a809": "从 {{value0}}" }, "BrowserUseComputerUseNotice": { "15b5e680ba": "开放计算机使用", @@ -4884,7 +4936,7 @@ "be6df68384": "从文件...", "e44c5d681e": "从", "67d9a53f47": "管理单独登录的配置文件", - "112f70adc4": "上次导入自", + "112f70adc4": "上次导入自 {{value0}}", "72d4815523": "将您现有的登录信息带入 Orca,以便智能体可以访问经过身份验证的页面。导入到默认配置文件中。", "2eb906706c": "导入浏览器 Cookie", "af8c83ed61": "从 Chrome、Edge 或其他浏览器导入 cookie,以便智能体可以重复使用您的登录信息。", @@ -4906,7 +4958,8 @@ "ad8cb0ee22": "修复路径", "0289434ed6": "启用", "8b3054dac7": "注册...", - "8f2675c2f3": "已从文件导入 {{value0}} 个 Cookie。" + "8f2675c2f3": "已从文件导入 {{value0}} 个 Cookie。", + "5301857d88": "从 {{value0}}" }, "BrowserUseSkillStep": { "0871b6998d": "使智能体能够在 Orca 浏览器中导航和验证页面。", @@ -4926,7 +4979,7 @@ "15eaad0d31": "命令路径:", "5dae812f50": "刷新", "52e640f3a0": "刷新 CLI 状态", - "38edbb5721": "外壳命令", + "38edbb5721": "Shell 命令", "6930feda9e": "从终端使用 Orca 打开应用程序、管理工作树并与 Orca 终端交互。", "c5c0f2641d": "Orca CLI", "d77352f2df": "无法从 PATH 中删除 `{{value0}}`。", @@ -4975,11 +5028,11 @@ "e001734396": "除非在编辑器中更改,否则将托管评审创建为草稿。", "6ba48f07a4": "默认草稿", "15b60d54b2": "例如ollama 运行 llama3.1 {提示}", - "3f1b26cc91": "将命令输入作为参数传递;否则 Orca 将其通过标准输入进行传输。", - "4f722a5f53": "由选择自定义命令的 commit 消息、PR和分支名称配方使用。使用", + "3f1b26cc91": "将提示词输入作为参数传递;否则 Orca 会通过标准输入传递。", + "4f722a5f53": "由选择自定义命令的 commit 消息、PR 和分支名称预设使用。使用", "47e45cbd5a": "自定义命令", - "1ef29f8c29": "当文本配方使用自定义命令时,命令行 Orca 运行。", - "2339a89104": "添加 AI 按钮,用于使用该操作的命令模板运行选定的智能体。", + "1ef29f8c29": "文本生成预设使用自定义命令时,Orca 运行的命令行。", + "2339a89104": "添加 AI 按钮,用于使用该操作的提示词模板运行选定的智能体。", "d5b45a3628": "显示源代码控制 AI 操作", "7bcad2b200": "为源代码管理的 commit、PR、分支命名和修复操作添加操作方案。", "d54c64163d": "已启用", @@ -4994,10 +5047,10 @@ "02bab6542c": "PR", "fdee745b87": "合并请求", "b388463881": "PR", - "19e10a12bb": "主持评论", - "b8b6fd55b4": "{迅速的}", + "19e10a12bb": "托管评审", + "b8b6fd55b4": "{prompt}", "fc1a525fa5": "占位符", - "a69e1fe91a": "迅速的", + "a69e1fe91a": "提示词", "1df7d71313": "二进制", "407d28bde6": "命令行", "54038660e0": "命令", @@ -5006,7 +5059,7 @@ "ComputerUsePane": { "1735461723": "使智能体能够检查和操作本地桌面应用程序。", "93255aaf18": "计算机控制技能", - "45f8e22c2e": "进行中", + "45f8e22c2e": "开放", "d95d1cfab8": "刷新", "0c29da5805": "就绪", "3383ea1aab": "无法重置计算机使用权限", @@ -5124,8 +5177,8 @@ "5f02e6fb21": "在富文本编辑器模式下显示本地 Markdown 评审笔记控件。", "51161d1647": "编辑文件时显示小地图概述。", "6690b1ffb9": "小地图", - "5a1ea6eaa2": "隐", - "73a09aad63": "如图所示", + "5a1ea6eaa2": "隐藏", + "73a09aad63": "显示", "1de48ad940": "默认差异文件树", "1b87897af9": "打开组合差异视图时显示或隐藏文件树。", "12cbc0d0d6": "并排", @@ -5144,7 +5197,9 @@ "8f1afdfbd8": "差异自动换行", "4aa4d9fb73": "在差异编辑器中换行显示长行,而不是要求水平滚动。", "bf16ef0af2": "关闭", - "3f6892f307": "开启" + "3f6892f307": "开启", + "b82f86d7d2": "Rich Markdown Spellcheck", + "5195f0b9ef": "在编辑富 Markdown 时显示浏览器拼写下划线和建议。" }, "AdvancedNetworkSettingsSection": { "3e431564b5": "本地主机,127.0.0.1,*.internal", @@ -5154,8 +5209,8 @@ "0adfce9fa7": "支持http、https、socks、socks4 和socks5 URL。", "476f302aca": "http://proxy.example.com:8080", "1e214e265a": "留空以使用系统代理设置和继承的代理环境变量。", - "f00daf6324": "HTTPAgent", - "823e0f15b1": "Orca 网络请求和本地终端子项的智能体 URL。", + "f00daf6324": "HTTP 代理", + "823e0f15b1": "用于 Orca 网络请求和本地终端子进程的代理 URL。", "d93c7cd531": "配置应用程序级网络路由。", "c46cdbbd4e": "网络", "configureProxy": "配置代理" @@ -5242,10 +5297,10 @@ "1f744a72f4": "配置" }, "GitPane": { - "d2eede4c54": "将 Orca 归因添加到 commits、PR 和议题。", - "e02ea23a32": "Orca 归因", + "d2eede4c54": "将 Orca 署名添加到 commits、PR 和议题。", + "e02ea23a32": "Orca 署名", "e71ce09c42": "orca", - "b9b5771bb1": "归因", + "b9b5771bb1": "署名", "b5f534717a": "合着的", "9838c921ed": "共同作者", "afada55042": "议题", @@ -5262,7 +5317,7 @@ "612a440e57": "GitHub API 预算", "2cde9044a8": "graphql", "36e3de3619": "通过与陈旧的历史进行比较。如果该分支有未 commits 的更改或仅限本地 commits,Orca 会跳过更新。", - "d072a12995": "git diff 主要...HEAD", + "d072a12995": "git diff main...HEAD", "db3a127eb1": "。这使得命令像", "3ae3de8898": "master", "5bf885be48": "或者", @@ -5310,14 +5365,14 @@ "264a9b6128": "Linear", "4831ba1083": "重新检查", "01f6c7582e": "了解更多", - "1a62c295c6": "Gitea 凭据已配置,但无法进行身份验证。检查令牌、API 基本 URL 和存储库权限,如果环境变量发生更改,则重新启动 Orca。", + "1a62c295c6": "Gitea 凭据已配置,但无法进行身份验证。检查 token、API 基本 URL 和存储库权限;如果环境变量发生更改,则重新启动 Orca。", "5a1f86225a": "仅当 Orca 无法从远程获取 API URL 时。", "6193444689": "ORCA_GITEA_API_BASE_URL", "2c0330ec3e": "对于私有存储库,并设置", "e678d89e8c": "ORCA_GITEA_TOKEN", "d9467ab026": "公共仓库通过其 git 远程检测。设置", "4ab9b96925": "Gitea", - "953b7bf6f7": "Azure DevOps 凭据已配置,但无法进行身份验证。检查令牌、API 基本 URL 和存储库权限,如果环境变量发生更改,则重新启动 Orca。", + "953b7bf6f7": "Azure DevOps 凭据已配置,但无法进行身份验证。检查 token、API 基本 URL 和存储库权限;如果环境变量发生更改,则重新启动 Orca。", "6f317f5132": "仅当 Orca 无法从 git 远程派生 API 基本 URL 时。", "ae6b7f5f40": "ORCA_AZURE_DEVOPS_API_BASE_URL", "67a9f26a80": "。设置", @@ -5326,7 +5381,7 @@ "5ee6ef6405": "ORCA_AZURE_DEVOPS_TOKEN", "4ee74d1470": "设置", "5efce6953d": "Azure DevOps", - "3c3cf05c63": "Bitbucket 凭据已配置,但无法进行身份验证。检查令牌和存储库权限,如果环境变量发生更改,则重新启动 Orca。", + "3c3cf05c63": "Bitbucket 凭据已配置,但无法进行身份验证。检查 token 和存储库权限;如果环境变量发生更改,则重新启动 Orca。", "6e0ff3403e": "ORCA_BITBUCKET_ACCESS_TOKEN", "44cde4aa01": "ORCA_BITBUCKET_API_TOKEN", "a6c2816115": "和", @@ -5363,11 +5418,11 @@ "6355fe585e": "已检测的存储库的PR和 commit 状态", "1fac9b4910": "{{value0}} · PR和 commit 状态", "f92fbf11aa": "未配置", - "6791d7af95": "通过 Azure DevOps REST API 令牌PR和构建状态。", + "6791d7af95": "通过 Azure DevOps REST API token 获取拉取请求和构建状态。", "e3d5a24979": "已检测的 Azure Repos 的PR和构建状态", "277fc23929": "{{value0}} · PR和构建状态", "295154e54e": "已连接", - "0879860c58": "通过 Bitbucket Cloud API 令牌PR和构建状态。", + "0879860c58": "通过 Bitbucket Cloud API token 获取拉取请求和构建状态。", "9707523939": "PR和构建状态", "a565377c38": "未安装", "15cf990798": "未经过验证", @@ -5429,7 +5484,7 @@ }, "McpConfigFileRow": { "b145eb6009": "环境:", - "e720c139cd": "进行中", + "e720c139cd": "开放", "845ae248e8": "有效的" }, "McpConfigSection": { @@ -5499,7 +5554,7 @@ "668016be7a": "在您的计算机和手机上。", "1dc87a7fbc": "Tailscale", "51d29927eb": "安装", - "9fc5d203ff": "Orca手机端 直接连接到此计算机。要在远离同一本地网络的地方使用它,请将您的计算机和手机置于同一私有覆盖网络上,然后使用所选的网络地址生成二维码。", + "9fc5d203ff": "Orca 手机端 直接连接到此计算机。要在远离同一本地网络的地方使用它,请将您的计算机和手机置于同一私有覆盖网络上,然后使用所选的网络地址生成二维码。", "39fad211d9": "使用尾网连接 Wi-Fi 外部", "a9db5d771d": "刷新网络接口", "b2c384cfd6": "没有找到接口", @@ -5509,14 +5564,14 @@ "1e64659126": "再生" }, "MobilePane": { - "dd3cd78d04": "使用 Orca手机端 扫描", + "dd3cd78d04": "使用 Orca 手机端 扫描", "35100bca5d": "当您在手机上使用终端时,Orca 会将其缩小以适合您的手机屏幕。当您关闭应用程序或切换离开时,这可以控制它是保持手机大小(因此交互式 CLI 工具不会回流)还是将大小调整回桌面。您始终可以在横幅上使用“恢复此终端”或“恢复所有终端”来手动调整大小。", "ee56f1c7e4": "当您离开手机应用程序时", "3939fd062c": "撤销设备会立即断开连接。", "254a6d09e4": "配对", "d7ce676270": "配对设备", "e778ecb209": "或者将此代码粘贴到手机应用程序中:", - "310924ad2c": "使用 Orca 手机应用程序扫描此代码。每个代码都会创建一个唯一的设备令牌。", + "310924ad2c": "使用 Orca 手机应用程序扫描此代码。每个代码都会创建一个唯一的设备token。", "870e1b5ca5": "撤销设备失败", "2e3dd0bc29": "设备已撤销", "711231348f": "复制配对码失败", @@ -5538,9 +5593,9 @@ "c8491c17ef": "通过扫描二维码从手机控制 Orca。 Beta/早期预览 - 预计会出现错误和重大更改。从以下位置获取 iOS 应用程序", "e7a3ae8c4e": "移动端", "174f4a3c6d": "通过手机控制终端和智能体。", - "1de96ec8a6": "显示 Orca手机端 按钮", - "682293cadf": "在左侧边栏顶部显示 Orca手机端 按钮。", - "d4f2b65f30": "在边栏中显示 Orca手机端 快捷入口。" + "1de96ec8a6": "显示 Orca 手机端按钮", + "682293cadf": "在左侧边栏顶部显示 Orca 手机端按钮。", + "d4f2b65f30": "在边栏中显示 Orca 手机端 快捷入口。" }, "NotificationsPane": { "906b4afebf": "发送测试通知", @@ -5680,12 +5735,12 @@ "QuickCommandsPane": { "8764c6e9e4": "删除 {{value0}}", "7d90fd5299": "编辑 {{value0}}", - "8c877dec41": "全球的", + "8c877dec41": "全局的", "c6b155911b": "所有命令", "5aacc8f7dc": "添加命令", - "c36912efd5": "从选项卡栏中的“快速命令”按钮运行它们,或者在任何终端内右键单击。", + "c36912efd5": "从选项卡栏中的“快捷命令”按钮运行它们,或者在任何终端内右键单击。", "f91b649324": "保存的命令", - "3d9dc558e8": "该快速命令将从您保存的列表中删除。", + "3d9dc558e8": "该快捷命令将从您保存的列表中删除。", "3edf3deaf8": "删除“{{value0}}”?", "9fcfc29519": "插入", "9b3e338d62": "进入", @@ -5694,7 +5749,7 @@ "7784912ed6": "仓库", "2bb9e38e93": "无题", "3eb9897ab0": "所选范围内没有命令。", - "38d61927e6": "没有保存快速命令。", + "38d61927e6": "没有保存快捷命令。", "44923dd982": "destructive", "ec1ed99e70": "删除", "d1d0976320": "没有任何" @@ -5759,7 +5814,7 @@ "8d6c56bff8": "运行两者", "0fa21e19ec": "工作区的名称,通常基于分支名称。", "54c73d88d0": "正在创建的工作树的路径。安装命令从此目录运行。", - "30952c4aa4": "主仓库结帐的路径。对于将共享文件(例如 .env)复制到工作树中非常有用。", + "30952c4aa4": "主仓库检出目录的路径。对于将共享文件(例如 .env)复制到工作树中非常有用。", "9b821fa19d": "# e.g. echo \"Cleaning up $ORCA_WORKSPACE_NAME\"", "6f90ebe3fd": "在归档或删除工作树之前运行。", "a3fc966677": "# e.g. pnpm install cp \"$ORCA_ROOT_PATH/.env\" \"$ORCA_WORKTREE_PATH/.env\"", @@ -5884,7 +5939,7 @@ "projectRuntimeDescription": "选择此项目运行在 Windows 还是 WSL。" }, "RepositorySourceControlAiActionRows": { - "548a6e1281": "命令模板", + "548a6e1281": "提示词模板", "7a3a8e431d": "CLI 参数", "2b2f38652b": "自定义命令", "0ffb081b3a": "使用默认智能体", @@ -6093,7 +6148,7 @@ "3de4bbb841": "终端", "dd72ed437a": "选择在“任务”页面和侧栏中显示的任务提供者。", "11faa2f7dd": "任务来源", - "cfa34f4465": "分支命名、基础引用、归因和 Git AI Author。", + "cfa34f4465": "分支命名、基础引用、署名和 Git AI Author。", "70100f94c7": "Git 和源代码控制", "b07041697f": "连接 GitHub、GitLab、Linear 和源托管服务。", "c9ca101a3b": "集成", @@ -6126,8 +6181,8 @@ "23c6874fdf": "AI 能力", "2309068a6f": "destructive", "65358016ea": "放弃", - "dev": "Dev Tools", - "devDescription": "Dev-only tools for exercising UI states." + "dev": "开发工具", + "devDescription": "仅用于调试 UI 状态的开发工具。" }, "SettingsFormControls": { "42a4d15a30": "没有匹配的字体。", @@ -6161,14 +6216,14 @@ }, "ShortcutFilterRail": { "28b63545bf": "状态", - "8a1e78c14b": "快捷状态搜索器", + "8a1e78c14b": "快捷键状态筛选条件", "df8466f3fc": "清除快捷方式搜索", "f733c4b89f": "搜索命令或按键", "02dc7d4251": "搜索快捷键", "1d5634ba31": "{{value0}} 快捷方式" }, "ShortcutRowsList": { - "4ce3cd24d9": "没有与这些搜索器匹配的快捷键。" + "4ce3cd24d9": "没有与这些筛选条件匹配的快捷键。" }, "ShortcutTerminalPolicyControl": { "0762983d13": "终端优先", @@ -6194,13 +6249,13 @@ "SourceControlAiActionRecipeDefaults": { "2576299196": "参数", "b3914ecbbc": "放弃", - "fb09da4345": "命令模板", + "fb09da4345": "提示词模板", "2cb4bb7e5d": "CLI 参数", "0740d30915": "自定义命令", "ee0e5c2a48": "使用默认智能体", "bf84dea6af": "仅当您希望 Orca 注入上下文时才使用变量。将智能体保留为默认值以遵循您的正常智能体偏好。", "a79c567194": "操作方案", - "cf01d41bce": "每个源代码管理 AI 按钮使用的智能体、CLI 参数和命令模板。", + "cf01d41bce": "每个源代码管理 AI 按钮使用的智能体、CLI 参数和提示词模板。", "a9359c8aa9": "未知错误", "b5f46664d3": "无法保存源代码控制 AI 操作默认值:{{value0}}", "d18d665e12": "保存", @@ -6329,9 +6384,9 @@ "92f80edbfd": "远程终端持久化", "feae1d1e69": "可选。相当于 ProxyJump / ssh -J。", "11bcb4507a": "bastion.example.com", - "b2ab248ded": "跳跃主机", + "b2ab248ded": "跳板主机", "3b01ca44a0": "可选。用于隧道(例如 Cloudflare Access、ProxyCommand)。", - "f42d844544": "例如cloudflared 访问 ssh --主机名 %h", + "f42d844544": "例如 cloudflared access ssh --hostname %h", "c7d0e18ecb": "代理命令", "cb91f6375c": "可选。默认使用 SSH 智能体。", "d6a5f2ee5c": "~/.ssh/id_ed25519(SSH 智能体留空)", @@ -6348,7 +6403,11 @@ "29af933cd5": "新的 SSH 目标", "f2331ce599": "编辑 SSH 目标", "7c13f58c91": "直到重置", - "55c56cf2c7": "断开连接后的超时时间(秒)" + "55c56cf2c7": "断开连接后的超时时间(秒)", + "4a342f44c1": "高级连接", + "e9609ddca6": "代理、跳板主机和连接复用", + "8c922dffba": "复用 SSH 连接以加快设置", + "53e9aabfc0": "可用时使用 OpenSSH 多路复用。对于有自定义 SSH 限制的主机,请关闭此项。" }, "TasksPane": { "3a72b9745e": "选择要在“任务”页面和侧边栏快捷键中显示的任务提供商。", @@ -6481,11 +6540,11 @@ "f61ac77f16": "git bash", "0f1b8669e6": "命令提示符", "eb7fc4d98a": "PowerShell", - "27e301f22c": "默认外壳", - "09bf02de9a": "打开新的终端窗口时使用的 shell。对新的终端生效。", - "bd68f3170d": "为 Windows 上的新终端窗口选择默认 shell。", - "a55eee649f": "Windows 上新终端窗口的默认 shell。", - "87e678a8af": "Windows外壳", + "27e301f22c": "默认 Shell", + "09bf02de9a": "打开新的终端窗口时使用的 Shell。对新的终端生效。", + "bd68f3170d": "为 Windows 上的新终端窗口选择默认 Shell。", + "a55eee649f": "Windows 上新终端窗口的默认 Shell。", + "87e678a8af": "Windows Shell", "05efc0bada": "true", "348246b06f": "false", "5936387ddd": "自动", @@ -6528,7 +6587,7 @@ "cbe56a0f79": "控制深色模式下窗格之间的分割分隔线。", "b739d2abfe": "深色分隔线颜色", "7add204bd5": "选择深色模式下使用的终端主题。", - "9499ad1dc4": "黑暗主题", + "9499ad1dc4": "深色主题", "f012172e21": "选择深色模式下用于终端窗格的主题。", "catalog_title": "终端主题", "catalog_description": "为深色和浅色模式选择终端主题和分隔线颜色。", @@ -6607,7 +6666,7 @@ "c9e1fdf42f": "光标", "da64e8f4c1": "终端背景颜色", "cc1b2ffeb2": "背景", - "026a0b8013": "主要文字颜色", + "026a0b8013": "主文本颜色", "79f6bfb76e": "前景", "cf37ff69f6": "根据", "8abdab9f7c": "立即重启", @@ -6657,9 +6716,9 @@ "ab40b8a5f1": "没有匹配项。继续输入以添加自定义路径。", "4cd2a4c077": "输入路径(例如 .env 或 node_modules)...", "241325302c": "添加路径", - "7ff265071d": "创建新工作树时,此处列出的每个路径会在可能时通过 macOS 上的 APFS clone-copy 复制;否则,会从主签出创建符号链接。", + "7ff265071d": "创建新工作树时,此处列出的每个路径会在可能时通过 macOS 上的 APFS clone-copy 复制;否则,会从主检出目录创建符号链接。", "4755f120b6": "工作树共享路径", - "b07ef5a8b6": "从主签出放入新创建工作树的路径。", + "b07ef5a8b6": "从主检出目录放入新建工作树的路径。", "d72ba8dc68": "{{value0}} 路径", "9ea912d811": "1 条路径" }, @@ -6735,7 +6794,9 @@ "bdbd1e668e": "视窗", "593720c17f": "位置", "b84a5b0c8a": "选择是否在此设备上或 WSL 中检查和添加提供商账户。", - "d09fb5ca92": "账户位置" + "d09fb5ca92": "账户位置", + "733f9e2a93": "MiniMax 使用量", + "f8374c3151": "粘贴 platform.minimax.io 会话 Cookie 以在本地获取速率限制。" } }, "advanced": { @@ -6820,7 +6881,7 @@ "appearance": { "search": { "468448bba4": "水彩", - "f586abfa35": "Blue", + "f586abfa35": "蓝色", "651f35b2c6": "切换器", "e5bc35d59e": "窗户", "d18b54ca90": "码头", @@ -6835,8 +6896,8 @@ "5bff6a2ef0": "侧边栏", "5e5b8878bf": "手机", "74618577c7": "移动端", - "682293cadf": "在左侧边栏顶部显示 Orca手机端 按钮。", - "1de96ec8a6": "显示 Orca手机端 按钮", + "682293cadf": "在左侧边栏顶部显示 Orca 手机端按钮。", + "1de96ec8a6": "显示 Orca 手机端按钮", "4c920ab2d1": "日程", "58f4e22fa2": "自动化", "b186f3cefb": "自动化", @@ -6893,7 +6954,7 @@ "c690a15849": "资源", "81ef5abc2f": "在状态栏中显示 CPU、内存、终端会话和工作区磁盘使用情况。", "7cf005b29f": "资源管理器", - "fe192b060e": "主持人", + "fe192b060e": "主机", "f4997e0f8a": "联系", "a278406ed5": "偏僻的", "6ecad74eb3": "SSH", @@ -6905,6 +6966,11 @@ "40e5c3c285": "基米", "c927a155d5": "在状态栏中显示 Kimi 订阅使用情况。", "3a6c028ea8": "基米用法", + "25e51b62ee": "速率限制", + "d9e7cef86f": "Cookie", + "d16378a88f": "minimax", + "e46178eb1b": "在状态栏中显示 MiniMax 订阅使用情况。", + "0f08f6b483": "MiniMax 使用量", "edbf0f63a0": "成本", "afbb6a3767": "Token", "d77537b580": "opencode-go", @@ -6958,8 +7024,8 @@ "50139297e6": "内置提示", "502aa57681": "指示", "40d21f2efc": "提示词", - "672387fb77": "生成分支名称时使用的智能体命令模板。", - "722551c5b3": "分支名称命令模板", + "672387fb77": "生成分支名称时使用的智能体提示词模板。", + "722551c5b3": "分支名称提示词模板", "f41833025e": "产生", "ed677944cc": "工作树", "3ef3cbe98c": "智能体", @@ -7092,7 +7158,7 @@ "57c851a68c": "CLI", "61117e57f3": "参数", "0f29331fed": "论点", - "18b6d38835": "每个源代码管理 AI 按钮使用的智能体、CLI 参数和命令模板。", + "18b6d38835": "每个源代码管理 AI 按钮使用的智能体、CLI 参数和提示词模板。", "3c4e5e5938": "操作方案", "ee14a9e9f7": "已启用", "82109d627d": "源代码控制", @@ -7245,7 +7311,7 @@ "884e5e6132": "markdown", "49db74a92d": "浏览器", "6410fe83d8": "终端", - "2b5efa55c9": "全球的", + "2b5efa55c9": "全局的", "ebeedb2f6a": "快捷终端", "6f183fa1b9": "浮动码头", "a08e482f6d": "浮动工作区", @@ -7356,10 +7422,10 @@ "8436ff6f8e": "代理绕过规则", "e55d62dfa4": "启动板", "9da6c875e5": "码头", - "b9096a44cf": "https_Agent", - "8f03d44672": "http_Agent", - "e3b1d42f95": "Orca 网络请求和本地终端子项的智能体 URL。", - "c29f23ab57": "HTTPAgent", + "b9096a44cf": "https_proxy", + "8f03d44672": "http_proxy", + "e3b1d42f95": "用于 Orca 网络请求和本地终端子进程的代理 URL。", + "c29f23ab57": "HTTP 代理", "6c2ce8457c": "文件浏览器", "c9d9636f24": "发现者", "68d03d9980": "VSCode", @@ -7393,21 +7459,23 @@ "161a86a9da": "关闭固定标签页前确认", "8e593f04fc": "关闭固定标签页前显示确认对话框。", "defaultProjectRuntime": "默认项目运行时", - "defaultProjectRuntimeDescription": "选择本地 Windows 项目继承的运行时。" + "defaultProjectRuntimeDescription": "选择本地 Windows 项目继承的运行时。", + "d2d2d929c0": "Rich Markdown Spellcheck", + "4497e2e2bb": "在编辑富 Markdown 时显示浏览器拼写下划线和建议。" } }, "git": { "search": { "61eab13403": "orca", - "1b93c1143c": "归因", + "1b93c1143c": "署名", "8461c908ae": "合着的", "61f9f5d1fc": "共同作者", "af0a144bfb": "议题", "6bdea421bb": "PR", "16f53f7323": "gh", "d088806071": "GitHub", - "118c23484b": "将 Orca 归因添加到 commits、PR 和议题。", - "bc7d9f69ce": "Orca 归因", + "118c23484b": "将 Orca 署名添加到 commits、PR 和议题。", + "bc7d9f69ce": "Orca 署名", "40f9b815fd": "API预算", "b7e52124c7": "速率限制", "ead733645f": "光亮的", @@ -7421,12 +7489,12 @@ "0c75583ca9": "安全地", "bae91effdd": "新基线", "de06e9d105": "基本参考", - "ab0e22c9f6": "刷新本地主", - "d9f70d51a0": "陈旧的主要", + "ab0e22c9f6": "刷新本地 main", + "d9f70d51a0": "过期的 main", "0849b571fe": "最新", - "c41e345153": "后面主要", + "c41e345153": "落后于 main", "6ee3cfff02": "git 差异", - "564942ffc5": "起源/主要", + "564942ffc5": "origin/main", "28192e3a63": "master", "e3e9adde59": "main", "0e993bf00f": "当您创建工作区时,Orca 会刷新远程库并安全地快进匹配的本地分支,例如 main 或 master。这使得像 git diff main...HEAD 这样的命令无法与过时的历史记录进行比较。如果该分支有未 commits 的更改或仅限本地 commits,Orca 会跳过更新。", @@ -7485,23 +7553,23 @@ "16a486a49d": "连接 Linear 以浏览和链接议题。", "b027b4b318": "Linear 集成", "20540996ef": "凭据", - "2ec2bd328c": "API 令牌", - "7345b7c3e6": "阿特拉斯语", + "2ec2bd328c": "API token", + "7345b7c3e6": "Atlassian", "e1263dd748": "Jira", - "76f6af7c57": "连接 Jira Cloud 或更新 Jira API 令牌凭据。", + "76f6af7c57": "连接 Jira Cloud 或更新 Jira API token 凭据。", "617603509b": "Jira 集成", "8c568d761c": "PR", "33180e8c10": "自托管", "129fc59aa8": "Gitea", - "d0d019dc29": "通过 API 令牌环境变量进行 Gitea 身份验证。", + "d0d019dc29": "通过 API token环境变量进行 Gitea 身份验证。", "aab86d64e5": "Gitea 集成", "03a7b275be": "ADO", "ed63380247": "天蓝色 repos", "b38b5d27f1": "天蓝色 devops", - "7b1f3984bb": "通过令牌环境变量进行 Azure DevOps Repos 身份验证。", + "7b1f3984bb": "通过token环境变量进行 Azure DevOps Repos 身份验证。", "af6611fa6e": "Azure DevOps 集成", "50d20817f7": "Bitbucket", - "c97d58a0f3": "通过 API 令牌环境变量进行 Bitbucket Cloud 身份验证。", + "c97d58a0f3": "通过 API token环境变量进行 Bitbucket Cloud 身份验证。", "67a2a0e868": "Bitbucket 集成", "371ee914d2": "合并请求", "581844769a": "MR", @@ -7518,15 +7586,15 @@ "jira": { "integration": { "card": { - "8ff73fef62": "Jira 令牌由活动运行时加密并存储在本地。重新输入相同的站点 URL 和电子邮件将替换该站点的 API 令牌。", + "8ff73fef62": "Jira token由活动运行时加密并存储在本地。重新输入相同的站点 URL 和电子邮件将替换该站点的 API token。", "9046a20d4c": "断开 {{value0}}", "eaffa454e9": "更新", "cec06a0f79": "测试...", "ab350991b8": "已验证", "d914d7ab70": "正在验证...", "5936977fcd": "取消", - "1666f8d562": "创建 Atlassian API 令牌", - "1ab7f551f3": "Atlassian API 令牌", + "1666f8d562": "创建 Atlassian API token", + "1ab7f551f3": "Atlassian API token", "09d310e42d": "你@example.com", "27dae4ab60": "https://example.atlassian.net", "a28f417220": "连接 Jira", @@ -7757,7 +7825,7 @@ "1c5bdcd0f2": "存储库", "89d2a9ad9f": "仓库", "f58b92a48f": "项目", - "8bf43c2dad": "全球的", + "8bf43c2dad": "全局的", "a26ecdb77b": "片段", "d07d130849": "捷径", "0073cf8ce9": "终端", @@ -7822,7 +7890,7 @@ "f1c53f2820": "工作树", "7e228fc439": "符号链接", "c06adcf136": "符号链接", - "ed885e589f": "从主签出放入新创建工作树的路径。", + "ed885e589f": "从主检出目录放入新建工作树的路径。", "01b3377ebc": "工作树共享路径", "fff8834983": "提示词", "fa3131f223": "模型", @@ -7938,7 +8006,7 @@ "7f251a45a8": "从 ~/.ssh/config 导入主机。", "41a3127094": "从 SSH 配置导入", "f9493b80c0": "服务器", - "8fb1cc87cc": "主持人", + "8fb1cc87cc": "主机", "09395490af": "目标", "00d1fda01a": "新的", "f7b6383aec": "添加", @@ -8104,7 +8172,7 @@ "9c32726f47": "控制深色模式下窗格之间的分割分隔线。", "8987db7ff2": "深色分隔线颜色", "13f6310dd3": "选择深色模式下使用的终端主题。", - "ec07ce9b02": "黑暗主题", + "ec07ce9b02": "深色主题", "f036794286": "活跃", "846a7a1204": "窗格", "d1fa00a9cb": "徘徊", @@ -8166,7 +8234,7 @@ "keyword_warp": "warp", "keyword_themes": "themes", "keyword_yaml": "yaml", - "keyword_legacy_title": "Import themes from Warp" + "keyword_legacy_title": "从 Warp 导入主题" }, "yaml_import": { "title": "从 YAML 导入", @@ -8217,9 +8285,9 @@ "591912177b": "gitbash", "12519edb5d": "命令提示符", "6cd20b9e64": "指令", - "7c7056940a": "壳", - "713c4a2f92": "为 Windows 上的新终端窗格选择默认 shell。", - "13715f9d23": "默认外壳" + "7c7056940a": "shell", + "713c4a2f92": "为 Windows 上的新终端窗格选择默认 Shell。", + "13715f9d23": "默认 Shell" } } }, @@ -8264,8 +8332,8 @@ "fixChecks": "从失败的托管评审检查中启动智能体。", "resolveConflicts": "启动用于解决本地或托管评审合并冲突的智能体。", "customCommand": "自定义命令", - "supportedAgents": "此配方支持的智能体:{{value0}}。", - "unsupportedSavedAgent": "{{value0}} 无法运行此文本生成配方。请在下方选择一个支持的智能体。", + "supportedAgents": "此预设支持的智能体:{{value0}}。", + "unsupportedSavedAgent": "{{value0}} 无法运行此文本生成预设。请在下方选择一个支持的智能体。", "resolveComments": "从选中的未解决 PR 或 MR 评论启动智能体。" } } @@ -8370,7 +8438,7 @@ "integration": { "cards": { "c90f2ef419": "重新检查", - "dd3529015d": "Disconnect {{value0}}", + "dd3529015d": "断开 {{value0}}", "8b2408a8e5": "此运行时已连接 Jira。如果已连接的站点列表看起来过期,请重新检查。", "8c20e76308": "每个已连接的 Jira 站点都由活动运行时存储一个 token。", "c24e56c532": "测试", @@ -8380,14 +8448,14 @@ "60996beda6": "添加 Jira 站点", "7ca5ffffdb": "从 Jira Cloud 议题浏览、创建并开始工作。", "a1093a06c7": "在显示设置操作前正在检查 Jira 访问权限。", - "9fa04a032e": "{{value0}} site{{value1}} connected", + "9fa04a032e": "{{value0}} 个站点{{value1}} 已连接", "cef18762a2": "用 Linear 设置里的个人 API 密钥添加访问权限。完全访问密钥可看到密钥所有者能访问的所有团队。", "6224fe9d34": "每个已连接的 Linear 工作区都有一个由活动运行时存储的密钥。全权限密钥可覆盖密钥所有者可访问的所有团队;受限密钥可随时更换。", "1a12e33fe5": "添加 Linear 访问", "622c224082": "添加工作区访问权限", "eae4a9f16b": "添加 Linear 访问以浏览和链接议题。", "fe9231215b": "在显示设置操作前正在检查 Linear 访问权限。", - "e1f5e6424c": "{{value0}} workspace{{value1}} connected", + "e1f5e6424c": "{{value0}} 个工作区{{value1}} 已连接", "disconnect_all": "断开连接", "account_scope_prefix": "账户范围" } @@ -8410,7 +8478,7 @@ "0613928cb3": "此运行时暂不支持 Gitea 状态。", "05863d2599": "通过 Gitea REST API 获取 pull request 和 commit 状态。", "52f75876be": "已检测仓库的 pull request 和 commit 状态", - "0b5242f8a2": "{{value0}} · Pull requests and commit statuses", + "0b5242f8a2": "{{value0}} · 拉取请求和提交状态", "40f678df73": "Azure DevOps 凭据已配置但无法通过身份验证。请检查 token、API 基础 URL 和仓库权限;如果环境变量已更改,请重启 Orca。", "7bd345e3f6": "仅当 Orca 无法从 git remote 推导出 API 基础 URL 时。", "186a6689df": "ORCA_AZURE_DEVOPS_API_BASE_URL", @@ -8422,7 +8490,7 @@ "f3f47dc7de": "此运行时暂不支持 Azure DevOps 状态。", "0eb50d5593": "通过 Azure DevOps REST API token 获取 pull request 和构建状态。", "54636c65d4": "已检测 Azure Repos 的 pull request 和构建状态", - "ea204f5e03": "{{value0}} · Pull requests and build statuses", + "ea204f5e03": "{{value0}} · 拉取请求和构建状态", "6154b02093": "Bitbucket 凭据已配置但无法通过身份验证。请检查 token 和仓库权限;如果环境变量已更改,请重启 Orca。", "e63fe8f627": "ORCA_BITBUCKET_ACCESS_TOKEN", "19416c874c": "ORCA_BITBUCKET_API_TOKEN", @@ -8441,7 +8509,7 @@ "checkingTitle": "正在检查计算机控制访问权限。", "checkingDescription": "Orca 正在检查计算机控制助手的 macOS 隐私权限。", "unavailableTitle": "计算机控制不可用。", - "unavailableDescription": "Computer Use permissions are unavailable because {{value0}}.", + "unavailableDescription": "Computer Use 权限不可用,因为 {{value0}}。", "readyTitle": "计算机控制已就绪。", "readyDescription": "当你要求时,智能体可以检查并操作应用窗口。", "permissionsTitle": "完成设置以使用本地应用。" @@ -8458,7 +8526,7 @@ "6f7a8b9cad": "使用相对路径(例如 .orca/worktrees)可让每个项目使用各自的位置,使用绝对路径则所有项目共用一个文件夹。" }, "ProviderHostScopeControl": { - "scope_label": "{{value0}}: {{value1}}", + "scope_label": "{{value0}}:{{value1}}", "change_host": "打开远程服务器" }, "providerAccountScope": { @@ -8521,7 +8589,7 @@ }, "ProjectWindowsRuntimeSetting": { "projectRuntime": "项目运行时", - "defaultRuntime": "Default ({{value0}})", + "defaultRuntime": "默认 ({{value0}})", "windows": "Windows", "wsl": "WSL", "selectDistro": "选择发行版", @@ -8604,7 +8672,7 @@ "b94ff260e6": "下载 Android Studio", "18925b082d": "定位 SDK 文件夹", "8c52684db8": "清除", - "76eb88b88e": "iOS Simulator (Xcode)", + "76eb88b88e": "iOS 模拟器 (Xcode)", "c6f3ea4f12": "已就绪", "e4f14b50d7": "请安装 Xcode 并添加 iOS 模拟器运行时。", "63fe73a1ea": "无法更新 Android SDK 文件夹。" @@ -8613,34 +8681,34 @@ "advanced": "高级" }, "DevToolsPane": { - "nativeActionLayoutCheck": "Native action layout check", - "secondary": "Secondary", - "secondaryActionClicked": "Secondary action clicked", - "primaryAction": "Primary action", - "primaryActionClicked": "Primary action clicked", - "branchHasChanges": "branch has changes", - "viewChangesClicked": "View Changes clicked", - "forceDeleteClicked": "Force Delete clicked", - "devOnlyCallback": "Dev-only callback.", - "infoToast": "Info toast", - "infoToastDescription": "Long informational copy with no explicit action.", - "localCanaryBehind": "Local canary is behind origin/canary", - "successToast": "Success toast", - "successToastDescription": "Short confirmation state.", - "settingsSaved": "Settings saved", - "shortSuccessCopy": "Short success copy.", - "errorToast": "Error toast", - "errorToastDescription": "Long error copy without recovery actions.", - "failedToSyncWorkspaceMetadata": "Failed to sync workspace metadata", - "nativeActionToast": "Native action toast", - "nativeActionToastDescription": "Uses Sonner action and cancel slots.", - "deleteFailureToast": "Delete failure toast", - "deleteFailureToastDescription": "Custom footer with View and Force Delete.", - "behindBaseRefToast": "Behind base ref toast", - "behindBaseRefToastDescription": "Persistent prompt with an inline Settings link and footer action.", - "notificationPlayground": "Notification playground", - "notificationPlaygroundDescription": "Dev-only triggers for checking toast layout, recovery actions, and long-copy wrapping.", - "devOnly": "Dev only" + "nativeActionLayoutCheck": "原生操作布局检查", + "secondary": "次要", + "secondaryActionClicked": "次要操作已点击", + "primaryAction": "主操作", + "primaryActionClicked": "主操作已点击", + "branchHasChanges": "分支有更改", + "viewChangesClicked": "查看更改已点击", + "forceDeleteClicked": "强制删除已点击", + "devOnlyCallback": "仅开发回调。", + "infoToast": "信息提示", + "infoToastDescription": "无明确操作的长信息文案。", + "localCanaryBehind": "本地 canary 落后于 origin/canary", + "successToast": "成功提示", + "successToastDescription": "简短的确认状态。", + "settingsSaved": "设置已保存", + "shortSuccessCopy": "简短的成功文案。", + "errorToast": "错误提示", + "errorToastDescription": "无恢复操作的详细错误文案。", + "failedToSyncWorkspaceMetadata": "同步工作区元数据失败", + "nativeActionToast": "原生操作提示", + "nativeActionToastDescription": "使用 Sonner 的操作和取消插槽。", + "deleteFailureToast": "删除失败提示", + "deleteFailureToastDescription": "包含查看和强制删除的自定义底部区域。", + "behindBaseRefToast": "落后于基础分支提示", + "behindBaseRefToastDescription": "带有内联设置链接和底部操作按钮的持久提示。", + "notificationPlayground": "通知测试面板", + "notificationPlaygroundDescription": "用于检查提示布局、恢复操作和长文案换行的开发触发器。", + "devOnly": "仅开发" }, "EphemeralVmRecipeRow": { "useInWorkspace": "在工作区中使用" @@ -8659,7 +8727,7 @@ "copiedCleanupPayload": "已复制清理载荷。", "copyCleanupFailed": "无法复制清理命令。", "title": "临时 VM 运行时", - "description": "由配方创建的运行时归工作区所有。崩溃、创建失败或手动恢复后,请清理过期条目。", + "description": "由环境模板创建的运行时归工作区所有。崩溃、创建失败或手动恢复后,请清理过期条目。", "refresh": "刷新临时 VM 运行时", "loading": "正在检查临时 VM 运行时…", "empty": "没有需要清理的临时 VM 运行时。", @@ -8670,30 +8738,42 @@ "ephemeralVms": { "search": { "title": "工作区专属环境", - "description": "了解仓库自带的配方如何为每个工作区提供按需创建、可丢弃的独立环境。" + "description": "了解仓库自带的环境模板如何为每个工作区提供按需创建、可丢弃的独立环境。" } }, "EphemeralVmsPane": { - "loadError": "无法加载配方。", + "loadError": "无法加载环境模板。", "copyError": "无法复制提示词。", "skillTitle": "工作区专属环境技能", - "skillDescription": "设置、构建、认证并验证仓库自带的环境配方。", + "skillDescription": "设置、构建、认证并验证仓库自带的环境模板。", "whatTitle": "此技能会与你一起完成", - "whatScaffold": "为你的提供商编写配方和脚本,可通过 Orca 服务器或 SSH 连接。", + "whatScaffold": "为你的提供商编写环境模板和脚本,可通过 Orca 服务器或 SSH 连接。", "whatBuild": "构建可复用的基础镜像,并让智能体完成登录。", "whatValidate": "验证环境,以便你可以在其上创建工作区。", "promptHint": "在任意工作区中,可以这样询问智能体:", "copy": "复制", "copied": "已复制", - "recipes": "配方", - "recipesHelp": "智能体添加到 orca.yaml 的配方会显示在这里,可用于启动工作区。", - "refresh": "刷新临时 VM 配方", - "checking": "正在检查配方...", - "none": "还没有找到配方。" + "recipes": "环境模板", + "recipesHelp": "智能体添加到 orca.yaml 的环境模板会显示在这里,可用于启动工作区。", + "refresh": "刷新临时 VM 环境模板", + "checking": "正在检查环境模板...", + "none": "还没有找到环境模板。" }, "ephemeralVmsExperimentalSetting": { "description": "显示仓库自带按需环境的设置控件和工作区运行目标。", "toggleLabel": "切换工作区专属环境" + }, + "SourceControlActionRepoOverrideNote": { + "agent": "Agent", + "agentArgs": "CLI arguments", + "commandTemplate": "Command template", + "more": "+{{count}} more", + "plural": "Global saves won't change {{count}} repositories with their own recipes.", + "recipe": "Recipe", + "review": "Review", + "reviewFirst": "Review first", + "singular": "Global saves won't change 1 repository with its own recipe.", + "tooltipTitle": "Repository overrides" } }, "right": { @@ -8714,7 +8794,7 @@ "b5dd73a105": "选择一个工作区以查看检查", "a4ef4e0832": "未选择工作区", "5594400d73": "没有需要修复的失败检查。", - "abf59262fb": "在启动智能体之前查看并编辑完整的命令输入。", + "abf59262fb": "启动智能体前请查看并编辑完整提示词。", "4ede779461": "使用 AI 解决评审冲突", "3b203c62f8": "这将从 PR 中永久删除该评论。", "ea9b649ce3": "删除评论?", @@ -8751,7 +8831,7 @@ "02b2ce911f": "说明(可选)", "1cd53359db": "描述", "68314b4369": "标题", - "694550a610": "主要的", + "694550a610": "main", "0fad57a14c": "搜索远程分支或输入分支名称。", "8584ccb43c": "基础分支", "6f5f1962b6": "源分支", @@ -8800,7 +8880,7 @@ "f61af83316": "新建文件夹", "37c875d827": "新文件", "b3e288bf41": "无法下载“{{value0}}”。", - "1a3df04ae1": "进行中", + "1a3df04ae1": "开放", "bce4d4e44f": "下载“{{value0}}”", "e26010014a": "被 .gitignore 忽略", "a06551beee": "进入", @@ -8812,13 +8892,14 @@ "98a79948b3": "复制", "b234ab25b4": "无法将文件复制到剪贴板", "f9d7ca753d": "复制路径", - "3161c4e425": "文件夹" + "3161c4e425": "文件夹", + "e887fa4b2e": "在终端中打开" }, "FileExplorerToolbar": { "d238264654": "显示 Git 忽略的文件", "78f133232c": "显示点文件", - "31b4c3195d": "更多资源管理器操作", - "d95e30fe28": "刷新资源管理器", + "31b4c3195d": "更多文件管理器操作", + "d95e30fe28": "刷新文件管理器", "6026b16950": "全部折叠", "693cbeadd0": "搜索", "c1f3f3ec70": "搜索文件内容" @@ -8833,10 +8914,10 @@ "d0fb0f4bf2": "刷新 commits", "9f7535d22b": "引用是指向该确切 commit 的分支或标记名称。它们仅出现在 Git 具有 commit 的命名引用的地方。", "9289ba0cb9": "什么是参考文献?", - "d836037d02": "Commits", + "d836037d02": "提交", "8232c8b2f2": "打开 commit {{value0}}: {{value1}}", "9a8b85882d": "加载中", - "62e685d5ec": "idle", + "62e685d5ec": "空闲", "111e1d0db4": "错误", "e5e81e59a6": "调整 commits 大小", "6d1e0a7c3b": "无法加载提交文件" @@ -8992,7 +9073,7 @@ "37a81f29ad": "生成 commit 消息。单击停止。", "b94112eb9e": "Commit 消息", "0d0a8359d3": "信息", - "15b7f210d7": "选择智能体并在启动前编辑完整的命令输入。", + "15b7f210d7": "启动智能体前请查看并编辑完整提示词。", "054ead86b1": "使用 AI 修复 Commit 失败", "9e5ccd00aa": "Commit 失败上下文不可用", "f0a2dc9e46": "自定义启动...", @@ -9006,7 +9087,7 @@ "4f76c0a9de": "基础分支必须与 head 分支不同。", "c5e4175139": "更多 {{value0}} 和远程操作", "78ddfd0bb4": "创建为草稿", - "e64a632456": "主要的", + "e64a632456": "main", "6055949c50": "{{value0}} 基础分支", "1f7119f604": "根据", "9484270f45": "正在生成标题和描述...", @@ -9020,11 +9101,11 @@ "b355e740b2": "停止生成 {{value0}} 详细信息", "527e130b6f": "停止生成", "e1970d327d": "新建 {{value0}}", - "f4c766f1ca": "为此运行选择智能体和命令模板。", + "f4c766f1ca": "为此运行选择智能体和提示词模板。", "1a6a6e0bc5": "生成托管评论详细信息", "6b122529d4": "生成 Commit 消息", "e48caaf0dd": "已启动 AI 智能体处理冲突。", - "901140f47d": "在启动智能体之前查看并编辑完整的命令输入。", + "901140f47d": "启动智能体前请查看并编辑完整提示词。", "19652ddd76": "用 AI 解决冲突", "c9ad22888e": "选择此存储库的分支比较目标。", "574d2f4413": "清晰的笔记", @@ -9159,7 +9240,8 @@ "b715ef615b": "领先 {{value1}} {{value0}} 个提交", "c1a8f3e204": "落后 {{value0}} 1 个提交", "d2b9g4f315": "落后 {{value1}} {{value0}} 个提交", - "e9c2a5d416": "与 {{value0}} 同步" + "e9c2a5d416": "与 {{value0}} 同步", + "createPrIntentCommitBlockedSummary": "Commit blocked: {{value0}} Fix the issue, then retry Create PR." }, "SourceControlAgentActionDialog": { "8e856842d1": "无法启动选定的智能体。", @@ -9171,7 +9253,7 @@ "SourceControlAgentActionDialogForm": { "1bc0bdbb5e": "启动:", "7ec6abbf2a": "重置", - "f4f3c9ca4a": "命令模板", + "f4f3c9ca4a": "提示词模板", "fe119187bb": "——模范十四行诗", "bc8dc39f4b": "CLI 参数", "b99c33cec5": "设置", @@ -9181,16 +9263,17 @@ "1d47db9bf0": "没有启用智能体", "c7ff8cef11": "检测剂...", "013c9ac04a": "保存范围", - "1bb611240f": "Use {basePrompt} for Orca's default prompt.", + "1bb611240f": "使用 {basePrompt} 作为 Orca 的默认提示词。", "23280cbab1": "此模板不包含 {basePrompt},因此智能体不会收到 Orca 的默认提示词。", "5421a96acb": "保存并启动智能体", "6cefcdfba1": "稍后可以在源代码管理 AI 设置中更改。", "c29f9cf266": "保存此提示,下次不再显示此评审", - "d8f40128ee": "{basePrompt} is Orca's default prompt.", + "d8f40128ee": "{basePrompt} 是 Orca 的默认提示词。", "ea4788705e": "取消", - "b0da3a4d3e": "启动配方已保存", - "bff4795a6d": "更改智能体、参数或提示词模板以更新已保存的配方。", - "5c75b24735": "自定义 Orca 启动智能体前发送给它的内容。" + "b0da3a4d3e": "启动预设已保存", + "bff4795a6d": "更改智能体、参数或提示词模板以更新已保存的预设。", + "5c75b24735": "自定义 Orca 启动智能体前发送给它的内容。", + "repoAgentOverrideNote": "此仓库会覆盖你的全局默认值({{global}}),当前运行 {{effective}}。保存到此仓库以更改这里运行的内容。" }, "SourceControlTextGenerationDialog": { "c5b7fa7cb6": "保存为全局默认值", @@ -9200,8 +9283,8 @@ }, "SourceControlTextGenerationDialogForm": { "25fcd8e49a": "保存默认值", - "d91b0a189d": "保存食谱", - "1f6fcfb6cf": "命令模板", + "d91b0a189d": "保存预设", + "1f6fcfb6cf": "提示词模板", "551ffd111b": "——模范十四行诗", "4eab815004": "CLI 参数", "914c8f6ac2": "自定义命令", @@ -9292,7 +9375,7 @@ "1e53e45072": "已复制", "084c516efb": "复制命令", "5dc3af25c0": "选择评论", - "d7a2f9c401": "Send unresolved {{value0}} comments", + "d7a2f9c401": "发送未解决的 {{value0}} 评论", "d91f2a6c39": "发送 {{value0}} 条已排队评论给 AI", "a6de3e5a20": "清除已排队评论", "49ea0937e4": "将评论添加到解决列表", @@ -9301,18 +9384,18 @@ "f8a2c91d04": "加入智能体队列", "b4e8a1c902": "已加入队列", "7c1f0a2b11": "打开", - "e8b4c1a903": "Resolved · {{value0}}", - "c3a8e5d710": "Needs review · {{value0}}", + "e8b4c1a903": "已解决 · {{value0}}", + "c3a8e5d710": "需审核 · {{value0}}", "a7f0c7e8d1": "加入队列", "8a621a2c4f": "已分组", "b13f85d75c": "时间线", "f5cf324efa": "评论显示选项", "5e6e5a13fa": "查看", - "actionRequiredHint": "This check needs a manual action on GitHub (for example, approving the workflow run) before merging is unblocked." + "actionRequiredHint": "此检查需要在 GitHub 上执行手动操作(例如批准工作流运行)后才能解除合并阻止。" }, "empty": { "state": { - "3322603418": "Pull request status unavailable", + "3322603418": "PR 状态不可用", "5b0cfae9a5": "创建 {{value0}} 以开始检查和评审。", "13e1c7d5ed": "未找到 {{value0}}", "d372072df1": "GitHub 刷新因当前速率限制预算而暂停", @@ -9328,7 +9411,7 @@ "41252bc53f": "分支未发布", "05e4aec17b": "{{value0}} 检查将在操作完成后可用", "d77c513c1e": "{{value0}} 正在进行中", - "b597440265": "Refresh GitHub status for this branch to load checks and review." + "b597440265": "刷新此分支的 GitHub 状态以加载检查和审核。" } } } @@ -9438,13 +9521,13 @@ "9e779995dd": "创建 {{value0}}", "226b85a3a7": "fetch", "323bb614aa": "Commit 并同步", - "2b8e6595fd": "Commit", + "2b8e6595fd": "提交", "04d709801d": "从远程获取但不合并" } }, "primary": { "action": { - "ed93b4f14f": "Commit", + "ed93b4f14f": "提交", "946a8a05ea": "为此分支创建 {{value0}}", "e7ffa46946": "创建 {{value0}}", "95550cff15": "推", @@ -9464,8 +9547,8 @@ "2d8f185fbc": "在提交部分暂存的文件之前暂存所有更改", "a6457b46a7": "提交前解决冲突", "484f45c439": "{{value0}} 正在进行中...", - "6f7a8b9c0d": "Remote operation in progress…", - "7f8a9b0c1d": "Remote operation in progress — try again once it finishes", + "6f7a8b9c0d": "远程操作进行中…", + "7f8a9b0c1d": "远程操作进行中——请等它完成后重试", "74fc171e99": "强制推送正在进行中...", "16aee3a5c1": "正在进行中……", "e61b0d7a3c": "请先检出分支再发布 commits。", @@ -9475,18 +9558,18 @@ "d37e68f61d": "正在准备分支以供评审…", "c72e5e65d1": "准备此分支并创建 {{value0}}", "b8e4f2a901": "等待远程操作完成。", - "c9f3a1b802": "Resolve conflicts before creating a {{value0}}.", - "d2a8c4e703": "No changes on this branch to include in a {{value0}}.", - "e3b9d5f814": "Cannot create a {{value0}} from the default branch.", - "f4c0e6a925": "Commit changes before creating a {{value0}}.", - "a5d1f7b036": "Publish commits before creating a {{value0}}.", - "b6e2a8c147": "Push commits before creating a {{value0}}.", - "c7f3b9d258": "Sync this branch before creating a {{value0}}.", - "d8a4c0e369": "Authenticate before creating a {{value0}}.", - "e9b5d1f470": "Check out a branch before creating a {{value0}}.", - "f0c6e2a581": "This branch is not ready for a {{value0}} yet.", - "8f9a0b1c2d": "Nothing to commit. Branch is up to date.", - "h3i4j5k607": "Checking whether this branch can create a {{value0}}…" + "c9f3a1b802": "在创建 {{value0}} 之前解决冲突。", + "d2a8c4e703": "此分支上没有需要包含在 {{value0}} 中的更改。", + "e3b9d5f814": "无法从默认分支创建 {{value0}}。", + "f4c0e6a925": "在创建 {{value0}} 之前提交更改。", + "a5d1f7b036": "在创建 {{value0}} 之前发布提交。", + "b6e2a8c147": "在创建 {{value0}} 之前推送提交。", + "c7f3b9d258": "在创建 {{value0}} 之前同步此分支。", + "d8a4c0e369": "在创建 {{value0}} 之前进行身份验证。", + "e9b5d1f470": "在创建 {{value0}} 之前检出一个分支。", + "f0c6e2a581": "此分支尚未准备好创建 {{value0}}。", + "8f9a0b1c2d": "没有可提交的内容。分支已是最新状态。", + "h3i4j5k607": "正在检查此分支是否可以创建 {{value0}}…" } } } @@ -9555,11 +9638,13 @@ "logPath": "日志路径", "agents": "智能体", "sessionsShownCompact": "已显示 {{value0}} 个", - "originalPaneUnavailable": "Original pane is no longer available.", - "worktreeUnavailable": "Worktree is no longer available.", - "runtimeBrowseLocalHistory": "Runtime-hosted workspaces can browse local history. Resume actions are available in local and SSH workspaces.", - "runtimeWorkspacesUnsupported": "Resume from history is not available in runtime-hosted workspaces.", - "openSupportedWorkspace": "Open a local or SSH workspace before resuming a session." + "originalPaneUnavailable": "原始面板不再可用。", + "worktreeUnavailable": "工作树不再可用。", + "runtimeBrowseLocalHistory": "远程运行时托管的工作区可以浏览本地历史。恢复操作在本地和 SSH 工作区中可用。", + "runtimeWorkspacesUnsupported": "在远程运行时托管的工作区中无法从历史记录恢复会话。", + "openSupportedWorkspace": "在恢复会话之前,请先打开一个本地或 SSH 工作区。", + "localSessionSshWorkspaceUnsupported": "此会话的历史记录存储在本机上,因此无法在 SSH 工作区中恢复。请改为打开一个本地工作区。", + "sessionHostMismatchUnsupported": "此会话属于其他主机。请打开同一主机上的工作区以恢复它。" }, "AiVaultPanelControls": { "scanningSessions": "正在扫描会话", @@ -9588,7 +9673,9 @@ "globalScope": "全局", "projectScope": "项目", "currentProjectLower": "当前项目", - "project": "项目" + "project": "项目", + "hostScopeAriaLabel": "Session History host: {{value0}}", + "host": "Host" }, "AiVaultSessionDetails": { "updated": "更新时间", @@ -9776,10 +9863,6 @@ "a84d5677ff": "OpenCode", "2528586aa7": "克劳迪诺" } - }, - "PetOverlay": { - "de932b0e8f": "@keyframes pet-bob { 0%,100% { 变换:translateY(0); } 50% { 变换:translateY(-4px); } }", - "4712d196c6": "@keyframes pet-{{value0}} { from { 背景位置:{{value1}}px {{value2}}px; } 到 { 背景位置:{{value3}}px {{value4}}px; } }" } }, "onboarding": { @@ -10005,7 +10088,7 @@ "26824f60dd": "全部", "6fad211c66": "已关闭", "2319d87718": "合并", - "622864b52a": "进行中", + "622864b52a": "开放", "fda67f0b61": "当前项目", "3e8bb1176a": "在设置中连接 Linear 以搜索议题。", "69ce292138": "Linear", @@ -10052,7 +10135,7 @@ "dfd2aa9d5d": "网络", "2f077ef4eb": ",然后扫码。", "3aa7bb2d8b": "配对桌面", - "d1495e5e64": "打开 Orca手机端,点击", + "d1495e5e64": "打开 Orca 手机端,点击", "901c98bb93": "配对这个", "3960f5c339": "第 2 步(共 2 步)", "3241f3c26a": "安装二维码", @@ -10060,15 +10143,15 @@ "aa97420ba4": "复制安装链接", "ac1eb64952": "安卓", "711e6f4b47": "iOS系统", - "e75647ace0": "使用手机扫描二维码或打开安装链接以获取 Orca手机端。", + "e75647ace0": "使用手机扫描二维码或打开安装链接以获取 Orca 手机端。", "0d9b33299e": "获取应用程序。", "92ddfdfa1f": "第 1 步(共 2 步)", "ff48d9d520": "与另一台设备配对", "f9cbf4bb53": "撤销设备", "34f878d04f": "撤销 {{value0}}", "94829abdb1": "配对", - "266c18c105": "打开 Orca手机端 从上次中断的地方继续,或配对其他设备。", - "5410d55d79": "Orca手机端", + "266c18c105": "打开 Orca 手机端 从上次中断的地方继续,或配对其他设备。", + "5410d55d79": "Orca 手机端", "10d27b4cba": "开始使用", "da1d5e5ed0": "可用于", "ec0607bf66": "支持的手机平台", @@ -10102,9 +10185,9 @@ "9883b58693": "关闭 Orca 手机", "fb5f28330e": "在侧边栏中显示", "c669abcf8f": "从侧边栏隐藏", - "e1c7b4a92d": "Configure in Settings > Mobile.", - "a4f8c2d91e": "Remove Orca手机端 from left sidebar", - "b7e3d1c84a": "Show Orca手机端 in left sidebar", + "e1c7b4a92d": "在设置 > 移动端中配置。", + "a4f8c2d91e": "从左侧边栏隐藏 Orca 手机端", + "b7e3d1c84a": "在左侧边栏显示 Orca 手机端", "f3d8e5b71a": "Adds the shortcut back to the left sidebar." }, "PhoneCarousel": { @@ -10201,8 +10284,8 @@ "title": "自定义网络地址", "description": "指定当手机不在同一 Wi-Fi 时可以访问的地址,例如 Tailscale 主机名或静态 IP。", "label": "地址", - "placeholder": "my-mac.ts.net 或 192.168.1.50", - "hint": "请输入 IP 地址或 Tailscale 主机名(以 .ts.net 结尾)。", + "placeholder": "my-mac.ts.net、home.example.com 或 192.168.1.50", + "hint": "请输入 IP 地址或主机名(Tailscale MagicDNS 名称、DDNS 域名或 LAN 主机名),可选择附加 :端口。", "cancel": "取消", "use": "使用地址" }, @@ -10490,7 +10573,7 @@ "3774b80eae": "描述", "07da9245cc": "PR标题", "54a093c52d": "标题", - "3b9b96d6a6": "主要的", + "3b9b96d6a6": "main", "ce7d5d3a18": "基础分支", "e4473d438f": "用 AI 生成", "c30cd930ff": "创建PR", @@ -10504,7 +10587,7 @@ "TasksAnimatedVisual": { "efba6f77eb": "阅读议题 #", "b68c92fbdc": "启动工作区", - "4331c4d0f8": "进行中", + "4331c4d0f8": "开放", "b13375617e": "工作树选择器截断名称", "72f9e516a3": "紧迫", "fe47c9c9e8": "工作区准备就绪", @@ -10525,7 +10608,7 @@ "000106adfe": "claude", "7d9f1d5f7d": "(0.8秒)", "944199e54a": "› 购物车总更新", - "623881d72e": "结帐规格", + "623881d72e": "checkout.spec.ts", "5c5cbd783f": "(1.2秒)", "3261c6853b": "› 可以登录", "defe550fe2": "登录规范", @@ -10881,8 +10964,8 @@ "1f9f4def45": "复制代码" }, "CombinedDiffFileTree": { - "f984289373": "没有文件与当前搜索器匹配。", - "eafe1aeb53": "重置搜索器", + "f984289373": "没有文件与当前筛选条件匹配。", + "eafe1aeb53": "重置筛选条件", "be119cb9d1": "已查看的文件", "c00020f081": "文件扩展名", "cd0e0ed79e": "搜索差异文件", @@ -11397,7 +11480,7 @@ "07eccfa397": "此检查没有可用详情。", "a916648574": "打开详情", "834cb3f23d": "用 AI 修复", - "c8f1a2d4e7": "启动前请选择智能体并编辑完整命令输入。", + "c8f1a2d4e7": "启动前请选择智能体并编辑完整提示词。", "b3e7f9a1c2": "检查修复上下文不可用", "d5a8c2f1b9": "启动默认 AI 智能体来修复此检查", "e2b4d7c8a1": "为此检查选择智能体", @@ -11439,7 +11522,7 @@ "109a791e7b": "保存", "bb0a55f856": "保存…", "0203bed775": "取消", - "6978871a3d": "进行中", + "6978871a3d": "开放", "cce596969e": "删除注释", "cad3384faa": "编辑备注", "508ee678a5": "在浏览器中打开" @@ -11570,7 +11653,7 @@ "removeWorktree": "删除工作树", "trashWorktree": "垃圾工作树", "addQuickCommand": "添加快捷命令", - "newQuickCommand": "新的快速命令" + "newQuickCommand": "新的快捷命令" } } }, @@ -11695,7 +11778,8 @@ "4bb7424d6b": "已取消", "6e776f9ef9": "下载失败", "756bfc25c9": "打开", - "09a9489aa5": "显示" + "09a9489aa5": "显示", + "2a4c4b8e1f": "复制" }, "BrowserToolbarMenu": { "429ef481f9": "取消", @@ -11757,9 +11841,9 @@ "cadb7b0bc9": "无效的" }, "AutomationDetail": { - "007c8ad874": "迅速的", + "007c8ad874": "提示词", "a1d52c2189": "使用范围", - "449fc83bf7": "令牌", + "449fc83bf7": "token", "401f40ae79": "预计。花费", "a7c312430d": "最后一次运行", "2df8970cd5": "智能体", @@ -11796,7 +11880,7 @@ "a4ac8fcc62": "/目标", "827b25a81e": "支持技能、文件路径和内置命令,例如", "6d778190b7": "每周运行依赖项审计并总结有风险的更改。", - "058c23cb3f": "迅速的", + "058c23cb3f": "提示词", "c4b19094c2": "日程", "e46c1aa9ad": "创建", "a9d9dccf77": "保存", @@ -11839,7 +11923,7 @@ "AutomationRunHistory": { "402651bfb6": "还没有运行。", "9974a2b429": "状态", - "13988187b3": "令牌", + "13988187b3": "token", "86a248187e": "花费", "149c0b49c7": "工作区", "8faaa00726": "跑步", @@ -11991,7 +12075,7 @@ "872d032d05": "跑步" }, "HermesCronOutputView": { - "e27c716b43": "迅速的", + "e27c716b43": "提示词", "4557213074": "回复", "05affc68e3": "错误", "88d48157fc": "默认" @@ -12051,8 +12135,8 @@ }, "AutomationSetupDecisionField": { "5a7863909c": "为每个新工作区运行设置", - "18f000ad4e": "Advanced", - "874b72195b": "When this automation creates a workspace, prepare it the same way creating a worktree by hand does — run the project's setup and open its terminal tabs." + "18f000ad4e": "高级", + "874b72195b": "当此自动化创建工作区时,使用与手动创建工作树相同的方式准备——运行项目的设置并打开其终端标签页。" } }, "agent": { @@ -12077,7 +12161,7 @@ "8de7c5beaa": "终端不可用", "866083500b": "拖动以调整大小", "443690186e": "调整活动线程列表的大小", - "7cd632006b": "没有智能体活动与这些搜索器匹配。", + "7cd632006b": "没有智能体活动与这些筛选条件匹配。", "a2b4437bfb": "{{value0}} 活动", "023ff75afe": "标记全部已读", "f70e4bec47": "紧凑模式", @@ -12116,14 +12200,14 @@ "4a2ab52781": "正在验证…", "79e7aaed39": "取消", "fdd26d81cc": "Atlassian 账户设置", - "8090504a3e": "在此创建令牌:", - "7b3967c12f": "Atlassian API 令牌", - "3d81bf3ab3": "API 令牌", + "8090504a3e": "在此创建 token:", + "7b3967c12f": "Atlassian API token", + "3d81bf3ab3": "API token", "e91b9a4073": "you@example.com", "2849ddb295": "Atlassian 邮箱", "70fcd360c4": "https://example.atlassian.net", "e176f9d0c5": "Jira Cloud 站点 URL", - "d785c42b8b": "使用 Jira Cloud 站点 URL、Atlassian 邮箱和 API 令牌来浏览议题。", + "d785c42b8b": "使用 Jira Cloud 站点 URL、Atlassian 邮箱和 API token 来浏览议题。", "8388bdea2b": "连接 Jira 站点" } } diff --git a/src/renderer/src/lib/agent-catalog.tsx b/src/renderer/src/lib/agent-catalog.tsx index 069db28292a..d43401e502f 100644 --- a/src/renderer/src/lib/agent-catalog.tsx +++ b/src/renderer/src/lib/agent-catalog.tsx @@ -9,6 +9,7 @@ import { CopilotIcon, KiloIcon, OmpIcon, + OpenCodeIcon, PiIcon } from './agent-icon-glyphs' import { translate } from '@/i18n/i18n' @@ -86,7 +87,6 @@ export const getAgentCatalog = createLocalizedCatalog((): AgentCatalogEntry[] => id: 'opencode', label: translate('auto.lib.agent.catalog.e7a4ca5103', 'OpenCode'), cmd: 'opencode', - faviconDomain: 'opencode.ai', homepageUrl: 'https://opencode.ai/docs/cli/' }, { @@ -336,6 +336,9 @@ export function AgentIcon({ if (agent === 'copilot') { return } + if (agent === 'opencode') { + return + } const catalogEntry = getAgentCatalog().find((a) => a.id === agent) if (catalogEntry?.iconUrl) { return ( diff --git a/src/renderer/src/lib/agent-hibernation-input-guard.ts b/src/renderer/src/lib/agent-hibernation-input-guard.ts new file mode 100644 index 00000000000..1a2d6c431e1 --- /dev/null +++ b/src/renderer/src/lib/agent-hibernation-input-guard.ts @@ -0,0 +1,37 @@ +import type { AgentStatusEntry } from '../../../shared/agent-status-types' + +export function lastInputBlocksHibernation(entry: AgentStatusEntry, inputAt: number): boolean { + // Why: attribute the last real input to the state segment it landed in — + // stateHistory entries in order, then the current state as the open tail. + // Input during any 'working' segment is a draft or queued message, and + // input in the open 'done' tail is post-completion engagement; both die + // with the PTY, so they block. Input during input-expecting states + // ('waiting', 'blocked') or in a done segment that later transitioned + // onward was consumed as a submission and must not block — otherwise every + // session with a mid-turn permission answer would never hibernate. + // Ties (input stamped the same millisecond as a segment start) resolve + // toward blocking: when the input could belong to either neighbor, prefer + // keeping the pane alive over risking a lost draft. + if (inputAt >= entry.stateStartedAt) { + if (entry.state === 'working' || entry.state === 'done') { + return true + } + if (inputAt > entry.stateStartedAt) { + return false + } + } + for (let i = entry.stateHistory.length - 1; i >= 0; i--) { + const past = entry.stateHistory[i] + if (!past || inputAt < past.startedAt) { + continue + } + if (past.state === 'working') { + return true + } + if (inputAt > past.startedAt) { + return false + } + } + // Input older than all recorded segments predates anything still at risk. + return false +} diff --git a/src/renderer/src/lib/agent-hibernation-planner.test.ts b/src/renderer/src/lib/agent-hibernation-planner.test.ts index 79064f312d1..69f9bfbc42c 100644 --- a/src/renderer/src/lib/agent-hibernation-planner.test.ts +++ b/src/renderer/src/lib/agent-hibernation-planner.test.ts @@ -130,8 +130,118 @@ describe('agent sleep planner', () => { plannedWorktrees(snapshot({ lastTerminalInputAtByPaneKey: { [`tab-1:${LEAF}`]: OLD + 1 } })) ).toEqual([]) expect( - plannedWorktrees(snapshot({ lastTerminalInputAtByPaneKey: { [`tab-1:${LEAF}`]: OLD } })) + plannedWorktrees(snapshot({ lastTerminalInputAtByPaneKey: { [`tab-1:${LEAF}`]: OLD - 1 } })) ).toEqual(['wt-bg']) + // A millisecond tie between the input stamp and the done transition + // resolves toward blocking — the keystroke may be a draft character. + expect( + plannedWorktrees(snapshot({ lastTerminalInputAtByPaneKey: { [`tab-1:${LEAF}`]: OLD } })) + ).toEqual([]) + }) + + it('blocks hibernation when real input arrived after the turn started', () => { + // Regression: a draft typed while the agent was still working lands before + // the done timestamp, so the old input-after-done compare was blind to it + // and the hibernation kill discarded the TUI composer's contents. + const turnStartedAt = OLD - 60_000 + const withTurn = entry({ + stateHistory: [{ state: 'working', prompt: 'make it so', startedAt: turnStartedAt }] + }) + expect( + plannedWorktrees( + snapshot({ + agentStatusByPaneKey: { [withTurn.paneKey]: withTurn }, + lastTerminalInputAtByPaneKey: { [withTurn.paneKey]: turnStartedAt + 30_000 } + }) + ) + ).toEqual([]) + // The submission that started the turn precedes the working transition + // and must not block hibernation. + expect( + plannedWorktrees( + snapshot({ + agentStatusByPaneKey: { [withTurn.paneKey]: withTurn }, + lastTerminalInputAtByPaneKey: { [withTurn.paneKey]: turnStartedAt - 1_000 } + }) + ) + ).toEqual(['wt-bg']) + // Input after done still blocks, with or without turn history. + expect( + plannedWorktrees( + snapshot({ + agentStatusByPaneKey: { [withTurn.paneKey]: withTurn }, + lastTerminalInputAtByPaneKey: { [withTurn.paneKey]: OLD + 1 } + }) + ) + ).toEqual([]) + }) + + it('attributes input to its state segment across working/waiting cycles', () => { + // A turn can pause for permission (working → waiting → working → done). A + // draft typed during the FIRST working segment is older than the last + // working start, so a last-working-start guard misses it; and a permission + // answer typed during the waiting segment must NOT block, or every session + // with a mid-turn permission prompt would never hibernate. + const multiSegment = entry({ + stateHistory: [ + { state: 'working', prompt: 'make it so', startedAt: OLD - 90_000 }, + { state: 'waiting', prompt: 'make it so', startedAt: OLD - 60_000 }, + { state: 'working', prompt: 'make it so', startedAt: OLD - 30_000 } + ] + }) + // Draft typed during the first working segment: blocked. + expect( + plannedWorktrees( + snapshot({ + agentStatusByPaneKey: { [multiSegment.paneKey]: multiSegment }, + lastTerminalInputAtByPaneKey: { [multiSegment.paneKey]: OLD - 75_000 } + }) + ) + ).toEqual([]) + // Permission answer typed during the waiting segment: consumed, eligible. + expect( + plannedWorktrees( + snapshot({ + agentStatusByPaneKey: { [multiSegment.paneKey]: multiSegment }, + lastTerminalInputAtByPaneKey: { [multiSegment.paneKey]: OLD - 45_000 } + }) + ) + ).toEqual(['wt-bg']) + // A submission typed in a PREVIOUS done segment that already transitioned + // onward was consumed and must not block the next completion. + const resubmitted = entry({ + stateHistory: [ + { state: 'done', prompt: 'earlier turn', startedAt: OLD - 90_000 }, + { state: 'working', prompt: 'make it so', startedAt: OLD - 30_000 } + ] + }) + expect( + plannedWorktrees( + snapshot({ + agentStatusByPaneKey: { [resubmitted.paneKey]: resubmitted }, + lastTerminalInputAtByPaneKey: { [resubmitted.paneKey]: OLD - 45_000 } + }) + ) + ).toEqual(['wt-bg']) + // Boundary ties resolve toward blocking: input stamped exactly at a + // working-segment start blocks, and a tie at a waiting-segment start also + // blocks because it could belong to the preceding working segment. + expect( + plannedWorktrees( + snapshot({ + agentStatusByPaneKey: { [multiSegment.paneKey]: multiSegment }, + lastTerminalInputAtByPaneKey: { [multiSegment.paneKey]: OLD - 30_000 } + }) + ) + ).toEqual([]) + expect( + plannedWorktrees( + snapshot({ + agentStatusByPaneKey: { [multiSegment.paneKey]: multiSegment }, + lastTerminalInputAtByPaneKey: { [multiSegment.paneKey]: OLD - 60_000 } + }) + ) + ).toEqual([]) }) it('uses foreground terminal tab last-seen as the idle baseline when it is newer', () => { diff --git a/src/renderer/src/lib/agent-hibernation-planner.ts b/src/renderer/src/lib/agent-hibernation-planner.ts index a83943dd20d..6f3a70d446c 100644 --- a/src/renderer/src/lib/agent-hibernation-planner.ts +++ b/src/renderer/src/lib/agent-hibernation-planner.ts @@ -5,6 +5,7 @@ import { type SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' import { parsePaneKey } from '../../../shared/stable-pane-id' +import { lastInputBlocksHibernation } from './agent-hibernation-input-guard' import type { GlobalSettings, TerminalLayoutSnapshot, TerminalTab } from '../../../shared/types' import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' @@ -162,7 +163,14 @@ function getEligiblePane(args: { return null } const inputAt = lastTerminalInputAtByPaneKey[entry.paneKey] - if (typeof inputAt === 'number' && Number.isFinite(inputAt) && inputAt > entry.updatedAt) { + // Why: killing the PTY discards the TUI composer's draft and any queued + // messages. The old input-after-done compare missed drafts typed while the + // agent was still working — the class that lost a user's draft in prod. + if ( + typeof inputAt === 'number' && + Number.isFinite(inputAt) && + lastInputBlocksHibernation(entry, inputAt) + ) { return null } const livePane = getPaneLivePtyId(entry, layout) diff --git a/src/renderer/src/lib/agent-icon-glyphs.tsx b/src/renderer/src/lib/agent-icon-glyphs.tsx index 1bf69207005..d722f7a1d42 100644 --- a/src/renderer/src/lib/agent-icon-glyphs.tsx +++ b/src/renderer/src/lib/agent-icon-glyphs.tsx @@ -112,6 +112,31 @@ export function CopilotIcon({ size = 14 }: { size?: number }): React.JSX.Element ) } +export function OpenCodeIcon({ size = 14 }: { size?: number }): React.JSX.Element { + // SVG geometry sourced from opencode.ai/favicon.svg's official 512 canvas. + // Why: the branded fills are adapted to currentColor so the mark is visible + // in both Orca themes, and keeping the square viewBox matches sibling glyphs. + return ( + + + + + ) +} + export function AgentLetterIcon({ letter, size = 14 diff --git a/src/renderer/src/lib/agent-send-title-status.ts b/src/renderer/src/lib/agent-send-title-status.ts new file mode 100644 index 00000000000..11f949fde88 --- /dev/null +++ b/src/renderer/src/lib/agent-send-title-status.ts @@ -0,0 +1,31 @@ +import { type AgentStatus, detectAgentStatusFromTitle, getAgentLabel } from './agent-status' + +const EXPLICIT_IDLE_SEND_TITLE_RE = /(^|\s)(ready|idle|done)(\s|$|[.!?])/i +const CLAUDE_IDLE_PREFIX = '\u2733' +const GEMINI_IDLE_PREFIX = '\u25c7' +const PI_IDLE_PREFIX = '\u03c0 - ' + +export function detectAgentSendTitleStatus(title: string | null | undefined): AgentStatus | null { + if (!title || getAgentLabel(title) === null) { + return null + } + + const status = detectAgentStatusFromTitle(title) + if (status !== 'idle') { + return status + } + + // Why: selected-target sends are immediate. A bare agent name proves identity, + // but not that the CLI is ready for submitted input yet. + return isExplicitIdleSendTitle(title) ? status : null +} + +function isExplicitIdleSendTitle(title: string): boolean { + return ( + EXPLICIT_IDLE_SEND_TITLE_RE.test(title) || + title.startsWith(CLAUDE_IDLE_PREFIX) || + title.startsWith('* ') || + title.includes(GEMINI_IDLE_PREFIX) || + title.startsWith(PI_IDLE_PREFIX) + ) +} diff --git a/src/renderer/src/lib/ai-vault-resume-command.test.ts b/src/renderer/src/lib/ai-vault-resume-command.test.ts index 1fa2559074d..dc0345491bf 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.test.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.test.ts @@ -294,4 +294,111 @@ describe('ai vault resume command runtime', () => { }) ).toBe("cd '/home/alice/repo' && CODEX_HOME='/home/alice/.codex' codex 'resume' 'session one'") }) + + it('returns the remote resume command verbatim for non-local host sessions', () => { + const state = makeState({ worktreePath: '/home/alice/repo' }) + state.repos = [{ id: 'repo-1', path: '/home/alice/repo', connectionId: 'ssh-1' }] as never + + expect( + buildAiVaultResumeStartupForWorktree({ + state, + worktreeId: 'repo-1::worktree-1', + session: { + agent: 'codex', + sessionId: 'session one', + cwd: '/home/alice/repo', + codexHome: null, + executionHostId: 'ssh:dev-box', + resumeCommand: "CODEX_HOME='/root/.codex' codex resume 'session one'" + } + }) + ).toEqual({ command: "CODEX_HOME='/root/.codex' codex resume 'session one'" }) + }) + + it('bypasses the resume pipeline even when the command override is blank', () => { + const state = makeState({ worktreePath: '/home/alice/repo' }) + state.repos = [{ id: 'repo-1', path: '/home/alice/repo', connectionId: 'ssh-1' }] as never + + expect( + buildAiVaultResumeCommandForWorktree({ + state, + worktreeId: 'repo-1::worktree-1', + commandOverride: ' ', + session: { + agent: 'codex', + sessionId: 'session one', + cwd: '/home/alice/repo', + codexHome: null, + executionHostId: 'ssh:dev-box', + resumeCommand: "CODEX_HOME='/root/.codex' codex resume 'session one'" + } + }) + ).toBe("CODEX_HOME='/root/.codex' codex resume 'session one'") + }) + + it('rebuilds the command when a non-blank override is supplied for a remote session', () => { + const state = makeState({ worktreePath: '/home/alice/repo' }) + state.repos = [{ id: 'repo-1', path: '/home/alice/repo', connectionId: 'ssh-1' }] as never + + expect( + buildAiVaultResumeCommandForWorktree({ + state, + worktreeId: 'repo-1::worktree-1', + commandOverride: 'my-codex', + session: { + agent: 'codex', + sessionId: 'session one', + cwd: '/home/alice/repo', + codexHome: null, + executionHostId: 'ssh:dev-box', + resumeCommand: "CODEX_HOME='/root/.codex' codex resume 'session one'" + } + }) + ).toBe("cd '/home/alice/repo' && my-codex 'resume' 'session one'") + }) + + it('rebuilds overridden remote commands with the recorded remote host platform', () => { + const state = makeState({ worktreePath: '/home/alice/repo' }) + state.repos = [{ id: 'repo-1', path: '/home/alice/repo', connectionId: 'ssh-1' }] as never + + expect( + buildAiVaultResumeCommandForWorktree({ + state, + worktreeId: 'repo-1::worktree-1', + commandOverride: 'my-codex', + session: { + agent: 'codex', + sessionId: 'session one', + cwd: 'C:/Users/alice/repo', + codexHome: 'C:/Users/alice/.codex', + executionHostId: 'ssh:win-box', + executionHostPlatform: 'win32', + resumeCommand: + 'cmd /d /s /c "cd /d ""C:/Users/alice/repo"" && set ""CODEX_HOME=C:/Users/alice/.codex"" && codex resume ""session one"""' + } + }) + ).toBe( + "Set-Location -LiteralPath 'C:/Users/alice/repo'; $env:CODEX_HOME='C:/Users/alice/.codex'; my-codex 'resume' 'session one'" + ) + }) + + it('ignores a stored resume command for local-host sessions', () => { + const state = makeState({ worktreePath: '/home/alice/repo' }) + state.repos = [{ id: 'repo-1', path: '/home/alice/repo', connectionId: 'ssh-1' }] as never + + expect( + buildAiVaultResumeCommandForWorktree({ + state, + worktreeId: 'repo-1::worktree-1', + session: { + agent: 'codex', + sessionId: 'session one', + cwd: '/home/alice/repo', + codexHome: null, + executionHostId: 'local', + resumeCommand: "CODEX_HOME='/root/.codex' codex resume 'session one'" + } + }) + ).toBe("cd '/home/alice/repo' && codex 'resume' 'session one'") + }) }) diff --git a/src/renderer/src/lib/ai-vault-resume-command.ts b/src/renderer/src/lib/ai-vault-resume-command.ts index 95da5ee49de..691d69efc09 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.ts @@ -19,10 +19,14 @@ import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-co import { CLIENT_PLATFORM } from '@/lib/new-workspace' import { buildAgentResumeStartupPlan } from '@/lib/tui-agent-startup' import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' -import { parseExecutionHostId } from '../../../shared/execution-host' +import { LOCAL_EXECUTION_HOST_ID, parseExecutionHostId } from '../../../shared/execution-host' import { parseWorkspaceKey } from '../../../shared/workspace-scope' -type AiVaultResumeCommandSession = Pick +type AiVaultResumeCommandSession = Pick< + AiVaultSession, + 'agent' | 'sessionId' | 'cwd' | 'codexHome' +> & + Partial> export type AiVaultResumeStartup = { command: string @@ -65,7 +69,20 @@ export function buildAiVaultResumeStartupForWorktree(args: { session: AiVaultResumeCommandSession commandOverride?: string | null }): AiVaultResumeStartup { - const platform = getAiVaultResumePlatform(args.state, args.worktreeId) + if ( + args.session.executionHostId && + args.session.executionHostId !== LOCAL_EXECUTION_HOST_ID && + args.session.resumeCommand && + !args.commandOverride?.trim() + ) { + return { command: args.session.resumeCommand } + } + const platform = + args.session.executionHostId && + args.session.executionHostId !== LOCAL_EXECUTION_HOST_ID && + args.session.executionHostPlatform + ? args.session.executionHostPlatform + : getAiVaultResumePlatform(args.state, args.worktreeId) const codexHome = getAiVaultResumeCodexHome(args.session.codexHome, platform) // Why: the queued command is typed verbatim into the freshly spawned tab whose // live shell is the configured Windows shell (default PowerShell). Hardcoding diff --git a/src/renderer/src/lib/ai-vault-resume-target.test.ts b/src/renderer/src/lib/ai-vault-resume-target.test.ts index 2bbd2f0bb32..207bc525a59 100644 --- a/src/renderer/src/lib/ai-vault-resume-target.test.ts +++ b/src/renderer/src/lib/ai-vault-resume-target.test.ts @@ -1,11 +1,14 @@ import { describe, expect, it } from 'vitest' import type { AppState } from '@/store/types' import { + canResumeAiVaultSessionOnTarget, + getAiVaultResumeWorkspaceExecutionHostId, getAiVaultResumeRepoTargetStatus, getAiVaultResumeWorktreeTargetStatus, getAiVaultResumeWorkspaceTargetStatus, isSupportedAiVaultResumeRepo, - isUnsupportedAiVaultResumeRepo + isUnsupportedAiVaultResumeRepo, + isWslStoredAiVaultSessionFile } from './ai-vault-resume-target' import { folderWorkspaceKey } from '../../../shared/workspace-scope' @@ -26,6 +29,101 @@ function makeState( } as unknown as ResumeTargetState } +describe('ai vault session storage compatibility', () => { + const hostSessionFile = '/Users/ada/.claude/projects/-Users-ada-repo/session-1.jsonl' + const windowsHostSessionFile = 'C:\\Users\\ada\\.claude\\projects\\C--repo\\session-1.jsonl' + const wslSessionFile = '\\\\wsl$\\Ubuntu\\home\\ada\\.claude\\projects\\-home-ada-repo\\s-1.jsonl' + const wslLocalhostSessionFile = + '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.claude\\projects\\-home-ada-repo\\s-1.jsonl' + + it('detects WSL-stored session files', () => { + expect(isWslStoredAiVaultSessionFile(wslSessionFile)).toBe(true) + expect(isWslStoredAiVaultSessionFile(wslLocalhostSessionFile)).toBe(true) + expect(isWslStoredAiVaultSessionFile(hostSessionFile)).toBe(false) + expect(isWslStoredAiVaultSessionFile(windowsHostSessionFile)).toBe(false) + expect(isWslStoredAiVaultSessionFile(null)).toBe(false) + expect(isWslStoredAiVaultSessionFile(undefined)).toBe(false) + }) + + it('allows host-stored sessions on local targets only', () => { + expect( + canResumeAiVaultSessionOnTarget({ sessionFilePath: hostSessionFile, targetStatus: 'local' }) + ).toBe(true) + expect( + canResumeAiVaultSessionOnTarget({ sessionFilePath: hostSessionFile, targetStatus: 'ssh' }) + ).toBe(false) + expect( + canResumeAiVaultSessionOnTarget({ + sessionFilePath: windowsHostSessionFile, + targetStatus: 'ssh' + }) + ).toBe(false) + }) + + it('allows WSL-stored sessions on local and SSH targets', () => { + expect( + canResumeAiVaultSessionOnTarget({ sessionFilePath: wslSessionFile, targetStatus: 'local' }) + ).toBe(true) + expect( + canResumeAiVaultSessionOnTarget({ sessionFilePath: wslSessionFile, targetStatus: 'ssh' }) + ).toBe(true) + }) + + it('allows host-tagged SSH sessions only on the matching SSH target', () => { + expect( + canResumeAiVaultSessionOnTarget({ + sessionFilePath: '/home/ada/.codex/sessions/remote.jsonl', + sessionExecutionHostId: 'ssh:dev-box', + targetStatus: 'ssh', + targetExecutionHostId: 'ssh:dev-box' + }) + ).toBe(true) + expect( + canResumeAiVaultSessionOnTarget({ + sessionFilePath: '/home/ada/.codex/sessions/remote.jsonl', + sessionExecutionHostId: 'ssh:dev-box', + targetStatus: 'ssh', + targetExecutionHostId: 'ssh:other-box' + }) + ).toBe(false) + expect( + canResumeAiVaultSessionOnTarget({ + sessionFilePath: '/home/ada/.codex/sessions/remote.jsonl', + sessionExecutionHostId: 'ssh:dev-box', + targetStatus: 'local', + targetExecutionHostId: 'local' + }) + ).toBe(false) + expect( + canResumeAiVaultSessionOnTarget({ + sessionFilePath: '/home/ada/.codex/sessions/remote.jsonl', + sessionExecutionHostId: 'ssh:dev-box', + targetStatus: 'local' + }) + ).toBe(false) + }) + + it('allows WSL-stored local sessions on any SSH target even with explicit host ids', () => { + expect( + canResumeAiVaultSessionOnTarget({ + sessionFilePath: wslSessionFile, + sessionExecutionHostId: 'local', + targetStatus: 'ssh', + targetExecutionHostId: 'ssh:dev-box' + }) + ).toBe(true) + }) + + it('never allows runtime or unknown targets', () => { + expect( + canResumeAiVaultSessionOnTarget({ sessionFilePath: wslSessionFile, targetStatus: 'runtime' }) + ).toBe(false) + expect( + canResumeAiVaultSessionOnTarget({ sessionFilePath: hostSessionFile, targetStatus: 'unknown' }) + ).toBe(false) + }) +}) + describe('ai vault resume target ownership', () => { it('classifies local, SSH, runtime, and unknown repo owners', () => { expect(getAiVaultResumeRepoTargetStatus({ connectionId: null, executionHostId: 'local' })).toBe( @@ -114,6 +212,34 @@ describe('ai vault resume target ownership', () => { ).toBe('runtime') }) + it('resolves exact execution host ids for active workspaces', () => { + const state = makeState({ + worktreesByRepo: { + 'repo-1': [{ id: 'repo-1::/repo/orca', repoId: 'repo-1', hostId: 'ssh:ssh-1' }] + }, + repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'local' }] + }) + + expect(getAiVaultResumeWorkspaceExecutionHostId(state, 'repo-1::/repo/orca')).toBe('ssh:ssh-1') + expect(getAiVaultResumeWorkspaceExecutionHostId(state, 'worktree:repo-1::/repo/orca')).toBe( + 'ssh:ssh-1' + ) + }) + + it('resolves local execution host ids for local workspaces', () => { + expect( + getAiVaultResumeWorkspaceExecutionHostId( + makeState({ + worktreesByRepo: { + 'repo-1': [{ id: 'repo-1::/repo/orca', repoId: 'repo-1' }] + }, + repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'local' }] + }), + 'repo-1::/repo/orca' + ) + ).toBe('local') + }) + it('supports folder workspaces owned by SSH project groups', () => { expect( getAiVaultResumeWorkspaceTargetStatus( diff --git a/src/renderer/src/lib/ai-vault-resume-target.ts b/src/renderer/src/lib/ai-vault-resume-target.ts index 0e2afff8f34..823840ec85b 100644 --- a/src/renderer/src/lib/ai-vault-resume-target.ts +++ b/src/renderer/src/lib/ai-vault-resume-target.ts @@ -1,5 +1,6 @@ import { getRepoExecutionHostId, + LOCAL_EXECUTION_HOST_ID, normalizeExecutionHostId, parseExecutionHostId, toSshExecutionHostId, @@ -8,6 +9,7 @@ import { import type { Repo } from '../../../shared/types' import { getRepoIdFromWorktreeId } from '../../../shared/worktree-id' import { parseWorkspaceKey } from '../../../shared/workspace-scope' +import { isWslUncPath } from '../../../shared/wsl-paths' import type { AppState } from '@/store/types' import { getFolderWorkspaceCandidateRepos } from './folder-workspace-connection' @@ -36,6 +38,49 @@ export function isSupportedAiVaultResumeTargetStatus(status: AiVaultResumeTarget return status === 'local' || status === 'ssh' } +export function isWslStoredAiVaultSessionFile(sessionFilePath: string | null | undefined): boolean { + return Boolean(sessionFilePath && isWslUncPath(sessionFilePath)) +} + +export function canResumeAiVaultSessionOnTarget(args: { + sessionFilePath: string | null | undefined + sessionExecutionHostId?: ExecutionHostId | null + targetStatus: AiVaultResumeTargetStatus + targetExecutionHostId?: ExecutionHostId | null +}): boolean { + if (!isSupportedAiVaultResumeTargetStatus(args.targetStatus)) { + return false + } + const sessionExecutionHostId = normalizeExecutionHostId(args.sessionExecutionHostId) + const targetExecutionHostId = normalizeExecutionHostId(args.targetExecutionHostId) + if (sessionExecutionHostId) { + if (targetExecutionHostId) { + if (sessionExecutionHostId === targetExecutionHostId) { + return true + } + // Why: SSH-to-local-WSL setups (#6270) tag the session 'local' but the + // file lives under a WSL UNC path reachable from any SSH shell into this + // machine, so we bypass the exact host-id match for that case. + return ( + sessionExecutionHostId === LOCAL_EXECUTION_HOST_ID && + args.targetStatus === 'ssh' && + isWslStoredAiVaultSessionFile(args.sessionFilePath) + ) + } + if (sessionExecutionHostId !== LOCAL_EXECUTION_HOST_ID) { + return false + } + } + // Why: vault sessions are scanned from this machine's disk (host home dirs + // plus local WSL homes). An SSH shell can only reach the WSL-stored ones + // (SSH-to-local-WSL setups, #6270); host-stored session files do not exist + // on a remote filesystem, so queuing a resume there is guaranteed to fail. + if (args.targetStatus === 'ssh') { + return isWslStoredAiVaultSessionFile(args.sessionFilePath) + } + return true +} + export function isUnsupportedAiVaultResumeRepo( repo: AiVaultResumeRepoOwner | null | undefined ): boolean { @@ -63,6 +108,32 @@ export function getAiVaultResumeWorktreeTargetStatus(args: { ) } +export function getAiVaultResumeWorkspaceExecutionHostId( + state: Pick, + workspaceId: string | null +): ExecutionHostId | null { + if (!workspaceId) { + return null + } + + const workspaceKey = parseWorkspaceKey(workspaceId) + if (workspaceKey?.type === 'folder') { + return getAiVaultResumeFolderExecutionHostId(state, workspaceKey.folderWorkspaceId) + } + + const worktreeId = workspaceKey?.type === 'worktree' ? workspaceKey.worktreeId : workspaceId + const worktree = Object.values(state.worktreesByRepo ?? {}) + .flat() + .find((candidate) => candidate.id === worktreeId) + const worktreeHostId = normalizeExecutionHostId(worktree?.hostId) + if (worktreeHostId) { + return worktreeHostId + } + const repoId = worktree?.repoId ?? getRepoIdFromWorktreeId(worktreeId) + const repo = state.repos.find((candidate) => candidate.id === repoId) + return repo ? getRepoExecutionHostId(repo) : null +} + export function getAiVaultResumeWorkspaceTargetStatus( state: Pick, workspaceId: string | null @@ -114,6 +185,29 @@ function getAiVaultResumeFolderTargetStatus( ) } +function getAiVaultResumeFolderExecutionHostId( + state: Pick, + folderWorkspaceId: string +): ExecutionHostId | null { + const workspace = state.folderWorkspaces.find((entry) => entry.id === folderWorkspaceId) + if (!workspace) { + return null + } + + const group = state.projectGroups.find((entry) => entry.id === workspace.projectGroupId) + const groupHostId = normalizeExecutionHostId(group?.executionHostId) + if (groupHostId) { + return groupHostId + } + const explicitConnectionId = (workspace.connectionId ?? group?.connectionId ?? '').trim() + if (explicitConnectionId) { + return toSshExecutionHostId(explicitConnectionId) + } + return mergeAiVaultResumeExecutionHostIds( + getFolderWorkspaceCandidateRepos(state, folderWorkspaceId).map(getRepoExecutionHostId) + ) +} + function getAiVaultResumeExecutionHostTargetStatus( hostId: ExecutionHostId | null | undefined ): AiVaultResumeTargetStatus { @@ -140,3 +234,13 @@ function mergeAiVaultResumeExecutionHostTargetStatuses( } return new Set(hostIds).size === 1 ? (statuses[0] ?? 'unknown') : 'unknown' } + +function mergeAiVaultResumeExecutionHostIds( + hostIds: readonly ExecutionHostId[] +): ExecutionHostId | null { + if (hostIds.length === 0) { + return LOCAL_EXECUTION_HOST_ID + } + const uniqueHostIds = new Set(hostIds) + return uniqueHostIds.size === 1 ? (hostIds[0] ?? null) : null +} diff --git a/src/renderer/src/lib/ai-vault-session-drag.test.ts b/src/renderer/src/lib/ai-vault-session-drag.test.ts index 6e1a93f763f..e51a0c78efd 100644 --- a/src/renderer/src/lib/ai-vault-session-drag.test.ts +++ b/src/renderer/src/lib/ai-vault-session-drag.test.ts @@ -48,6 +48,7 @@ describe('Session History session drag data', () => { sessionId: 'session-1', title: 'Fix terminal split', command: "cd '/repo' && claude --resume session-1", + sessionFilePath: '/Users/ada/.claude/projects/-repo/session-1.jsonl', env: { ANTHROPIC_BASE_URL: 'https://claude.example.test' }, launchConfig: { agentCommand: 'claude --dangerously-skip-permissions', @@ -63,6 +64,24 @@ describe('Session History session drag data', () => { expect(readAiVaultSessionDragData(transfer)).toEqual(payload) }) + it('rejects blank session file paths', () => { + const transfer = createTransfer() + transfer.setData( + AI_VAULT_SESSION_DRAG_TYPE, + JSON.stringify({ + kind: 'ai-vault-session', + version: 1, + agent: 'claude', + sessionId: 'session-1', + title: 'Blank session file path', + command: 'claude --resume session-1', + sessionFilePath: ' ' + }) + ) + + expect(readAiVaultSessionDragData(transfer)).toBeNull() + }) + it('rejects malformed payloads', () => { const transfer = createTransfer() transfer.setData( diff --git a/src/renderer/src/lib/ai-vault-session-drag.ts b/src/renderer/src/lib/ai-vault-session-drag.ts index 1dfede058ec..7b5ec941dd7 100644 --- a/src/renderer/src/lib/ai-vault-session-drag.ts +++ b/src/renderer/src/lib/ai-vault-session-drag.ts @@ -1,6 +1,7 @@ import { AI_VAULT_AGENTS, type AiVaultAgent } from '../../../shared/ai-vault-types' import type { SleepingAgentLaunchConfig } from '../../../shared/agent-session-resume' import { measureClipboardTextByteLength } from '../../../shared/clipboard-text' +import { normalizeExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' export const AI_VAULT_SESSION_DRAG_TYPE = 'application/x-orca-ai-vault-session' export const AI_VAULT_SESSION_DRAG_START_EVENT = 'orca-ai-vault-session-drag-start' @@ -12,6 +13,10 @@ export type AiVaultSessionDragPayload = { sessionId: string title: string command: string + // Why: drop targets must know where the session file lives (host vs local + // WSL) to reject SSH panes that cannot reach it. + sessionFilePath?: string + sessionExecutionHostId?: ExecutionHostId // Why: drag/drop resume must preserve planned env/default args, not just the shell command. env?: Record launchConfig?: SleepingAgentLaunchConfig @@ -63,6 +68,9 @@ function isSerializedPayload(value: unknown): value is SerializedAiVaultSessionD isNonEmptyString(payload.sessionId) && isNonEmptyString(payload.title) && isNonEmptyString(payload.command) && + (payload.sessionFilePath === undefined || isNonEmptyString(payload.sessionFilePath)) && + (payload.sessionExecutionHostId === undefined || + Boolean(normalizeExecutionHostId(payload.sessionExecutionHostId))) && (payload.env === undefined || isStringRecord(payload.env)) && (payload.launchConfig === undefined || isLaunchConfig(payload.launchConfig)) ) @@ -110,12 +118,23 @@ export function readAiVaultSessionDragData( if (!isSerializedPayload(parsed)) { return null } - const { agent, sessionId, title, command, env, launchConfig } = parsed + const { + agent, + sessionId, + title, + command, + sessionFilePath, + sessionExecutionHostId, + env, + launchConfig + } = parsed return { agent, sessionId, title, command, + ...(sessionFilePath ? { sessionFilePath } : {}), + ...(sessionExecutionHostId ? { sessionExecutionHostId } : {}), ...(env ? { env } : {}), ...(launchConfig ? { launchConfig } : {}) } diff --git a/src/renderer/src/lib/duplicate-browser-tab-options.ts b/src/renderer/src/lib/duplicate-browser-tab-options.ts new file mode 100644 index 00000000000..e2a41c80df8 --- /dev/null +++ b/src/renderer/src/lib/duplicate-browser-tab-options.ts @@ -0,0 +1,15 @@ +import type { BrowserTab } from '../../../shared/types' + +export function buildDuplicatedBrowserTabOptions( + source: Pick +): { + title: string + sessionProfileId: string | null + sessionPartition: string | null +} { + return { + title: source.title, + sessionProfileId: source.sessionProfileId ?? null, + sessionPartition: source.sessionPartition ?? null + } +} diff --git a/src/renderer/src/lib/github-links.test.ts b/src/renderer/src/lib/github-links.test.ts index 31c7f72aadd..64a9bd59df3 100644 --- a/src/renderer/src/lib/github-links.test.ts +++ b/src/renderer/src/lib/github-links.test.ts @@ -133,7 +133,36 @@ describe('normalizeGitHubLinkQuery', () => { it('accepts full GitHub URLs whose slug differs from the selected repo slug', () => { expect(normalizeGitHubLinkQuery('https://github.com/stablyai/orca/issues/923')).toEqual({ query: 'https://github.com/stablyai/orca/issues/923', - directNumber: 923 + directNumber: 923, + directLink: { + slug: { owner: 'stablyai', repo: 'orca' }, + number: 923, + type: 'issue' + } + }) + }) + + it('preserves PR route intent for full GitHub URLs', () => { + expect(normalizeGitHubLinkQuery('https://github.com/stablyai/orca/pull/6934')).toEqual({ + query: 'https://github.com/stablyai/orca/pull/6934', + directNumber: 6934, + directLink: { + slug: { owner: 'stablyai', repo: 'orca' }, + number: 6934, + type: 'pr' + } + }) + }) + + it('preserves route intent for URLs with uppercase schemes', () => { + expect(normalizeGitHubLinkQuery('HTTPS://github.com/stablyai/orca/pull/6934')).toEqual({ + query: 'HTTPS://github.com/stablyai/orca/pull/6934', + directNumber: 6934, + directLink: { + slug: { owner: 'stablyai', repo: 'orca' }, + number: 6934, + type: 'pr' + } }) }) diff --git a/src/renderer/src/lib/github-links.ts b/src/renderer/src/lib/github-links.ts index 0f1fc4f25a6..05ea8c812f5 100644 --- a/src/renderer/src/lib/github-links.ts +++ b/src/renderer/src/lib/github-links.ts @@ -1,15 +1,23 @@ import { isWorkItemLinkQueryTooLarge } from './work-item-link-query-bounds' const GH_ITEM_PATH_RE = /^\/([^/]+)\/([^/]+)\/(issues|pull)\/(\d+)(?:\/.*)?$/i +const HTTP_URL_PREFIX_RE = /^https?:\/\//i export type RepoSlug = { owner: string repo: string } +export type GitHubIssueOrPRLink = { + slug: RepoSlug + number: number + type: 'issue' | 'pr' +} + export type GitHubLinkQuery = { query: string directNumber: number | null + directLink?: GitHubIssueOrPRLink tooLarge?: boolean } @@ -67,11 +75,7 @@ export function parseGitHubIssueOrPRNumber(input: string): number | null { * Parses an owner/repo slug plus issue/PR number from a GitHub URL. Returns * null for anything that isn't a recognizable GitHub-shaped issue or pull URL. */ -export function parseGitHubIssueOrPRLink(input: string): { - slug: RepoSlug - number: number - type: 'issue' | 'pr' -} | null { +export function parseGitHubIssueOrPRLink(input: string): GitHubIssueOrPRLink | null { const trimmed = input.trim() if (!trimmed) { return null @@ -118,7 +122,7 @@ export function normalizeGitHubLinkQuery(raw: string): GitHubLinkQuery { } const direct = parseGitHubIssueOrPRNumber(trimmed) - if (direct !== null && !trimmed.startsWith('http')) { + if (direct !== null && !HTTP_URL_PREFIX_RE.test(trimmed)) { return { query: trimmed, directNumber: direct } } @@ -132,6 +136,7 @@ export function normalizeGitHubLinkQuery(raw: string): GitHubLinkQuery { // slug differs from the origin remote. return { query: trimmed, - directNumber: link.number + directNumber: link.number, + directLink: link } } diff --git a/src/renderer/src/lib/github-work-item-background-create.test.ts b/src/renderer/src/lib/github-work-item-background-create.test.ts index 17a70124d38..ef7adfdb2b9 100644 --- a/src/renderer/src/lib/github-work-item-background-create.test.ts +++ b/src/renderer/src/lib/github-work-item-background-create.test.ts @@ -97,6 +97,13 @@ function makeDeps(store = makeStore()) { resolveSetupDecision: vi.fn().mockResolvedValue({ kind: 'decided', decision: 'inherit' }), resolvePrStartPoint: vi.fn(), confirmHooks: vi.fn().mockResolvedValue('run'), + readIssueCommand: vi.fn().mockResolvedValue({ + effectiveContent: null, + localContent: null, + sharedContent: null, + localFilePath: '', + source: 'none' + }), beginBackgroundCreate: vi.fn(() => 'creation-1'), continueBackgroundCreate: vi.fn(() => true), activatePendingCreate: vi.fn(), @@ -156,6 +163,46 @@ describe('createGitHubWorkItemWorkspaceInBackground', () => { ) }) + it('does not resolve a PR start point for a stale PR-typed issue URL', async () => { + const deps = makeDeps() + + await createGitHubWorkItemWorkspaceInBackground( + { + item: makeIssue({ + type: 'pr', + number: 6933, + title: 'The board columns are displayed backwards', + url: 'https://github.com/stablyai/orca/issues/6933', + branchName: 'fix-issue-6933', + baseRefName: 'main', + isCrossRepository: true + }), + repoId: 'repo-1', + telemetrySource: 'sidebar', + openModalFallback: vi.fn() + }, + deps + ) + + expect(deps.resolvePrStartPoint).not.toHaveBeenCalled() + const beginCalls = deps.beginBackgroundCreate.mock.calls as unknown as [ + WorktreeCreationRequest + ][] + expect(beginCalls[0]?.[0].linkedIssue).toBe(6933) + expect(beginCalls[0]?.[0].linkedPR).toBeUndefined() + const continueCalls = deps.continueBackgroundCreate.mock.calls as unknown as [ + string, + WorktreeCreationRequest + ][] + const request = continueCalls[0]?.[1] + expect(request?.linkedIssue).toBe(6933) + expect(request?.linkedPR).toBeUndefined() + expect(request?.baseBranch).toBeUndefined() + expect(request?.pushTarget).toBeUndefined() + expect(request?.branchNameOverride).toBeUndefined() + expect(request?.compareBaseRef).toBeUndefined() + }) + it('shows the pending workspace before async preflight resolves', async () => { let resolveSetupDecision: ((value: { kind: 'decided'; decision: 'inherit' }) => void) | null = null @@ -559,17 +606,36 @@ describe('createGitHubWorkItemWorkspaceInBackground', () => { await createGitHubWorkItemWorkspaceInBackground( { - item: makeIssue({ type: 'pr', number: 7, url: 'https://github.com/stablyai/orca/pull/7' }), + item: makeIssue({ + type: 'pr', + number: 6934, + url: 'https://github.com/stablyai/orca/pull/6934', + branchName: 'fix-issue-6933', + baseRefName: 'main', + isCrossRepository: true + }), repoId: 'repo-1', openModalFallback: vi.fn() }, deps ) + expect(deps.resolvePrStartPoint).toHaveBeenCalledWith( + 'repo-1', + 6934, + expect.anything(), + expect.objectContaining({ + type: 'pr', + number: 6934, + branchName: 'fix-issue-6933', + baseRefName: 'main', + isCrossRepository: true + }) + ) expect(deps.continueBackgroundCreate).toHaveBeenCalledWith( 'creation-1', expect.objectContaining({ - linkedPR: 7, + linkedPR: 6934, baseBranch: 'feature/from-pr', pushTarget: { remote: 'origin', branch: 'feature/from-pr' }, branchNameOverride: 'feature/from-pr', @@ -607,4 +673,206 @@ describe('createGitHubWorkItemWorkspaceInBackground', () => { expect(request.startup?.command).toBe('codex --prompt-file') expect(buildAgentStartupPlan).not.toHaveBeenCalled() }) + + it('attaches the rendered issue command when the repo configured one and trust runs', async () => { + const deps = makeDeps() + deps.readIssueCommand.mockResolvedValueOnce({ + effectiveContent: 'gh issue view {{issue}} --repo {{artifact_url}}', + localContent: null, + sharedContent: 'gh issue view {{issue}} --repo {{artifact_url}}', + localFilePath: '', + source: 'shared' + }) + + await createGitHubWorkItemWorkspaceInBackground( + { + item: makeIssue(), + repoId: 'repo-1', + openModalFallback: vi.fn() + }, + deps + ) + + expect(deps.confirmHooks).toHaveBeenCalledWith(expect.anything(), 'repo-1', 'setup') + expect(deps.confirmHooks).toHaveBeenCalledWith(expect.anything(), 'repo-1', 'issueCommand') + const continueCall = deps.continueBackgroundCreate.mock.calls[0] as unknown[] | undefined + expect(continueCall).toBeDefined() + const request = continueCall?.[1] as WorktreeCreationRequest + expect(request.issueCommand?.command).toBe( + 'gh issue view 42 --repo https://github.com/stablyai/orca/issues/42' + ) + }) + + it('omits the issue command when the repo configured none', async () => { + const deps = makeDeps() + + await createGitHubWorkItemWorkspaceInBackground( + { + item: makeIssue(), + repoId: 'repo-1', + openModalFallback: vi.fn() + }, + deps + ) + + const continueCall = deps.continueBackgroundCreate.mock.calls[0] as unknown[] | undefined + expect(continueCall).toBeDefined() + const request = continueCall?.[1] as WorktreeCreationRequest + expect(request.issueCommand).toBeUndefined() + expect(deps.confirmHooks).not.toHaveBeenCalledWith(expect.anything(), 'repo-1', 'issueCommand') + }) + + it('skips the issue command when setup trust was declined', async () => { + const deps = makeDeps() + deps.readIssueCommand.mockResolvedValueOnce({ + effectiveContent: 'echo {{issue}}', + localContent: 'echo {{issue}}', + sharedContent: null, + localFilePath: '/repo/.orca/issue-command', + source: 'local' + }) + // Why: the setup confirmHooks call resolves 'skip', which mirrors the + // composer suppressing the issue command when setup trust is declined. + deps.confirmHooks.mockResolvedValue('skip') + + await createGitHubWorkItemWorkspaceInBackground( + { + item: makeIssue(), + repoId: 'repo-1', + openModalFallback: vi.fn() + }, + deps + ) + + const continueCall = deps.continueBackgroundCreate.mock.calls[0] as unknown[] | undefined + expect(continueCall).toBeDefined() + const request = continueCall?.[1] as WorktreeCreationRequest + expect(request.issueCommand).toBeUndefined() + expect(deps.confirmHooks).not.toHaveBeenCalledWith(expect.anything(), 'repo-1', 'issueCommand') + // Why: a declined setup trust short-circuits before the (up-to-15s) read, + // so workspace creation is never stalled to fetch a command we will drop. + expect(deps.readIssueCommand).not.toHaveBeenCalled() + }) + + it('does not run the issue command for PR items', async () => { + const deps = makeDeps() + deps.resolvePrStartPoint.mockResolvedValueOnce({ + baseBranch: 'feature/from-pr', + pushTarget: { remote: 'origin', branch: 'feature/from-pr' }, + branchNameOverride: 'feature/from-pr', + compareBaseRef: 'main' + }) + deps.readIssueCommand.mockResolvedValueOnce({ + effectiveContent: 'echo {{issue}}', + localContent: 'echo {{issue}}', + sharedContent: null, + localFilePath: '/repo/.orca/issue-command', + source: 'local' + }) + + await createGitHubWorkItemWorkspaceInBackground( + { + item: makeIssue({ type: 'pr', number: 7, url: 'https://github.com/stablyai/orca/pull/7' }), + repoId: 'repo-1', + openModalFallback: vi.fn() + }, + deps + ) + + const continueCall = deps.continueBackgroundCreate.mock.calls[0] as unknown[] | undefined + expect(continueCall).toBeDefined() + const request = continueCall?.[1] as WorktreeCreationRequest + expect(request.issueCommand).toBeUndefined() + expect(deps.confirmHooks).not.toHaveBeenCalledWith(expect.anything(), 'repo-1', 'issueCommand') + }) + + it('fails closed when reading the issue command rejects', async () => { + const deps = makeDeps() + deps.readIssueCommand.mockRejectedValueOnce(new Error('runtime offline')) + + const result = await createGitHubWorkItemWorkspaceInBackground( + { + item: makeIssue(), + repoId: 'repo-1', + openModalFallback: vi.fn() + }, + deps + ) + + expect(result).toEqual({ kind: 'background-started' }) + const continueCall = deps.continueBackgroundCreate.mock.calls[0] as unknown[] | undefined + expect(continueCall).toBeDefined() + const request = continueCall?.[1] as WorktreeCreationRequest + expect(request.issueCommand).toBeUndefined() + }) + + it('re-reads the store for each trust check so mid-flow trust updates are honored', async () => { + const snapshots = [makeStore(), makeStore(), makeStore()] + let getStoreCall = 0 + const deps = makeDeps(snapshots[0]) + deps.getStore = vi.fn( + () => snapshots[Math.min(getStoreCall++, snapshots.length - 1)] ?? snapshots[0] + ) + deps.readIssueCommand.mockResolvedValueOnce({ + effectiveContent: 'echo {{issue}}', + localContent: null, + sharedContent: 'echo {{issue}}', + localFilePath: '', + source: 'shared' + }) + + await createGitHubWorkItemWorkspaceInBackground( + { + item: makeIssue(), + repoId: 'repo-1', + openModalFallback: vi.fn() + }, + deps + ) + + // Why: an "Always trust" stamped by the setup prompt only exists in a fresh + // snapshot. Each trust check must read the store at call time instead of + // reusing the snapshot captured when the flow began. + const setupCall = deps.confirmHooks.mock.calls.find((call) => call[2] === 'setup') + const issueCall = deps.confirmHooks.mock.calls.find((call) => call[2] === 'issueCommand') + expect(setupCall?.[0]).toBeDefined() + expect(issueCall?.[0]).toBeDefined() + expect(setupCall?.[0]).not.toBe(snapshots[0]) + expect(issueCall?.[0]).not.toBe(snapshots[0]) + expect(issueCall?.[0]).not.toBe(setupCall?.[0]) + }) + + it('keeps the seeded agent prompt as the bare issue URL (not Complete )', async () => { + const store = makeStore({ + ensureDetectedAgents: vi.fn().mockResolvedValue(['codex']) + }) + const deps = makeDeps(store) + deps.readIssueCommand.mockResolvedValueOnce({ + effectiveContent: 'echo {{issue}}', + localContent: 'echo {{issue}}', + sharedContent: null, + localFilePath: '/repo/.orca/issue-command', + source: 'local' + }) + + await createGitHubWorkItemWorkspaceInBackground( + { + item: makeIssue(), + repoId: 'repo-1', + openModalFallback: vi.fn() + }, + deps + ) + + const continueCall = deps.continueBackgroundCreate.mock.calls[0] as unknown[] | undefined + expect(continueCall).toBeDefined() + const request = continueCall?.[1] as WorktreeCreationRequest + // Why: Brennan's confirmed scope keeps the quick-start prompt the bare link; + // the issue command runs as a side-pane split, not as the agent prompt. + expect(request.startupPlan?.draftPrompt).toBe('https://github.com/stablyai/orca/issues/42') + expect(request.startupPlan?.draftPrompt ?? '').not.toContain('Complete') + expect(request.quickPrompt).not.toContain('Complete') + // The issue command itself still threads through as a side-pane split. + expect(request.issueCommand?.command).toBe('echo 42') + }) }) diff --git a/src/renderer/src/lib/github-work-item-background-create.ts b/src/renderer/src/lib/github-work-item-background-create.ts index edaaa92bb92..260cd2ad657 100644 --- a/src/renderer/src/lib/github-work-item-background-create.ts +++ b/src/renderer/src/lib/github-work-item-background-create.ts @@ -19,7 +19,10 @@ import { } from '@/lib/launch-work-item-direct-preflight' import { agentLaunchCommandErrorMessage } from '@/lib/launch-work-item-direct-messages' import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' +import { renderIssueCommandTemplate } from '@/lib/new-workspace' import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' +import { readRuntimeIssueCommand } from '@/runtime/runtime-hooks-client' +import { isGitRepoKind } from '../../../shared/repo-kind' import { getRepoExecutionHostId, parseExecutionHostId } from '../../../shared/execution-host' import { evaluateRuntimeCompat } from '../../../shared/protocol-compat' import { @@ -29,6 +32,7 @@ import { import type { GitHubWorkItem, SetupDecision } from '../../../shared/types' import type { Repo } from '../../../shared/types' import type { TaskSourceContext, WorkspaceRunContext } from '../../../shared/task-source-context' +import { resolveGitHubWorkItemIdentity } from '@/lib/github-work-item-identity' export type BackgroundGitHubWorkItemCreateResult = | { kind: 'background-started' } @@ -50,8 +54,9 @@ type BackgroundGitHubWorkItemCreateDeps = { confirmHooks: ( store: GitHubWorkItemBackgroundStoreSnapshot, repoId: string, - scope: 'setup' + scope: 'setup' | 'issueCommand' ) => ReturnType + readIssueCommand: typeof readRuntimeIssueCommand beginBackgroundCreate: typeof beginBackgroundWorktreePreparation continueBackgroundCreate: typeof continueBackgroundWorktreeCreation activatePendingCreate: (creationId: string) => void @@ -78,8 +83,9 @@ const DEFAULT_DEPS: BackgroundGitHubWorkItemCreateDeps = { useAppStore.getState().activePendingCreationId === creationId, resolveSetupDecision: resolveDirectSetupDecision, resolvePrStartPoint: resolveDirectPrStartPoint, - confirmHooks: (store, repoId, scope) => + confirmHooks: (store, repoId, scope: 'setup' | 'issueCommand') => ensureHooksConfirmed(store as ReturnType, repoId, scope), + readIssueCommand: readRuntimeIssueCommand, beginBackgroundCreate: beginBackgroundWorktreePreparation, continueBackgroundCreate: continueBackgroundWorktreeCreation, activatePendingCreate: (creationId) => { @@ -177,6 +183,7 @@ export async function createGitHubWorkItemWorkspaceInBackground( const restoreView = deps.getActiveView() const creationId = deps.beginBackgroundCreate(initialRequest) + const itemIdentity = resolveGitHubWorkItemIdentity(args.item) try { const repoOwnerSettings = getSettingsForRepoRuntimeOwner(store, args.repoId) @@ -196,11 +203,11 @@ export async function createGitHubWorkItemWorkspaceInBackground( let pushTarget: WorktreeCreationRequest['pushTarget'] let branchNameOverride: string | undefined let compareBaseRef: string | undefined - if (args.item.type === 'pr' && args.item.number) { + if (itemIdentity.type === 'pr' && itemIdentity.number) { try { const result = await deps.resolvePrStartPoint( args.repoId, - args.item.number, + itemIdentity.number, repoOwnerSettings, args.item ) @@ -222,7 +229,10 @@ export async function createGitHubWorkItemWorkspaceInBackground( } } - const trustDecision = await deps.confirmHooks(store, args.repoId, 'setup') + // Why: trust prompts are serialized app-wide, so read the store fresh at + // each check — an "Always trust" stamped by an earlier prompt (including + // this flow's own setup prompt) must short-circuit instead of re-prompting. + const trustDecision = await deps.confirmHooks(deps.getStore(), args.repoId, 'setup') if (!deps.hasPendingCreate(creationId)) { return { kind: 'background-started' } } @@ -246,6 +256,49 @@ export async function createGitHubWorkItemWorkspaceInBackground( } const backendStartup = buildGitHubWorkItemBackendStartup(agent, startupPlan, quickTelemetry) + // Why: mirror the composer's trust-gated issue-command split. Only GitHub + // issues (numeric issue number) on git repos run it; PRs/Linear/folders never + // do. Reuse the setup trust decision: a 'skip' there also skips the command. + let issueCommand: WorktreeCreationRequest['issueCommand'] + // Why: a declined setup trust also skips the issue command, so short-circuit + // before the (up-to-15s) read rather than reading just to drop the result. + if ( + trustDecision !== 'skip' && + isGitRepoKind(repo) && + args.item.type === 'issue' && + typeof args.item.number === 'number' + ) { + // Why: read failures fail closed (no command), so create still proceeds. + let effectiveContent = '' + try { + const issueCommandRead = await deps.readIssueCommand(repoOwnerSettings, args.repoId) + effectiveContent = (issueCommandRead.effectiveContent ?? '').trim() + } catch { + effectiveContent = '' + } + if (!deps.hasPendingCreate(creationId)) { + return { kind: 'background-started' } + } + if (effectiveContent.length > 0) { + const issueCommandTrust = await deps.confirmHooks( + deps.getStore(), + args.repoId, + 'issueCommand' + ) + if (!deps.hasPendingCreate(creationId)) { + return { kind: 'background-started' } + } + if (issueCommandTrust === 'run') { + issueCommand = { + command: renderIssueCommandTemplate(effectiveContent, { + issueNumber: args.item.number, + artifactUrl: args.item.url ?? null + }) + } + } + } + } + const request: WorktreeCreationRequest = { ...initialRequest, ...(baseBranch ? { baseBranch } : {}), @@ -255,6 +308,7 @@ export async function createGitHubWorkItemWorkspaceInBackground( agent, ...(branchNameOverride ? { branchNameOverride } : {}), ...(backendStartup ? { startup: backendStartup } : {}), + ...(issueCommand ? { issueCommand } : {}), startupPlan, quickPrompt, quickTelemetry diff --git a/src/renderer/src/lib/github-work-item-background-request.ts b/src/renderer/src/lib/github-work-item-background-request.ts index f37c71ded0a..5b157525053 100644 --- a/src/renderer/src/lib/github-work-item-background-request.ts +++ b/src/renderer/src/lib/github-work-item-background-request.ts @@ -14,6 +14,7 @@ import { CLIENT_PLATFORM, getWorkspaceIntentName, getWorkspaceSeedName } from '@ import { getLocalRepoProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform' import { repoIsRemote } from '../../../shared/agent-launch-remote' +import { resolveGitHubWorkItemIdentity } from '@/lib/github-work-item-identity' import { resolveTuiAgentLaunchArgs, resolveTuiAgentLaunchEnv @@ -232,19 +233,20 @@ export function buildGitHubWorkItemStartupPlan(args: { } function getGitHubWorkItemName(item: GitHubWorkItem): { seedName: string; displayName?: string } { + const identity = resolveGitHubWorkItemIdentity(item) const intent = - item.number !== null + identity.number !== null ? getWorkspaceIntentName({ sourceText: item.title, - workItem: { type: item.type, number: item.number, title: item.title } + workItem: { type: identity.type, number: identity.number, title: item.title } }) : null return { seedName: getWorkspaceSeedName({ explicitName: intent?.seedName ?? '', prompt: '', - linkedIssueNumber: item.type === 'issue' ? item.number : null, - linkedPR: item.type === 'pr' ? item.number : null + linkedIssueNumber: identity.type === 'issue' ? identity.number : null, + linkedPR: identity.type === 'pr' ? identity.number : null }), ...(intent?.displayName ? { displayName: intent.displayName } : {}) } @@ -257,6 +259,7 @@ export function buildInitialGitHubWorkItemRequest( const { seedName, displayName } = getGitHubWorkItemName(args.item) const workspaceRunContext = getWorkspaceRunContextForRepo(repo, args.workspaceRunContext) const ownerHost = parseExecutionHostId(getRepoExecutionHostId(repo)) + const identity = resolveGitHubWorkItemIdentity(args.item) return { repoId: args.repoId, worktreeCreateProgressMode: ownerHost?.kind === 'local' ? 'stepped' : 'indeterminate', @@ -264,8 +267,8 @@ export function buildInitialGitHubWorkItemRequest( ...(workspaceRunContext ? { workspaceRunContext } : {}), name: seedName, ...(displayName ? { displayName } : {}), - ...(args.item.type === 'issue' && args.item.number ? { linkedIssue: args.item.number } : {}), - ...(args.item.type === 'pr' && args.item.number ? { linkedPR: args.item.number } : {}), + ...(identity.type === 'issue' && identity.number ? { linkedIssue: identity.number } : {}), + ...(identity.type === 'pr' && identity.number ? { linkedPR: identity.number } : {}), ...(args.telemetrySource ? { telemetrySource: args.telemetrySource } : {}), setupDecision: 'inherit', agent: null, diff --git a/src/renderer/src/lib/github-work-item-identity.ts b/src/renderer/src/lib/github-work-item-identity.ts new file mode 100644 index 00000000000..c9b24e316ff --- /dev/null +++ b/src/renderer/src/lib/github-work-item-identity.ts @@ -0,0 +1,20 @@ +import { parseGitHubIssueOrPRLink } from '@/lib/github-links' + +export type GitHubWorkItemIdentity = { + type: 'issue' | 'pr' + number: number +} + +export function resolveGitHubWorkItemIdentity(item: { + type: 'issue' | 'pr' + number: number + url?: string | null +}): GitHubWorkItemIdentity { + const link = item.url ? parseGitHubIssueOrPRLink(item.url) : null + if (link) { + // Why: stale cached work-item payloads can disagree with a pasted URL. The + // URL path is the user-visible intent, so it decides issue-vs-PR launches. + return { type: link.type, number: link.number } + } + return { type: item.type, number: item.number } +} diff --git a/src/renderer/src/lib/gitlab-links.test.ts b/src/renderer/src/lib/gitlab-links.test.ts index 6ce333ad516..8fd85c5d76f 100644 --- a/src/renderer/src/lib/gitlab-links.test.ts +++ b/src/renderer/src/lib/gitlab-links.test.ts @@ -24,6 +24,16 @@ describe('parseGitLabIssueOrMRNumber', () => { expect(parseGitLabIssueOrMRNumber('https://gitlab.example.com/team/api/-/issues/7')).toBe(7) }) + it('parses modern /-/work_items/ issue URLs', () => { + expect(parseGitLabIssueOrMRNumber('https://gitlab.com/stablyai/orca/-/work_items/923')).toBe( + 923 + ) + expect( + parseGitLabIssueOrMRNumber('https://gitlab.example.com:8443/team/api/-/work_items/7') + ).toBe(7) + expect(parseGitLabIssueOrMRNumber('https://gitlab.com/g/p/-/work_items/923/designs')).toBe(923) + }) + it('parses URLs with nested group paths', () => { expect( parseGitLabIssueOrMRNumber('https://gitlab.com/group/subgroup/project/-/merge_requests/55') @@ -90,6 +100,21 @@ describe('parseGitLabIssueOrMRLink', () => { }) }) + it('treats /-/work_items/ as an issue work item', () => { + expect(parseGitLabIssueOrMRLink('https://gitlab.com/stablyai/orca/-/work_items/923')).toEqual({ + slug: { host: 'gitlab.com', path: 'stablyai/orca' }, + number: 923, + type: 'issue' + }) + expect( + parseGitLabIssueOrMRLink('https://gitlab.example.com:8443/team/api/-/work_items/7') + ).toEqual({ + slug: { host: 'gitlab.example.com:8443', path: 'team/api' }, + number: 7, + type: 'issue' + }) + }) + it('extracts slug, number, and type from URLs with trailing page segments', () => { expect(parseGitLabIssueOrMRLink('https://gitlab.com/g/p/-/merge_requests/77/diffs')).toEqual({ slug: { host: 'gitlab.com', path: 'g/p' }, diff --git a/src/renderer/src/lib/gitlab-links.ts b/src/renderer/src/lib/gitlab-links.ts index 217a56ef28a..1bd36c13712 100644 --- a/src/renderer/src/lib/gitlab-links.ts +++ b/src/renderer/src/lib/gitlab-links.ts @@ -4,10 +4,12 @@ import { isWorkItemLinkQueryTooLarge } from './work-item-link-query-bounds' // be self-hosted (gitlab.example.com), so the URL pattern uses the // project-internal `/-/` separator as the GitLab-specific signal rather // than locking to gitlab.com. Anything matching `//-/(issues| -// merge_requests)/` is treated as a GitLab item URL regardless -// of host. -const GL_ITEM_PATH_RE = /\/(?:issues|merge_requests)\/(\d+)(?:\/.*)?$/i -const GL_ITEM_PATH_FULL_RE = /^\/(.+)\/-\/(issues|merge_requests)\/(\d+)(?:\/.*)?$/i +// work_items|merge_requests)/` is treated as a GitLab item URL +// regardless of host. Modern GitLab emits issue URLs as +// `/-/work_items/`; treat that as an issue work item, same as the +// legacy `/-/issues/` form. +const GL_ITEM_PATH_RE = /\/(?:issues|work_items|merge_requests)\/(\d+)(?:\/.*)?$/i +const GL_ITEM_PATH_FULL_RE = /^\/(.+)\/-\/(issues|work_items|merge_requests)\/(\d+)(?:\/.*)?$/i export type ProjectSlug = { /** GitLab hostname, preserving self-hosted instances from pasted URLs. */ diff --git a/src/renderer/src/lib/hook-command-delayed-delivery.test.ts b/src/renderer/src/lib/hook-command-delayed-delivery.test.ts new file mode 100644 index 00000000000..63fafaaae55 --- /dev/null +++ b/src/renderer/src/lib/hook-command-delayed-delivery.test.ts @@ -0,0 +1,77 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { + queueHookCommandsForFirstWorktreeTab, + resetHookCommandDelayedDeliveryForTests +} from './hook-command-delayed-delivery' + +type AppState = ReturnType + +const initialTabsByWorktree = useAppStore.getState().tabsByWorktree +const initialGetKnownWorktreeById = useAppStore.getState().getKnownWorktreeById + +function setStorePartial(partial: Record): void { + useAppStore.setState(partial as Partial) +} + +function markWorktreeKnown(worktreeId: string): void { + setStorePartial({ + getKnownWorktreeById: ((id: string) => + id === worktreeId ? { id } : undefined) as unknown as AppState['getKnownWorktreeById'] + }) +} + +afterEach(() => { + resetHookCommandDelayedDeliveryForTests() + setStorePartial({ + tabsByWorktree: initialTabsByWorktree, + getKnownWorktreeById: initialGetKnownWorktreeById + }) +}) + +describe('queueHookCommandsForFirstWorktreeTab', () => { + it('holds the delivery until the first worktree tab lands, then delivers exactly once', () => { + markWorktreeKnown('wt-1') + setStorePartial({ tabsByWorktree: {} }) + const deliver = vi.fn() + + queueHookCommandsForFirstWorktreeTab({ worktreeId: 'wt-1', deliver }) + expect(deliver).not.toHaveBeenCalled() + + setStorePartial({ tabsByWorktree: { 'wt-1': [{ id: 'mirror-tab-1' }] } }) + + expect(deliver).toHaveBeenCalledTimes(1) + expect(deliver).toHaveBeenCalledWith(expect.anything(), 'mirror-tab-1') + + // Later tab churn must not re-deliver the consumed entry. + setStorePartial({ tabsByWorktree: { 'wt-1': [{ id: 'mirror-tab-1' }, { id: 'tab-2' }] } }) + expect(deliver).toHaveBeenCalledTimes(1) + }) + + it('delivers immediately when the worktree already has a tab at queue time', () => { + markWorktreeKnown('wt-1') + setStorePartial({ tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] } }) + const deliver = vi.fn() + + queueHookCommandsForFirstWorktreeTab({ worktreeId: 'wt-1', deliver }) + + expect(deliver).toHaveBeenCalledTimes(1) + expect(deliver).toHaveBeenCalledWith(expect.anything(), 'tab-1') + }) + + it('drops the pending delivery when the worktree is no longer known', () => { + setStorePartial({ + tabsByWorktree: {}, + getKnownWorktreeById: (() => undefined) as unknown as AppState['getKnownWorktreeById'] + }) + const deliver = vi.fn() + + queueHookCommandsForFirstWorktreeTab({ worktreeId: 'wt-gone', deliver }) + + // A tab appearing later (e.g. an id reused by mirror churn) must not + // deliver commands for a worktree that was dropped while unknown. + markWorktreeKnown('wt-gone') + setStorePartial({ tabsByWorktree: { 'wt-gone': [{ id: 'tab-1' }] } }) + expect(deliver).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/hook-command-delayed-delivery.ts b/src/renderer/src/lib/hook-command-delayed-delivery.ts new file mode 100644 index 00000000000..234126f73ad --- /dev/null +++ b/src/renderer/src/lib/hook-command-delayed-delivery.ts @@ -0,0 +1,67 @@ +import { useAppStore } from '@/store' + +type AppStoreSnapshot = ReturnType + +type PendingWorktreeHookCommandDelivery = { + worktreeId: string + deliver: (state: AppStoreSnapshot, firstTerminalTabId: string) => void +} + +// Why: runtime-owned worktrees mirror their session tabs asynchronously, so a +// fresh create usually has no tab to queue setup/issue commands on yet. Hold +// the delivery until the first mirrored terminal tab lands instead of +// dropping it. Mirrors agent-startup-delayed-delivery's lazy-subscription +// shape: subscribed only while something is pending. +const pendingHookCommandDeliveries = new Map() +let unsubscribePendingHookCommandDeliveries: (() => void) | null = null + +export function queueHookCommandsForFirstWorktreeTab( + delivery: PendingWorktreeHookCommandDelivery +): void { + pendingHookCommandDeliveries.set(delivery.worktreeId, delivery) + ensurePendingHookCommandSubscription() + flushPendingHookCommandDeliveries() +} + +export function resetHookCommandDelayedDeliveryForTests(): void { + pendingHookCommandDeliveries.clear() + unsubscribePendingHookCommandDeliveries?.() + unsubscribePendingHookCommandDeliveries = null +} + +function ensurePendingHookCommandSubscription(): void { + if (unsubscribePendingHookCommandDeliveries) { + return + } + unsubscribePendingHookCommandDeliveries = useAppStore.subscribe(() => { + flushPendingHookCommandDeliveries() + }) +} + +function stopPendingHookCommandSubscriptionIfIdle(): void { + if (pendingHookCommandDeliveries.size > 0 || !unsubscribePendingHookCommandDeliveries) { + return + } + unsubscribePendingHookCommandDeliveries() + unsubscribePendingHookCommandDeliveries = null +} + +function flushPendingHookCommandDeliveries(): void { + const state = useAppStore.getState() + for (const [worktreeId, delivery] of pendingHookCommandDeliveries) { + const firstTerminalTabId = state.tabsByWorktree[worktreeId]?.[0]?.id + if (!firstTerminalTabId) { + // Why: a worktree can be removed before its tabs ever mirror; drop the + // entry so the subscription does not stay armed forever. + if (!state.getKnownWorktreeById(worktreeId)) { + pendingHookCommandDeliveries.delete(worktreeId) + } + continue + } + // Delete before delivering so store writes inside deliver cannot re-enter + // this entry through the subscription. + pendingHookCommandDeliveries.delete(worktreeId) + delivery.deliver(state, firstTerminalTabId) + } + stopPendingHookCommandSubscriptionIfIdle() +} diff --git a/src/renderer/src/lib/ime-composition-keyboard-event.test.ts b/src/renderer/src/lib/ime-composition-keyboard-event.test.ts new file mode 100644 index 00000000000..daa02c5411e --- /dev/null +++ b/src/renderer/src/lib/ime-composition-keyboard-event.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import type { KeyboardEvent as ReactKeyboardEvent } from 'react' +import { isImeCompositionKeyDown } from './ime-composition-keyboard-event' + +function keyEvent(nativeEvent: { isComposing?: boolean; keyCode?: number }): ReactKeyboardEvent { + return { + nativeEvent: { + isComposing: nativeEvent.isComposing ?? false, + keyCode: nativeEvent.keyCode ?? 13 + } + } as unknown as ReactKeyboardEvent +} + +describe('isImeCompositionKeyDown', () => { + it('is true while the IME is composing', () => { + expect(isImeCompositionKeyDown(keyEvent({ isComposing: true }))).toBe(true) + }) + + it('is true for the keyCode 229 fallback when isComposing is not set', () => { + expect(isImeCompositionKeyDown(keyEvent({ isComposing: false, keyCode: 229 }))).toBe(true) + }) + + it('is false for a plain Enter outside of composition', () => { + expect(isImeCompositionKeyDown(keyEvent({ isComposing: false, keyCode: 13 }))).toBe(false) + }) +}) diff --git a/src/renderer/src/lib/ime-composition-keyboard-event.ts b/src/renderer/src/lib/ime-composition-keyboard-event.ts new file mode 100644 index 00000000000..c5faed78d16 --- /dev/null +++ b/src/renderer/src/lib/ime-composition-keyboard-event.ts @@ -0,0 +1,13 @@ +import type { KeyboardEvent as ReactKeyboardEvent } from 'react' + +/** + * Why: CJK IMEs (Japanese/Chinese/Korean) fire a keydown for the Enter that + * only confirms a conversion candidate. Rename/title inputs that commit on + * `Enter` must ignore that keydown, otherwise they submit mid-composition with a + * half-converted value. `isComposing` covers most browsers; `keyCode === 229` is + * a defensive fallback for IMEs that don't set `isComposing` on keydown. + */ +export function isImeCompositionKeyDown(event: ReactKeyboardEvent): boolean { + const nativeEvent = event.nativeEvent + return nativeEvent.isComposing || nativeEvent.keyCode === 229 +} diff --git a/src/renderer/src/lib/launch-work-item-direct.test.ts b/src/renderer/src/lib/launch-work-item-direct.test.ts index e11e7414d8d..77d8b44e062 100644 --- a/src/renderer/src/lib/launch-work-item-direct.test.ts +++ b/src/renderer/src/lib/launch-work-item-direct.test.ts @@ -235,9 +235,9 @@ describe('launchWorkItemDirect', () => { openModalFallback: vi.fn(), item: { type: 'pr', - number: 42, + number: 6934, title: 'Fix the bug', - url: 'https://github.com/acme/repo/pull/42', + url: 'https://github.com/stablyai/orca/pull/6934', branchName: 'feature/fix', baseRefName: 'main', isCrossRepository: true @@ -246,21 +246,21 @@ describe('launchWorkItemDirect', () => { expect(mocks.resolvePrBase).toHaveBeenCalledWith({ repoId: 'repo-1', - prNumber: 42, + prNumber: 6934, headRefName: 'feature/fix', baseRefName: 'main', isCrossRepository: true }) expect(mocks.createWorktree).toHaveBeenCalledWith( 'repo-1', - 'review-pr-42', + 'review-pr-6934', 'abc123', 'inherit', undefined, 'sidebar', - 'Review PR 42', + 'Review PR 6934', undefined, - 42, + 6934, { remoteName: 'origin', branchName: 'feature/fix' }, undefined, undefined, @@ -280,6 +280,41 @@ describe('launchWorkItemDirect', () => { ) }) + it('treats a PR-typed GitHub issue URL as an issue without resolving a PR head', async () => { + const { launchWorkItemDirect } = await import('./launch-work-item-direct') + const openModalFallback = vi.fn() + + await expect( + launchWorkItemDirect({ + repoId: 'repo-1', + launchSource: 'task_page', + telemetrySource: 'sidebar', + openModalFallback, + item: { + type: 'pr', + number: 6933, + title: 'The board columns are displayed backwards', + url: 'https://github.com/stablyai/orca/issues/6933', + branchName: 'fix-issue-6933', + baseRefName: 'main', + isCrossRepository: true + } + }) + ).resolves.toBe(true) + + expect(mocks.resolvePrBase).not.toHaveBeenCalled() + expect(openModalFallback).not.toHaveBeenCalled() + const createArgs = mocks.createWorktree.mock.calls[0] + expect(createArgs?.[1]).toBe('issue-6933') + expect(createArgs?.[2]).toBeUndefined() + expect(createArgs?.[6]).toBe('Issue 6933') + expect(createArgs?.[7]).toBe(6933) + expect(createArgs?.[8]).toBeUndefined() + expect(createArgs?.[9]).toBeUndefined() + expect(createArgs?.[12]).toBeUndefined() + expect(createArgs?.[24]).toBeUndefined() + }) + it('uses the Linear identifier in direct-launch workspace names', async () => { const { launchWorkItemDirect } = await import('./launch-work-item-direct') diff --git a/src/renderer/src/lib/launch-work-item-direct.ts b/src/renderer/src/lib/launch-work-item-direct.ts index def1c5c6c8a..61f49274edb 100644 --- a/src/renderer/src/lib/launch-work-item-direct.ts +++ b/src/renderer/src/lib/launch-work-item-direct.ts @@ -16,6 +16,7 @@ import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' import { getConnectionId } from '@/lib/connection-context' import type { GitPushTarget, SetupDecision, TuiAgent } from '../../../shared/types' import { getLinearIssueWorkspaceName } from '../../../shared/workspace-name' +import { resolveGitHubWorkItemIdentity } from '@/lib/github-work-item-identity' import { buildDirectWorkItemAgentStartupPlan, buildDirectWorkItemStartupOpts, @@ -70,6 +71,16 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom const repoOwnerSettings = getSettingsForRepoRuntimeOwner(store, repoId) const promptDelivery = args.promptDelivery ?? 'draft' const repoConnectionId = repo.connectionId?.trim() || null + const githubIdentity = + item.number !== null && (item.type === 'issue' || item.type === 'pr') + ? resolveGitHubWorkItemIdentity({ + type: item.type, + number: item.number, + url: item.url + }) + : null + const itemType = githubIdentity?.type ?? item.type + const itemNumber = githubIdentity?.number ?? item.number const repoProjectRuntime = repoConnectionId ? undefined : getLocalRepoProjectExecutionRuntimeContext(store, repoId, CLIENT_PLATFORM) @@ -107,10 +118,10 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom trustDecision === 'skip' ? 'skip' : setupResolution.decision const workspaceIntentName = - item.number !== null + itemNumber !== null ? getWorkspaceIntentName({ sourceText: item.pasteContent, - workItem: { ...item, number: item.number } + workItem: { ...item, type: itemType, number: itemNumber } }) : null const workspaceName = getWorkspaceSeedName({ @@ -118,18 +129,18 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom ? getLinearIssueWorkspaceName({ identifier: item.linearIdentifier, title: item.title }) : (workspaceIntentName?.seedName ?? ''), prompt: '', - linkedIssueNumber: item.type === 'issue' ? (item.number ?? null) : null, - linkedPR: item.type === 'pr' ? (item.number ?? null) : null + linkedIssueNumber: itemType === 'issue' ? (itemNumber ?? null) : null, + linkedPR: itemType === 'pr' ? (itemNumber ?? null) : null }) let resolvedBaseBranch = baseBranch let resolvedPushTarget: GitPushTarget | undefined let resolvedBranchNameOverride: string | undefined let resolvedCompareBaseRef: string | undefined - if (!resolvedBaseBranch && item.type === 'pr' && item.number) { + if (!resolvedBaseBranch && itemType === 'pr' && itemNumber) { try { // Why: direct "Use PR" launches bypass the Start-from picker, so they // must still resolve the PR head before `git worktree add`. - const result = await resolveDirectPrStartPoint(repoId, item.number, repoOwnerSettings, item) + const result = await resolveDirectPrStartPoint(repoId, itemNumber, repoOwnerSettings, item) resolvedBaseBranch = result.baseBranch resolvedPushTarget = result.pushTarget resolvedBranchNameOverride = result.branchNameOverride @@ -157,15 +168,15 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom undefined, telemetrySource, workspaceIntentName?.displayName ?? item.title, - item.type === 'issue' && item.number ? item.number : undefined, - item.type === 'pr' && item.number ? item.number : undefined, + itemType === 'issue' && itemNumber ? itemNumber : undefined, + itemType === 'pr' && itemNumber ? itemNumber : undefined, resolvedPushTarget, undefined, item.linearIdentifier, resolvedBranchNameOverride, undefined, - item.type === 'mr' && item.number ? item.number : undefined, - gitLabIssueNumber(item), + itemType === 'mr' && itemNumber ? itemNumber : undefined, + gitLabIssueNumber({ ...item, type: itemType, number: itemNumber }), undefined, undefined, undefined, diff --git a/src/renderer/src/lib/linked-work-item-provider.ts b/src/renderer/src/lib/linked-work-item-provider.ts index 381a148931d..7ce731622ff 100644 --- a/src/renderer/src/lib/linked-work-item-provider.ts +++ b/src/renderer/src/lib/linked-work-item-provider.ts @@ -1,11 +1,15 @@ import type { LinkedWorkItemSummary } from './new-workspace' +// Why: self-hosted GitLab issue URLs may not contain "gitlab", and modern +// GitLab emits issue URLs as `/-/work_items/` as well as the legacy +// `/-/issues/`. Recognize both forms. +const GL_ISSUE_PATH_RE = /\/-\/(?:issues|work_items)\//i + export function isGitLabIssueUrl(url: string): boolean { - // Why: self-hosted GitLab issue URLs may not contain "gitlab". try { - return new URL(url).pathname.includes('/-/issues/') + return GL_ISSUE_PATH_RE.test(new URL(url).pathname) } catch { - return /\/-\/issues\//i.test(url) + return GL_ISSUE_PATH_RE.test(url) } } diff --git a/src/renderer/src/lib/new-workspace-duplicate-project-details.ts b/src/renderer/src/lib/new-workspace-duplicate-project-details.ts new file mode 100644 index 00000000000..127b8b99812 --- /dev/null +++ b/src/renderer/src/lib/new-workspace-duplicate-project-details.ts @@ -0,0 +1,147 @@ +import { getExecutionHostLabel, type ExecutionHostId } from '../../../shared/execution-host' +import type { NewWorkspaceProjectOption } from './new-workspace-project-options' + +export type ProjectSetupDirectory = { + path: string + hostId: ExecutionHostId +} + +export type ProjectOptionDraft = Extract & { + detailSource: 'provider' | 'generic' +} + +type ProjectDirectoryDetailMode = 'path' | 'host-label' | 'host-id' + +function getRepeatedValues(values: readonly string[]): Set { + const seen = new Set() + const repeated = new Set() + for (const value of values) { + if (seen.has(value)) { + repeated.add(value) + continue + } + seen.add(value) + } + return repeated +} + +function getProjectDirectoryDetail( + directories: readonly ProjectSetupDirectory[], + hostLabelById: ReadonlyMap, + mode: ProjectDirectoryDetailMode +): string | null { + const detailByKey = new Map() + for (const directory of directories) { + const path = directory.path.trim() + if (!path) { + continue + } + const hostLabel = + hostLabelById.get(directory.hostId)?.trim() || getExecutionHostLabel(directory.hostId) + const detail = + mode === 'path' + ? path + : mode === 'host-id' + ? `${hostLabel} (${directory.hostId}) · ${path}` + : `${hostLabel} · ${path}` + const key = mode === 'path' ? path : `${directory.hostId}\0${path}` + detailByKey.set(key, detail) + } + + const distinctDetails = [...detailByKey.values()].sort() + if (distinctDetails.length === 0) { + return null + } + + const [firstDetail] = distinctDetails + return distinctDetails.length === 1 + ? firstDetail + : `${firstDetail} (+${distinctDetails.length - 1} more)` +} + +export function getDuplicateProjectDetailsById( + options: readonly ProjectOptionDraft[], + setupDirectoriesByProjectId: ReadonlyMap, + hostLabelById: ReadonlyMap +): Map { + const optionsByName = new Map() + for (const option of options) { + optionsByName.set(option.displayName, [ + ...(optionsByName.get(option.displayName) ?? []), + option + ]) + } + + const detailsById = new Map() + for (const sameNameOptions of optionsByName.values()) { + if (sameNameOptions.length < 2) { + continue + } + + const repeatedProviderDetails = getRepeatedValues( + sameNameOptions + .filter((option) => option.detailSource === 'provider') + .map((option) => option.detail) + ) + const ambiguousOptions = sameNameOptions.filter( + (option) => option.detailSource === 'generic' || repeatedProviderDetails.has(option.detail) + ) + if (ambiguousOptions.length === 0) { + continue + } + + const pathOnlyDetailsById = new Map() + for (const option of ambiguousOptions) { + const detail = getProjectDirectoryDetail( + setupDirectoriesByProjectId.get(option.projectId) ?? [], + hostLabelById, + 'path' + ) + if (detail) { + pathOnlyDetailsById.set(option.id, detail) + } + } + + const repeatedPathOnlyDetails = getRepeatedValues([...pathOnlyDetailsById.values()]) + const hostLabelDetailsById = new Map() + for (const option of ambiguousOptions) { + const pathOnlyDetail = pathOnlyDetailsById.get(option.id) + if (!pathOnlyDetail || !repeatedPathOnlyDetails.has(pathOnlyDetail)) { + continue + } + const detail = getProjectDirectoryDetail( + setupDirectoriesByProjectId.get(option.projectId) ?? [], + hostLabelById, + 'host-label' + ) + if (detail) { + hostLabelDetailsById.set(option.id, detail) + } + } + + const repeatedHostLabelDetails = getRepeatedValues([...hostLabelDetailsById.values()]) + for (const option of ambiguousOptions) { + const pathOnlyDetail = pathOnlyDetailsById.get(option.id) + if (!pathOnlyDetail) { + continue + } + let detail = pathOnlyDetail + if (repeatedPathOnlyDetails.has(pathOnlyDetail)) { + detail = hostLabelDetailsById.get(option.id) ?? pathOnlyDetail + if (repeatedHostLabelDetails.has(detail)) { + // Why: user-renamed hosts can share a label, so fall back to the + // stable host id only when the final visible detail still repeats. + detail = + getProjectDirectoryDetail( + setupDirectoriesByProjectId.get(option.projectId) ?? [], + hostLabelById, + 'host-id' + ) ?? detail + } + } + detailsById.set(option.id, detail) + } + } + + return detailsById +} diff --git a/src/renderer/src/lib/new-workspace-project-options.test.ts b/src/renderer/src/lib/new-workspace-project-options.test.ts index d96cc14ba4a..67c25ab2598 100644 --- a/src/renderer/src/lib/new-workspace-project-options.test.ts +++ b/src/renderer/src/lib/new-workspace-project-options.test.ts @@ -108,6 +108,257 @@ describe('buildNewWorkspaceProjectOptions', () => { expect(options.map((option) => option.id)).toEqual(['github:stablyai/orca']) }) + it('shows configured directories when project names are duplicated', () => { + const options = buildNewWorkspaceProjectOptions({ + projects: [ + project({ + id: 'project:merchant-a', + displayName: 'merchant', + providerIdentity: undefined + }), + project({ + id: 'project:merchant-b', + displayName: 'merchant', + providerIdentity: undefined + }) + ], + projectHostSetups: [ + setup({ + id: 'merchant-a-setup', + projectId: 'project:merchant-a', + repoId: 'merchant-a-repo', + path: '/workspace/storefront/merchant' + }), + setup({ + id: 'merchant-b-setup', + projectId: 'project:merchant-b', + repoId: 'merchant-b-repo', + path: '/workspace/admin/merchant' + }) + ], + eligibleRepos: [repo('merchant-a-repo'), repo('merchant-b-repo')] + }) + + expect(options).toEqual([ + expect.objectContaining({ + id: 'project:merchant-b', + detail: '/workspace/admin/merchant' + }), + expect.objectContaining({ + id: 'project:merchant-a', + detail: '/workspace/storefront/merchant' + }) + ]) + }) + + it('keeps provider details when duplicate project names are already distinguishable', () => { + const options = buildNewWorkspaceProjectOptions({ + projects: [ + project({ + id: 'github:acme/merchant', + displayName: 'merchant', + providerIdentity: { provider: 'github', owner: 'acme', repo: 'merchant' } + }), + project({ + id: 'github:contoso/merchant', + displayName: 'merchant', + providerIdentity: { provider: 'github', owner: 'contoso', repo: 'merchant' } + }) + ], + projectHostSetups: [ + setup({ + id: 'acme-setup', + projectId: 'github:acme/merchant', + repoId: 'acme-repo', + path: '/workspace/acme/merchant' + }), + setup({ + id: 'contoso-setup', + projectId: 'github:contoso/merchant', + repoId: 'contoso-repo', + path: '/workspace/contoso/merchant' + }) + ], + eligibleRepos: [repo('acme-repo'), repo('contoso-repo')] + }) + + expect(options.map((option) => option.detail).sort()).toEqual([ + 'acme/merchant', + 'contoso/merchant' + ]) + }) + + it('shows directory details for non-provider duplicates with different setup counts', () => { + const options = buildNewWorkspaceProjectOptions({ + projects: [ + project({ + id: 'project:merchant-single', + displayName: 'merchant', + providerIdentity: undefined + }), + project({ + id: 'project:merchant-multi', + displayName: 'merchant', + providerIdentity: undefined + }) + ], + projectHostSetups: [ + setup({ + id: 'merchant-single-setup', + projectId: 'project:merchant-single', + repoId: 'merchant-single-repo', + path: '/workspace/single/merchant' + }), + setup({ + id: 'merchant-multi-local-setup', + projectId: 'project:merchant-multi', + repoId: 'merchant-multi-local-repo', + path: '/workspace/multi/local/merchant' + }), + setup({ + id: 'merchant-multi-remote-setup', + projectId: 'project:merchant-multi', + hostId: 'ssh:builder', + repoId: 'merchant-multi-remote-repo', + path: '/workspace/multi/remote/merchant' + }) + ], + eligibleRepos: [ + repo('merchant-single-repo'), + repo('merchant-multi-local-repo'), + repo('merchant-multi-remote-repo') + ] + }) + + expect(options.map((option) => option.detail).sort()).toEqual([ + '/workspace/multi/local/merchant (+1 more)', + '/workspace/single/merchant' + ]) + }) + + it('keeps provider details while disambiguating non-provider duplicate names', () => { + const options = buildNewWorkspaceProjectOptions({ + projects: [ + project({ + id: 'github:acme/merchant', + displayName: 'merchant', + providerIdentity: { provider: 'github', owner: 'acme', repo: 'merchant' } + }), + project({ + id: 'project:merchant-local', + displayName: 'merchant', + providerIdentity: undefined + }) + ], + projectHostSetups: [ + setup({ + id: 'acme-setup', + projectId: 'github:acme/merchant', + repoId: 'acme-repo', + path: '/workspace/acme/merchant' + }), + setup({ + id: 'merchant-local-setup', + projectId: 'project:merchant-local', + repoId: 'merchant-local-repo', + path: '/workspace/local/merchant' + }) + ], + eligibleRepos: [repo('acme-repo'), repo('merchant-local-repo')] + }) + + expect(options.map((option) => option.detail).sort()).toEqual([ + '/workspace/local/merchant', + 'acme/merchant' + ]) + }) + + it('adds host labels when duplicate project directory details collide', () => { + const options = buildNewWorkspaceProjectOptions({ + projects: [ + project({ + id: 'project:merchant-local', + displayName: 'merchant', + providerIdentity: undefined + }), + project({ + id: 'project:merchant-remote', + displayName: 'merchant', + providerIdentity: undefined + }) + ], + projectHostSetups: [ + setup({ + id: 'merchant-local-setup', + projectId: 'project:merchant-local', + hostId: 'local', + repoId: 'merchant-local-repo', + path: '/workspace/merchant' + }), + setup({ + id: 'merchant-remote-setup', + projectId: 'project:merchant-remote', + hostId: 'ssh:builder', + repoId: 'merchant-remote-repo', + path: '/workspace/merchant' + }) + ], + eligibleRepos: [repo('merchant-local-repo'), repo('merchant-remote-repo')], + hosts: [ + { id: 'local', label: 'Local Mac' }, + { id: 'ssh:builder', label: 'Builder' } + ] + }) + + expect(options.map((option) => option.detail).sort()).toEqual([ + 'Builder · /workspace/merchant', + 'Local Mac · /workspace/merchant' + ]) + }) + + it('adds host ids when duplicate project host labels still collide', () => { + const options = buildNewWorkspaceProjectOptions({ + projects: [ + project({ + id: 'project:merchant-builder-a', + displayName: 'merchant', + providerIdentity: undefined + }), + project({ + id: 'project:merchant-builder-b', + displayName: 'merchant', + providerIdentity: undefined + }) + ], + projectHostSetups: [ + setup({ + id: 'merchant-builder-a-setup', + projectId: 'project:merchant-builder-a', + hostId: 'ssh:builder-a', + repoId: 'merchant-builder-a-repo', + path: '/workspace/merchant' + }), + setup({ + id: 'merchant-builder-b-setup', + projectId: 'project:merchant-builder-b', + hostId: 'ssh:builder-b', + repoId: 'merchant-builder-b-repo', + path: '/workspace/merchant' + }) + ], + eligibleRepos: [repo('merchant-builder-a-repo'), repo('merchant-builder-b-repo')], + hosts: [ + { id: 'ssh:builder-a', label: 'Builder' }, + { id: 'ssh:builder-b', label: 'Builder' } + ] + }) + + expect(options.map((option) => option.detail).sort()).toEqual([ + 'Builder (ssh:builder-a) · /workspace/merchant', + 'Builder (ssh:builder-b) · /workspace/merchant' + ]) + }) + it('filters project options by display name and detail', () => { const options: NewWorkspaceProjectOption[] = [ { diff --git a/src/renderer/src/lib/new-workspace-project-options.ts b/src/renderer/src/lib/new-workspace-project-options.ts index cabbb4f4cf6..bfa47e2a63b 100644 --- a/src/renderer/src/lib/new-workspace-project-options.ts +++ b/src/renderer/src/lib/new-workspace-project-options.ts @@ -1,6 +1,11 @@ import { projectHostSetupProjectionFromRepos } from '../../../shared/project-host-setup-projection' import type { Project, ProjectGroup, ProjectHostSetup, Repo } from '../../../shared/types' import { isClipboardTextByteLengthOverLimit } from '../../../shared/clipboard-text' +import type { ExecutionHostRegistryEntry } from '../../../shared/execution-host-registry' +import { + getDuplicateProjectDetailsById, + type ProjectSetupDirectory +} from './new-workspace-duplicate-project-details' export const NEW_WORKSPACE_PROJECT_GROUP_OPTION_PREFIX = 'project-group:' export const NEW_WORKSPACE_FOLDER_SOURCE_OPTION_PREFIX = 'folder-source:' @@ -45,6 +50,7 @@ type BuildNewWorkspaceProjectOptionsInput = { projects: readonly Project[] projectHostSetups: readonly ProjectHostSetup[] eligibleRepos: readonly Repo[] + hosts?: readonly Pick[] } type BuildNewWorkspaceCreateTargetOptionsInput = BuildNewWorkspaceProjectOptionsInput & { @@ -85,7 +91,9 @@ export function buildNewWorkspaceProjectOptions( const { eligibleRepos } = input const { projects, projectHostSetups } = getProjectModel(input) const eligibleRepoIds = new Set(eligibleRepos.map((repo) => repo.id)) + const hostLabelById = new Map((input.hosts ?? []).map((host) => [host.id, host.label])) const readySetupCountsByProjectId = new Map() + const setupDirectoriesByProjectId = new Map() for (const setup of projectHostSetups) { if (setup.setupState !== 'ready' || !eligibleRepoIds.has(setup.repoId)) { @@ -95,9 +103,12 @@ export function buildNewWorkspaceProjectOptions( setup.projectId, (readySetupCountsByProjectId.get(setup.projectId) ?? 0) + 1 ) + const directories = setupDirectoriesByProjectId.get(setup.projectId) ?? [] + directories.push({ path: setup.path, hostId: setup.hostId }) + setupDirectoriesByProjectId.set(setup.projectId, directories) } - return projects + const options = projects .filter((project) => (readySetupCountsByProjectId.get(project.id) ?? 0) > 0) .map((project) => ({ kind: 'project' as const, @@ -105,8 +116,21 @@ export function buildNewWorkspaceProjectOptions( projectId: project.id, displayName: project.displayName, badgeColor: project.badgeColor, - detail: getProjectDetail(project, readySetupCountsByProjectId.get(project.id) ?? 0) + detail: getProjectDetail(project, readySetupCountsByProjectId.get(project.id) ?? 0), + detailSource: project.providerIdentity ? ('provider' as const) : ('generic' as const) })) + + const duplicateProjectDetailsById = getDuplicateProjectDetailsById( + options, + setupDirectoriesByProjectId, + hostLabelById + ) + + return options + .map(({ detailSource: _detailSource, ...option }) => { + const directoryDetail = duplicateProjectDetailsById.get(option.id) + return directoryDetail ? { ...option, detail: directoryDetail } : option + }) .sort((a, b) => a.displayName.localeCompare(b.displayName) || a.detail.localeCompare(b.detail)) } diff --git a/src/renderer/src/lib/new-workspace.test.ts b/src/renderer/src/lib/new-workspace.test.ts index d753e0977d0..8306ca0d62c 100644 --- a/src/renderer/src/lib/new-workspace.test.ts +++ b/src/renderer/src/lib/new-workspace.test.ts @@ -215,6 +215,13 @@ describe('isGitLabIssueUrl', () => { expect(isGitLabIssueUrl('https://gitlab.example.com/group/project/-/issues/123')).toBe(true) }) + it('detects modern /-/work_items/ issue URLs (incl. non-default port)', () => { + expect(isGitLabIssueUrl('https://gitlab.com/group/project/-/work_items/123')).toBe(true) + expect(isGitLabIssueUrl('https://gitlab.example.com:8443/group/project/-/work_items/7')).toBe( + true + ) + }) + it('does not classify GitHub issue URLs as GitLab issues', () => { expect(isGitLabIssueUrl('https://github.com/group/project/issues/123')).toBe(false) }) diff --git a/src/renderer/src/lib/notes-send-agent-targets.test.ts b/src/renderer/src/lib/notes-send-agent-targets.test.ts index 018e63b6aff..e7b4178df2b 100644 --- a/src/renderer/src/lib/notes-send-agent-targets.test.ts +++ b/src/renderer/src/lib/notes-send-agent-targets.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import type { AgentStatusEntry, AgentStatusState } from '../../../shared/agent-status-types' +import { + AGENT_STATUS_STALE_AFTER_MS, + type AgentStatusEntry, + type AgentStatusState +} from '../../../shared/agent-status-types' import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/types' import { makePaneKey } from '../../../shared/stable-pane-id' import { @@ -10,9 +14,11 @@ import { const WORKTREE_ID = 'wt-1' const STATUS_TAB_ID = 'tab-status' const LAUNCH_TAB_ID = 'tab-launch' +const MANUAL_TAB_ID = 'tab-manual' const LEAF_A = '11111111-1111-4111-8111-111111111111' const LEAF_B = '22222222-2222-4222-8222-222222222222' const NOW = 10_000 +const OLD_STATUS_UPDATED_AT = NOW - AGENT_STATUS_STALE_AFTER_MS - 1 function tab(id: string, overrides: Partial = {}): TerminalTab { return { @@ -28,15 +34,21 @@ function tab(id: string, overrides: Partial = {}): TerminalTab { } } -function entry(paneKey: string, state: AgentStatusState = 'done'): AgentStatusEntry { +function entry( + paneKey: string, + state: AgentStatusState = 'done', + updatedAt = NOW, + overrides: Partial = {} +): AgentStatusEntry { return { paneKey, state, prompt: '', - updatedAt: NOW, - stateStartedAt: NOW, + updatedAt, + stateStartedAt: updatedAt, agentType: 'codex', - stateHistory: [] + stateHistory: [], + ...overrides } } @@ -190,6 +202,70 @@ describe('notes send agent targets', () => { ]) }) + it('lists a manual agent tab with a recognized idle pane title and live PTY', () => { + const targets = deriveNotesSendAgentTargets( + state({ + tabsByWorktree: { + [WORKTREE_ID]: [tab(MANUAL_TAB_ID, { title: 'Terminal 2' })] + }, + terminalLayoutsByTabId: { [MANUAL_TAB_ID]: leafLayout(LEAF_B, 'pty-b') }, + runtimePaneTitlesByTabId: { [MANUAL_TAB_ID]: { 1: 'Codex ready' } } + }), + WORKTREE_ID, + NOW + ) + + expect(targets).toEqual([ + { + paneKey: makePaneKey(MANUAL_TAB_ID, LEAF_B), + tabId: MANUAL_TAB_ID, + leafId: LEAF_B, + agentType: 'codex', + tabTitle: 'Terminal 2', + status: 'eligible' + } + ]) + }) + + it('skips a manual agent tab with an unrecognized booting pane title', () => { + const targets = deriveNotesSendAgentTargets( + state({ + tabsByWorktree: { + [WORKTREE_ID]: [tab(MANUAL_TAB_ID, { title: 'Terminal 2' })] + }, + terminalLayoutsByTabId: { [MANUAL_TAB_ID]: leafLayout(LEAF_B, 'pty-b') }, + runtimePaneTitlesByTabId: { [MANUAL_TAB_ID]: { 1: 'zsh' } } + }), + WORKTREE_ID, + NOW + ) + + expect(targets).toEqual([]) + }) + + it('disables a manual agent tab with a permission pane title', () => { + const targets = deriveNotesSendAgentTargets( + state({ + tabsByWorktree: { + [WORKTREE_ID]: [tab(MANUAL_TAB_ID, { title: 'Terminal 2' })] + }, + terminalLayoutsByTabId: { [MANUAL_TAB_ID]: leafLayout(LEAF_B, 'pty-b') }, + runtimePaneTitlesByTabId: { [MANUAL_TAB_ID]: { 1: 'Codex - action required' } } + }), + WORKTREE_ID, + NOW + ) + + expect(targets).toEqual([ + expect.objectContaining({ + paneKey: makePaneKey(MANUAL_TAB_ID, LEAF_B), + agentType: 'codex', + status: 'disabled', + disabledReason: 'Agent needs permission' + }) + ]) + }) + it('recognizes a launch-agent tab by its explicit ready tab title when no pane title is set', () => { const targets = deriveNotesSendAgentTargets( state({ @@ -340,6 +416,202 @@ describe('notes send agent targets', () => { }) }) + it('does not duplicate a status-backed tab with a manual title fallback', () => { + const paneKey = makePaneKey(MANUAL_TAB_ID, LEAF_A) + const targets = deriveNotesSendAgentTargets( + state({ + agentStatusByPaneKey: { [paneKey]: entry(paneKey, 'working') }, + tabsByWorktree: { + [WORKTREE_ID]: [tab(MANUAL_TAB_ID, { title: 'Codex ready' })] + }, + terminalLayoutsByTabId: { + [MANUAL_TAB_ID]: splitLayout(LEAF_B, { [LEAF_A]: 'pty-a', [LEAF_B]: 'pty-b' }) + }, + runtimePaneTitlesByTabId: { [MANUAL_TAB_ID]: { 2: 'Codex ready' } } + }), + WORKTREE_ID, + NOW + ) + + expect(targets).toHaveLength(1) + expect(targets[0]).toMatchObject({ + tabId: MANUAL_TAB_ID, + leafId: LEAF_A, + status: 'eligible' + }) + }) + + it('promotes a stale status-backed launch-agent pane when live title and PTY prove it is sendable', () => { + const paneKey = makePaneKey(LAUNCH_TAB_ID, LEAF_B) + const targets = deriveNotesSendAgentTargets( + state({ + agentStatusByPaneKey: { + [paneKey]: entry(paneKey, 'done', OLD_STATUS_UPDATED_AT) + }, + tabsByWorktree: { + [WORKTREE_ID]: [ + tab(LAUNCH_TAB_ID, { title: 'Previous Codex session', launchAgent: 'codex' }) + ] + }, + terminalLayoutsByTabId: { [LAUNCH_TAB_ID]: leafLayout(LEAF_B, 'pty-b') }, + runtimePaneTitlesByTabId: { [LAUNCH_TAB_ID]: { 1: 'Codex ready' } } + }), + WORKTREE_ID, + NOW + ) + + expect(targets).toEqual([ + { + paneKey, + tabId: LAUNCH_TAB_ID, + leafId: LEAF_B, + agentType: 'codex', + tabTitle: 'Previous Codex session', + status: 'eligible' + } + ]) + }) + + it('uses launch ownership when promoting a stale unknown status-backed pane', () => { + const paneKey = makePaneKey(LAUNCH_TAB_ID, LEAF_B) + const targets = deriveNotesSendAgentTargets( + state({ + agentStatusByPaneKey: { + [paneKey]: entry(paneKey, 'done', OLD_STATUS_UPDATED_AT, { + agentType: 'unknown' + }) + }, + tabsByWorktree: { + [WORKTREE_ID]: [tab(LAUNCH_TAB_ID, { title: 'Codex ready', launchAgent: 'codex' })] + }, + terminalLayoutsByTabId: { [LAUNCH_TAB_ID]: leafLayout(LEAF_B, 'pty-b') }, + runtimePaneTitlesByTabId: { [LAUNCH_TAB_ID]: { 1: 'Codex ready' } } + }), + WORKTREE_ID, + NOW + ) + + expect(targets).toEqual([ + expect.objectContaining({ + paneKey, + agentType: 'codex', + status: 'eligible' + }) + ]) + }) + + it('keeps a stale status-backed launch-agent pane disabled with only a bare agent title', () => { + const paneKey = makePaneKey(LAUNCH_TAB_ID, LEAF_B) + const targets = deriveNotesSendAgentTargets( + state({ + agentStatusByPaneKey: { + [paneKey]: entry(paneKey, 'done', OLD_STATUS_UPDATED_AT) + }, + tabsByWorktree: { + [WORKTREE_ID]: [tab(LAUNCH_TAB_ID, { title: 'Codex', launchAgent: 'codex' })] + }, + terminalLayoutsByTabId: { [LAUNCH_TAB_ID]: leafLayout(LEAF_B, 'pty-b') } + }), + WORKTREE_ID, + NOW + ) + + expect(targets).toEqual([ + expect.objectContaining({ + paneKey, + status: 'disabled', + disabledReason: 'Agent status is stale' + }) + ]) + }) + + it('keeps a stale status-backed launch-agent pane disabled when the live title needs permission', () => { + const paneKey = makePaneKey(LAUNCH_TAB_ID, LEAF_B) + const targets = deriveNotesSendAgentTargets( + state({ + agentStatusByPaneKey: { + [paneKey]: entry(paneKey, 'done', OLD_STATUS_UPDATED_AT) + }, + tabsByWorktree: { + [WORKTREE_ID]: [tab(LAUNCH_TAB_ID, { title: 'Codex', launchAgent: 'codex' })] + }, + terminalLayoutsByTabId: { [LAUNCH_TAB_ID]: leafLayout(LEAF_B, 'pty-b') }, + runtimePaneTitlesByTabId: { [LAUNCH_TAB_ID]: { 1: 'Codex - action required' } } + }), + WORKTREE_ID, + NOW + ) + + expect(targets).toEqual([ + expect.objectContaining({ + paneKey, + status: 'disabled', + disabledReason: 'Agent needs permission' + }) + ]) + }) + + it('does not let a stale status-backed split pane hide a different live active launch-agent pane', () => { + const stalePaneKey = makePaneKey(LAUNCH_TAB_ID, LEAF_A) + const livePaneKey = makePaneKey(LAUNCH_TAB_ID, LEAF_B) + const targets = deriveNotesSendAgentTargets( + state({ + agentStatusByPaneKey: { + [stalePaneKey]: entry(stalePaneKey, 'done', OLD_STATUS_UPDATED_AT) + }, + tabsByWorktree: { + [WORKTREE_ID]: [tab(LAUNCH_TAB_ID, { title: 'Codex ready', launchAgent: 'codex' })] + }, + terminalLayoutsByTabId: { + [LAUNCH_TAB_ID]: splitLayout(LEAF_B, { [LEAF_A]: 'pty-a', [LEAF_B]: 'pty-b' }) + }, + runtimePaneTitlesByTabId: { [LAUNCH_TAB_ID]: { 2: 'Codex ready' } } + }), + WORKTREE_ID, + NOW + ) + + expect(targets).toEqual([ + expect.objectContaining({ + paneKey: stalePaneKey, + status: 'disabled', + disabledReason: 'Agent status is stale' + }), + expect.objectContaining({ + paneKey: livePaneKey, + status: 'eligible' + }) + ]) + }) + + it('does not borrow a stale tab title for an active split pane after another pane has title evidence', () => { + const stalePaneKey = makePaneKey(LAUNCH_TAB_ID, LEAF_A) + const targets = deriveNotesSendAgentTargets( + state({ + agentStatusByPaneKey: { + [stalePaneKey]: entry(stalePaneKey, 'done', OLD_STATUS_UPDATED_AT) + }, + tabsByWorktree: { + [WORKTREE_ID]: [tab(LAUNCH_TAB_ID, { title: 'Codex ready', launchAgent: 'codex' })] + }, + terminalLayoutsByTabId: { + [LAUNCH_TAB_ID]: splitLayout(LEAF_B, { [LEAF_A]: 'pty-a', [LEAF_B]: 'pty-b' }) + }, + runtimePaneTitlesByTabId: { [LAUNCH_TAB_ID]: { 1: 'zsh' } } + }), + WORKTREE_ID, + NOW + ) + + expect(targets).toEqual([ + expect.objectContaining({ + paneKey: stalePaneKey, + status: 'disabled', + disabledReason: 'Agent status is stale' + }) + ]) + }) + it('skips a launch-agent tab whose active leaf is not a terminal leaf', () => { const targets = deriveNotesSendAgentTargets( state({ @@ -363,17 +635,22 @@ describe('notes send agent targets', () => { expect(targets).toEqual([]) }) - it('does not list a plain terminal tab without a launch agent or status', () => { + it('recognizes a manual agent tab by its explicit ready tab title when no pane title is set', () => { const targets = deriveNotesSendAgentTargets( state({ - tabsByWorktree: { [WORKTREE_ID]: [tab(LAUNCH_TAB_ID, { title: 'Codex ready' })] }, - terminalLayoutsByTabId: { [LAUNCH_TAB_ID]: leafLayout(LEAF_B, 'pty-b') }, - runtimePaneTitlesByTabId: { [LAUNCH_TAB_ID]: { 1: 'Codex ready' } } + tabsByWorktree: { [WORKTREE_ID]: [tab(MANUAL_TAB_ID, { title: 'Codex ready' })] }, + terminalLayoutsByTabId: { [MANUAL_TAB_ID]: leafLayout(LEAF_B, 'pty-b') } }), WORKTREE_ID, NOW ) - expect(targets).toEqual([]) + expect(targets).toEqual([ + expect.objectContaining({ + paneKey: makePaneKey(MANUAL_TAB_ID, LEAF_B), + agentType: 'codex', + status: 'eligible' + }) + ]) }) }) diff --git a/src/renderer/src/lib/notes-send-agent-targets.ts b/src/renderer/src/lib/notes-send-agent-targets.ts index e2909a22b0f..163907d99fc 100644 --- a/src/renderer/src/lib/notes-send-agent-targets.ts +++ b/src/renderer/src/lib/notes-send-agent-targets.ts @@ -1,18 +1,18 @@ import type { AgentType } from '../../../shared/agent-status-types' import type { AppState } from '@/store/types' import { isTerminalLeafId, makePaneKey } from '../../../shared/stable-pane-id' -import { detectAgentStatusFromTitle, getAgentLabel } from './agent-status' -import { resolveRuntimePaneTitleForLeaf } from './runtime-pane-title-leaf-id' +import { resolveTerminalTitleAgentType } from '../../../shared/terminal-title-agent-type' +import type { TerminalTab } from '../../../shared/types' +import { detectAgentSendTitleStatus } from './agent-send-title-status' +import { + resolveRuntimePaneTitleLeafResolution, + type RuntimePaneTitleLeafResolution +} from './runtime-pane-title-leaf-id' import { deriveRunningAgentSendTargets, type RunningAgentTargetState } from './running-agent-targets' -const EXPLICIT_IDLE_TITLE_RE = /(^|\s)(ready|idle|done)(\s|$|[.!?])/i -const CLAUDE_IDLE_PREFIX = '\u2733' -const GEMINI_IDLE_PREFIX = '\u25c7' -const PI_IDLE_PREFIX = '\u03c0 - ' - export type NotesSendAgentTargetState = RunningAgentTargetState & Pick @@ -26,41 +26,26 @@ export type NotesSendAgentTarget = { disabledReason?: string } -function isRecognizedAgentTitle(title: string | null): boolean { - return ( - title !== null && detectAgentStatusFromTitle(title) !== null && getAgentLabel(title) !== null - ) +type AgentTitleEvidence = { + status: NonNullable> + title: string } -function detectLaunchAgentPaneStatus(paneTitle: string | null, tabTitle: string) { - if (paneTitle !== null && isRecognizedAgentTitle(paneTitle)) { - return detectLaunchAgentStatusFromTitle(paneTitle) +function detectTitleHintPaneEvidence( + paneTitleResolution: RuntimePaneTitleLeafResolution, + tabTitle: string +): AgentTitleEvidence | null { + if (paneTitleResolution.title !== null) { + const status = detectAgentSendTitleStatus(paneTitleResolution.title) + return status ? { status, title: paneTitleResolution.title } : null } // Why: mirror isTerminalRunningAgent — the OSC-enriched tab title only counts // when the leaf has no runtime pane title of its own yet. - return paneTitle === null && isRecognizedAgentTitle(tabTitle) - ? detectLaunchAgentStatusFromTitle(tabTitle) - : null -} - -function detectLaunchAgentStatusFromTitle(title: string) { - const status = detectAgentStatusFromTitle(title) - if (status !== 'idle') { - return status + if (paneTitleResolution.hasAnyPaneTitle) { + return null } - // Why: selected-target sends are immediate. A bare launch title like - // "Codex" proves agent identity, but not that the CLI is ready for input. - return isExplicitIdleLaunchTitle(title) ? status : null -} - -function isExplicitIdleLaunchTitle(title: string): boolean { - return ( - EXPLICIT_IDLE_TITLE_RE.test(title) || - title.startsWith(CLAUDE_IDLE_PREFIX) || - title.startsWith('* ') || - title.includes(GEMINI_IDLE_PREFIX) || - title.startsWith(PI_IDLE_PREFIX) - ) + const status = detectAgentSendTitleStatus(tabTitle) + return status ? { status, title: tabTitle } : null } /** @@ -69,11 +54,12 @@ function isExplicitIdleLaunchTitle(title: string): boolean { * Why this exists on top of deriveRunningAgentSendTargets: that derivation only * sees panes with a live status entry, so a freshly launched (still idle) agent * stays invisible until its first hook event — i.e. until the user talks to it. - * We augment it with launch-agent tabs whose pane still has a live PTY: - * TerminalTab.launchAgent records the harness Orca started and is the same - * pre-hook signal the tab bar already trusts for its provider icon. + * We augment it with recognized agent-title tabs whose pane still has a live + * PTY. TerminalTab.launchAgent records the harness Orca started; manually typed + * CLIs do not have that owner bit, so their runtime title is the only pre-hook + * signal available. * - * The launch hint is gated on a recognized agent title (pane or tab) — the same + * The title hint is gated on a recognized agent title (pane or tab) — the same * signal isTerminalRunningAgent checks — so a freshly spawned tab is only listed * once the runtime would actually accept the send. Without that gate, clicking a * still-booting pane fails with "not a recognized agent session". @@ -88,59 +74,128 @@ export function deriveNotesSendAgentTargets( paneKey: target.paneKey, tabId: target.tabId, leafId: target.leafId, - agentType: target.entry.agentType, + agentType: resolveNotesTargetAgentType(target.entry.agentType, target.tab.launchAgent), tabTitle: target.tab.title, status: target.status, ...(target.disabledReason ? { disabledReason: target.disabledReason } : {}) }) ) - // Why: dedupe by tab, not pane. A launch-agent tab already surfaced through a - // live status entry must not also emit a hint row — its active leaf may be a - // split shell pane, which would list a second bogus row for the same tab. - const statusBackedTabIds = new Set(targets.map((target) => target.tabId)) - for (const tab of state.tabsByWorktree[worktreeId] ?? []) { - if (!tab.launchAgent || statusBackedTabIds.has(tab.id)) { + const titleHintTarget = deriveTitleHintAgentTarget(state, tab) + if (!titleHintTarget) { continue } - const layout = state.terminalLayoutsByTabId[tab.id] - const leafId = layout?.activeLeafId - if (!leafId || !isTerminalLeafId(leafId)) { - continue + // Why: a launchAgent tab carries an owner bit, so its live title can promote a + // stale status row on the same pane. A manually started CLI has no owner bit, + // so it only ever adds a row and must not override existing status evidence. + if (tab.launchAgent) { + mergeLaunchAgentTitleTarget(targets, titleHintTarget) + } else { + mergeManualAgentTitleTarget(targets, titleHintTarget) } - - const ptyId = layout.ptyIdsByLeafId?.[leafId] ?? null - if (!ptyId || !state.ptyIdsByTabId[tab.id]?.includes(ptyId)) { - continue - } - - const paneTitle = resolveRuntimePaneTitleForLeaf( - layout, - state.runtimePaneTitlesByTabId[tab.id], - leafId - ) - const launchStatus = detectLaunchAgentPaneStatus(paneTitle, tab.title) - if (!launchStatus) { - // Why: launchAgent is set the instant Orca spawns the tab, but the runtime - // only accepts a send once the pane reads as an agent. Skipping until the - // title is recognized keeps "listed ⇒ sendable" and avoids the boot-window - // "not a recognized agent session" error. - continue - } - const disabledReason = launchStatus === 'permission' ? 'Agent needs permission' : undefined - - targets.push({ - paneKey: makePaneKey(tab.id, leafId), - tabId: tab.id, - leafId, - agentType: tab.launchAgent, - tabTitle: tab.title, - status: disabledReason ? 'disabled' : 'eligible', - ...(disabledReason ? { disabledReason } : {}) - }) } return targets } + +function resolveNotesTargetAgentType( + entryAgentType: AgentType | null | undefined, + launchAgent: AgentType | null | undefined +): AgentType | null | undefined { + if (entryAgentType && entryAgentType !== 'unknown') { + return entryAgentType + } + return launchAgent ?? entryAgentType +} + +function deriveTitleHintAgentTarget( + state: NotesSendAgentTargetState, + tab: TerminalTab +): NotesSendAgentTarget | null { + const layout = state.terminalLayoutsByTabId[tab.id] + const leafId = layout?.activeLeafId + if (!leafId || !isTerminalLeafId(leafId)) { + return null + } + + const ptyId = layout.ptyIdsByLeafId?.[leafId] ?? null + if (!ptyId || !state.ptyIdsByTabId[tab.id]?.includes(ptyId)) { + return null + } + + const paneTitles = state.runtimePaneTitlesByTabId[tab.id] + const paneTitleResolution = resolveRuntimePaneTitleLeafResolution(layout, paneTitles, leafId) + const titleEvidence = detectTitleHintPaneEvidence(paneTitleResolution, tab.title) + if (!titleEvidence) { + // Why: launchAgent is set the instant Orca spawns the tab, but the runtime + // only accepts a send once the pane reads as an agent; manually started + // CLIs need the same title proof before appearing in the send menu. + return null + } + const disabledReason = + titleEvidence.status === 'permission' ? 'Agent needs permission' : undefined + + return { + paneKey: makePaneKey(tab.id, leafId), + tabId: tab.id, + leafId, + agentType: tab.launchAgent ?? resolveTerminalTitleAgentType(titleEvidence.title), + tabTitle: tab.title, + status: disabledReason ? 'disabled' : 'eligible', + ...(disabledReason ? { disabledReason } : {}) + } +} + +// Why: a manual title hint is the weakest signal, so never duplicate a tab that +// already has a status-backed or launch-agent row — even a disabled one. +function mergeManualAgentTitleTarget( + targets: NotesSendAgentTarget[], + target: NotesSendAgentTarget +): void { + if (targets.some((existing) => existing.tabId === target.tabId)) { + return + } + targets.push(target) +} + +function mergeLaunchAgentTitleTarget( + targets: NotesSendAgentTarget[], + target: NotesSendAgentTarget +): void { + const samePaneIndex = targets.findIndex((existing) => existing.paneKey === target.paneKey) + if (samePaneIndex !== -1) { + const existing = targets[samePaneIndex] + if (existing.status === 'eligible' || existing.disabledReason === 'Agent needs permission') { + return + } + + // Why: hook-backed status can outlive the CLI after sleep/resume. When the + // same live launch-agent pane has a fresh title proof, prefer the sendable + // runtime evidence over the stale retained status row. + targets[samePaneIndex] = { + ...target, + agentType: + existing.agentType && existing.agentType !== 'unknown' + ? existing.agentType + : target.agentType, + tabTitle: existing.tabTitle || target.tabTitle + } + return + } + + // Why: dedupe by tab for fresh/permission status rows. Their active leaf may + // be a split shell pane, which would list a second bogus row for the same tab. + if ( + targets.some( + (existing) => + existing.tabId === target.tabId && + (existing.status === 'eligible' || existing.disabledReason === 'Agent needs permission') + ) + ) { + return + } + + targets.push(target) +} 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 c2c701bee7d..0b552ccc4ca 100644 --- a/src/renderer/src/lib/pane-manager/pane-divider-drag.ts +++ b/src/renderer/src/lib/pane-manager/pane-divider-drag.ts @@ -158,7 +158,7 @@ export function attachDividerDrag( if (shouldRefit && nextEl) { callbacks.refitPanesUnder(nextEl) } - if (shouldRefit) { + if (commitLayout && shouldRefit) { releasePtyResizeHold?.flush() } else { releasePtyResizeHold?.cancel() @@ -179,6 +179,21 @@ export function attachDividerDrag( e.preventDefault() flexScheduler.cancel() finishActiveDrag(false) + const previousPane = divider.previousElementSibling as HTMLElement | null + const nextPane = divider.nextElementSibling as HTMLElement | null + if (!previousPane || !nextPane) { + return + } + + const prevRect = previousPane.getBoundingClientRect() + const nextRect = nextPane.getBoundingClientRect() + const prevSize = isVertical ? prevRect.width : prevRect.height + const nextSize = isVertical ? nextRect.width : nextRect.height + const measuredTotalSize = prevSize + nextSize + if (!Number.isFinite(measuredTotalSize) || measuredTotalSize <= 0) { + return + } + divider.setPointerCapture(e.pointerId) activePointerId = e.pointerId divider.classList.add('is-dragging') @@ -188,12 +203,8 @@ export function attachDividerDrag( addWindowListeners() startPos = isVertical ? e.clientX : e.clientY - prevEl = divider.previousElementSibling as HTMLElement | null - nextEl = divider.nextElementSibling as HTMLElement | null - - if (!prevEl || !nextEl) { - return - } + prevEl = previousPane + nextEl = nextPane prevInitialFlex = prevEl.style.flex nextInitialFlex = nextEl.style.flex @@ -201,11 +212,7 @@ export function attachDividerDrag( // we still fit xterm locally, but forward only the final PTY size on drop. releasePtyResizeHold = holdPtyResizesForPaneSubtrees([prevEl, nextEl]) - const prevRect = prevEl.getBoundingClientRect() - const nextRect = nextEl.getBoundingClientRect() - const prevSize = isVertical ? prevRect.width : prevRect.height - const nextSize = isVertical ? nextRect.width : nextRect.height - totalSize = prevSize + nextSize + totalSize = measuredTotalSize prevFlex = prevSize } diff --git a/src/renderer/src/lib/pane-manager/pane-divider.test.ts b/src/renderer/src/lib/pane-manager/pane-divider.test.ts index 59d9f878f84..04475662b66 100644 --- a/src/renderer/src/lib/pane-manager/pane-divider.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-divider.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { createDivider, createDividerFlexFrameScheduler, disposeDivider } from './pane-divider' +import { queuePanePtyResizeIfHeld } from './pane-pty-resize-hold' afterEach(() => { vi.unstubAllGlobals() @@ -47,6 +48,94 @@ describe('createDividerFlexFrameScheduler', () => { }) describe('disposeDivider', () => { + it('does not start divider drag state without resizeable pane siblings', () => { + const dividerListeners = new Map() + const onDragActiveChange = vi.fn() + const preventDefault = vi.fn() + 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(), + setPointerCapture: vi.fn(), + hasPointerCapture: vi.fn(() => false), + releasePointerCapture: vi.fn(), + previousElementSibling: null, + nextElementSibling: null + } as unknown as HTMLElement + vi.stubGlobal('document', { + createElement: vi.fn(() => divider) + }) + vi.stubGlobal('window', { + addEventListener: vi.fn(), + removeEventListener: vi.fn() + }) + vi.stubGlobal('requestAnimationFrame', vi.fn()) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) + + createDivider(true, {}, { refitPanesUnder: vi.fn(), onDragActiveChange }) + dividerListeners.get('pointerdown')?.( + createPointerEvent({ pointerId: 7, clientX: 10, preventDefault }) + ) + + expect(preventDefault).toHaveBeenCalled() + expect(divider.setPointerCapture).not.toHaveBeenCalled() + expect(divider.classList.add).not.toHaveBeenCalled() + expect(onDragActiveChange).not.toHaveBeenCalled() + expect(window.addEventListener).not.toHaveBeenCalled() + }) + + it('does not start divider drag state when sibling panes have no measurable size', () => { + const dividerListeners = new Map() + const onDragActiveChange = vi.fn() + const previousPane = createSizedPaneElement({ width: 0, height: 200 }) + const nextPane = createSizedPaneElement({ width: 0, height: 200 }) + 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(), + setPointerCapture: vi.fn(), + hasPointerCapture: vi.fn(() => false), + releasePointerCapture: vi.fn(), + previousElementSibling: previousPane, + nextElementSibling: nextPane + } as unknown as HTMLElement + vi.stubGlobal('document', { + createElement: vi.fn(() => divider) + }) + vi.stubGlobal('window', { + addEventListener: vi.fn(), + removeEventListener: vi.fn() + }) + vi.stubGlobal('requestAnimationFrame', vi.fn()) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) + + createDivider(true, {}, { refitPanesUnder: vi.fn(), onDragActiveChange }) + dividerListeners.get('pointerdown')?.(createPointerEvent({ pointerId: 7, clientX: 10 })) + + expect(divider.setPointerCapture).not.toHaveBeenCalled() + expect(divider.classList.add).not.toHaveBeenCalled() + expect(onDragActiveChange).not.toHaveBeenCalled() + expect(window.addEventListener).not.toHaveBeenCalled() + expect(previousPane.style.flex).toBeUndefined() + expect(nextPane.style.flex).toBeUndefined() + }) + it('finishes an active resize from a window-level pointerup', () => { const dividerListeners = new Map() const windowListeners = new Map() @@ -251,8 +340,75 @@ describe('disposeDivider', () => { expect(onLayoutChanged).not.toHaveBeenCalled() }) + it('drops held PTY resize updates when an active resize is cancelled', () => { + const dividerListeners = new Map() + const windowListeners = new Map() + const capturedPointerIds = new Set() + const previousPane = createSizedPaneElement( + { width: 100, height: 200 }, + { classNames: ['pane'] } + ) + const nextPane = createSizedPaneElement({ width: 300, height: 200 }, { classNames: ['pane'] }) + 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(), + 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() + }) + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn(() => 7) + ) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) + + createDivider(true, {}, { refitPanesUnder: vi.fn(), onLayoutChanged: vi.fn() }) + dividerListeners.get('pointerdown')?.( + createPointerEvent({ pointerId: 9, clientX: 100, clientY: 0 }) + ) + + expect(queuePanePtyResizeIfHeld(previousPane, 140, 30)).toBe(true) + expect(queuePanePtyResizeIfHeld(nextPane, 80, 30)).toBe(true) + + windowListeners.get('pointermove')?.( + createPointerEvent({ pointerId: 9, clientX: 180, clientY: 0 }) + ) + windowListeners.get('pointercancel')?.(createPointerEvent({ pointerId: 9 })) + + expect(previousPane.dispatchEvent).not.toHaveBeenCalled() + expect(nextPane.dispatchEvent).not.toHaveBeenCalled() + expect(queuePanePtyResizeIfHeld(previousPane, 140, 30)).toBe(false) + expect(queuePanePtyResizeIfHeld(nextPane, 80, 30)).toBe(false) + }) + it('removes divider-local drag listeners and releases active pointer capture', () => { const listeners = new Map() + const previousPane = createSizedPaneElement({ width: 100, height: 200 }) + const nextPane = createSizedPaneElement({ width: 300, height: 200 }) const divider = { style: { setProperty: vi.fn() @@ -272,8 +428,8 @@ describe('disposeDivider', () => { setPointerCapture: vi.fn(), hasPointerCapture: vi.fn(() => true), releasePointerCapture: vi.fn(), - previousElementSibling: null, - nextElementSibling: null + previousElementSibling: previousPane, + nextElementSibling: nextPane } as unknown as HTMLElement vi.stubGlobal('document', { createElement: vi.fn(() => divider) @@ -311,15 +467,25 @@ function createPointerEvent(args: Partial): PointerEvent { } as unknown as PointerEvent } -function createSizedPaneElement(rect: { - width: number - height: number -}): HTMLElement & { style: Record } { +function createSizedPaneElement( + rect: { + width: number + height: number + }, + options?: { + classNames?: string[] + } +): HTMLElement & { + dispatchEvent: ReturnType + style: Record +} { + const classNames = new Set(options?.classNames ?? []) return { style: {}, classList: { - contains: vi.fn(() => false) + contains: vi.fn((className: string) => classNames.has(className)) }, + dispatchEvent: vi.fn(() => true), getBoundingClientRect: vi.fn(() => ({ left: 0, top: 0, @@ -329,5 +495,8 @@ function createSizedPaneElement(rect: { height: rect.height })), querySelectorAll: vi.fn(() => []) - } as unknown as HTMLElement & { style: Record } + } as unknown as HTMLElement & { + dispatchEvent: ReturnType + style: Record + } } diff --git a/src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts b/src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts index ec1783654d5..3165e86c04e 100644 --- a/src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts @@ -1,5 +1,5 @@ // @vitest-environment happy-dom -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { TerminalLeafId } from '../../../../shared/stable-pane-id' import { createPaneDOM } from './pane-dom-creation' @@ -48,7 +48,65 @@ vi.mock('@xterm/xterm', () => ({ }) })) +function setPlatform(userAgent: string): void { + vi.stubGlobal('navigator', { userAgent }) +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + describe('createPaneDOM link tooltips', () => { + it('anchors WebLinks hover text to the unpadded terminal window corner', () => { + const leafId = '11111111-1111-4111-8111-111111111111' as TerminalLeafId + const pane = createPaneDOM( + 1, + leafId, + {}, + { active: null } as never, + {} as never, + vi.fn(), + vi.fn() + ) + + expect(pane.linkTooltip.style.left).toBe('0px') + expect(pane.linkTooltip.style.bottom).toBe('0px') + }) + + it('uses desktop modifier-click text for WebLinks hover hints', () => { + const leafId = '11111111-1111-4111-8111-111111111111' as TerminalLeafId + + setPlatform('Macintosh') + const macPane = createPaneDOM( + 1, + leafId, + {}, + { active: null } as never, + {} as never, + vi.fn(), + vi.fn() + ) + webLinksAddonMock.options?.hover?.({} as MouseEvent, 'http://localhost:5180/') + expect(macPane.linkTooltip.textContent).toBe( + 'http://localhost:5180/ (⌘+click to open or ⇧⌘+click for system browser)' + ) + + setPlatform('Windows') + const windowsPane = createPaneDOM( + 2, + leafId, + {}, + { active: null } as never, + {} as never, + vi.fn(), + vi.fn() + ) + webLinksAddonMock.options?.hover?.({} as MouseEvent, 'http://localhost:5180/') + expect(windowsPane.linkTooltip.textContent).toBe( + 'http://localhost:5180/ (Ctrl+click to open or Shift+Ctrl+click for system browser)' + ) + }) + it('lets callers replace WebLinks hover text for display-only labels', async () => { const labeledText = 'http://main.orca.localhost:60016/ (localhost:5180; click to open)' const leafId = '11111111-1111-4111-8111-111111111111' as TerminalLeafId diff --git a/src/renderer/src/lib/pane-manager/pane-dom-creation.ts b/src/renderer/src/lib/pane-manager/pane-dom-creation.ts index 8c1c1e289ff..43b628ffbd5 100644 --- a/src/renderer/src/lib/pane-manager/pane-dom-creation.ts +++ b/src/renderer/src/lib/pane-manager/pane-dom-creation.ts @@ -16,8 +16,8 @@ import { installGuardedLinkProviderRegistration } from './terminal-link-provider function getTerminalUrlOpenHint(): string { return navigator.userAgent.includes('Mac') - ? 'click to open or ⇧+click for system browser' - : 'click to open or Shift+click for system browser' + ? '⌘+click to open or ⇧⌘+click for system browser' + : 'Ctrl+click to open or Shift+Ctrl+click for system browser' } function defaultLinkTooltipText(uri: string, openLinkHint: string): string { @@ -66,8 +66,10 @@ export function createPaneDOM( const linkTooltip = document.createElement('div') linkTooltip.className = 'pane-link-tooltip' linkTooltip.classList.add('xterm-hover') + // Why: Ghostty-style URL hover belongs to the terminal window corner; do not + // let terminal content padding shift it inward. linkTooltip.style.cssText = - 'display:none;position:absolute;bottom:4px;left:8px;z-index:40;' + + 'display:none;position:absolute;bottom:0;left:0;z-index:40;' + 'padding:5px 8px;border-radius:4px;font-size:11px;font-family:inherit;' + 'color:#a1a1aa;background:rgba(24,24,27,0.85);border:1px solid rgba(63,63,70,0.6);' + 'pointer-events:none;max-width:80%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;' diff --git a/src/renderer/src/lib/pane-manager/pane-drag-pointer.ts b/src/renderer/src/lib/pane-manager/pane-drag-pointer.ts index 45d71f151e0..3be4db71d72 100644 --- a/src/renderer/src/lib/pane-manager/pane-drag-pointer.ts +++ b/src/renderer/src/lib/pane-manager/pane-drag-pointer.ts @@ -1,4 +1,4 @@ -import type { DropZone, ManagedPaneInternal } from './pane-manager-types' +import type { DropZone, ManagedPaneInternal, PaneExternalDropTarget } from './pane-manager-types' import type { DragReorderCallbacks, DragReorderState } from './pane-drag-reorder' import { handlePaneDrop, @@ -62,14 +62,18 @@ export function beginPaneDragFromPointerDown( callbacks.getRoot().classList.remove('is-pane-dragging') callbacks.getPanes().get(paneId)?.container.classList.remove('is-drag-source') try { - if (commitDrop && state.currentDropTarget && state.dragSourcePaneId !== null) { - handlePaneDrop( - state.dragSourcePaneId, - state.currentDropTarget.paneId, - state.currentDropTarget.zone, - state, - callbacks - ) + if (commitDrop && state.dragSourcePaneId !== null) { + if (state.currentDropTarget) { + handlePaneDrop( + state.dragSourcePaneId, + state.currentDropTarget.paneId, + state.currentDropTarget.zone, + state, + callbacks + ) + } else if (state.currentExternalDropTarget) { + callbacks.onExternalPaneDrop?.(state.dragSourcePaneId, state.currentExternalDropTarget) + } } } finally { // Why: pointer capture can be lost crossing Electron webviews; always @@ -78,6 +82,7 @@ export function beginPaneDragFromPointerDown( hideDropOverlay(state) state.dragSourcePaneId = null state.currentDropTarget = null + state.currentExternalDropTarget = null } } @@ -166,8 +171,20 @@ function updateDropTarget( } const targetPane = findDropTargetPane(clientX, clientY, state, callbacks) if (!targetPane) { - overlay.style.display = 'none' + const sourcePaneId = state.dragSourcePaneId + const externalTarget = + sourcePaneId === null + ? null + : (callbacks.resolveExternalDropTarget?.({ sourcePaneId, clientX, clientY }) ?? null) + if (!externalTarget) { + overlay.style.display = 'none' + state.currentDropTarget = null + state.currentExternalDropTarget = null + return + } state.currentDropTarget = null + state.currentExternalDropTarget = externalTarget + positionExternalDropOverlay(overlay, externalTarget) return } @@ -180,9 +197,11 @@ function updateDropTarget( ) { overlay.style.display = 'none' state.currentDropTarget = null + state.currentExternalDropTarget = null return } state.currentDropTarget = { paneId: targetPane.id, zone } + state.currentExternalDropTarget = null positionDropOverlay(overlay, rect, zone) } @@ -223,6 +242,7 @@ function resolveDropZone(clientX: number, clientY: number, rect: DOMRect): DropZ function positionDropOverlay(overlay: HTMLElement, rect: DOMRect, zone: DropZone): void { overlay.style.display = '' + overlay.dataset.paneDropOverlayKind = 'area' const scrollX = window.scrollX const scrollY = window.scrollY const halfWidth = rect.width / 2 @@ -233,3 +253,13 @@ function positionDropOverlay(overlay: HTMLElement, rect: DOMRect, zone: DropZone overlay.style.width = `${zone === 'left' || zone === 'right' ? halfWidth : rect.width}px` overlay.style.height = `${zone === 'top' || zone === 'bottom' ? halfHeight : rect.height}px` } + +function positionExternalDropOverlay(overlay: HTMLElement, target: PaneExternalDropTarget): void { + const rect = target.rect + overlay.style.display = '' + overlay.dataset.paneDropOverlayKind = target.overlayKind ?? 'area' + overlay.style.left = `${rect.left + window.scrollX}px` + overlay.style.top = `${rect.top + window.scrollY}px` + overlay.style.width = `${rect.width}px` + overlay.style.height = `${rect.height}px` +} diff --git a/src/renderer/src/lib/pane-manager/pane-drag-reorder.test.ts b/src/renderer/src/lib/pane-manager/pane-drag-reorder.test.ts index dd81b2063fe..1f5996526cd 100644 --- a/src/renderer/src/lib/pane-manager/pane-drag-reorder.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-drag-reorder.test.ts @@ -229,6 +229,7 @@ describe('attachPaneDrag', () => { expect(appendedElements[0].removed).toBe(true) expect(state.dragSourcePaneId).toBeNull() expect(state.currentDropTarget).toBeNull() + expect(state.currentExternalDropTarget).toBeNull() expect(state.cleanupActiveDrag).toBeNull() expect(onDragActiveChange).toHaveBeenLastCalledWith(false) expect(detachPaneFromTree).not.toHaveBeenCalled() @@ -288,10 +289,83 @@ describe('attachPaneDrag', () => { expect(appendedElements[0]?.removed).toBe(true) expect(state.dragSourcePaneId).toBeNull() expect(state.currentDropTarget).toBeNull() + expect(state.currentExternalDropTarget).toBeNull() expect(state.cleanupActiveDrag).toBeNull() expect(onDragActiveChange).toHaveBeenLastCalledWith(false) }) + it('drops onto an external target when no pane target is under the pointer', () => { + const handle = new FakeElement() + const root = new FakeElement(['pane-manager-root']) + const sourcePane = createPane( + 1, + new FakeElement(['pane'], { + left: 0, + top: 80, + right: 100, + bottom: 180, + width: 100, + height: 100 + }) + ) + const siblingPane = createPane( + 2, + new FakeElement(['pane'], { + left: 120, + top: 80, + right: 220, + bottom: 180, + width: 100, + height: 100 + }) + ) + const panes = new Map([ + [sourcePane.id, sourcePane], + [siblingPane.id, siblingPane] + ]) + const externalTarget = { + id: 'group-1', + overlayKind: 'insertion' as const, + rect: { left: 0, top: 0, right: 300, bottom: 32, width: 300, height: 32 } as DOMRect + } + const onExternalPaneDrop = vi.fn(() => true) + const state = createDragReorderState() + + attachPaneDrag(handle as unknown as HTMLElement, sourcePane.id, state, { + getPanes: () => panes, + getRoot: () => root as unknown as HTMLElement, + getStyleOptions: () => ({}), + isDestroyed: () => false, + safeFit: vi.fn(), + applyPaneOpacity: vi.fn(), + applyDividerStyles: vi.fn(), + refitPanesUnder: vi.fn(), + resolveExternalDropTarget: ({ clientX, clientY }) => + clientX === 10 && clientY === 10 ? externalTarget : null, + onExternalPaneDrop + }) + + handle.dispatchPointer('pointerdown', pointerEvent({ clientX: 10, clientY: 90 })) + handle.dispatchPointer('pointermove', pointerEvent({ clientX: 10, clientY: 10 })) + + expect(state.currentDropTarget).toBeNull() + expect(state.currentExternalDropTarget).toBe(externalTarget) + expect(appendedElements[0].style).toMatchObject({ + left: '0px', + top: '0px', + width: '300px', + height: '32px' + }) + expect(appendedElements[0].dataset.paneDropOverlayKind).toBe('insertion') + + handle.dispatchPointer('pointerup', pointerEvent({ pointerId: 1 })) + + expect(onExternalPaneDrop).toHaveBeenCalledWith(sourcePane.id, externalTarget) + expect(detachPaneFromTree).not.toHaveBeenCalled() + expect(insertPaneNextTo).not.toHaveBeenCalled() + expect(state.currentExternalDropTarget).toBeNull() + }) + it('returns cleanup that removes handle listeners and cancels active drag capture', () => { const handle = new FakeElement() const root = new FakeElement(['pane-manager-root']) diff --git a/src/renderer/src/lib/pane-manager/pane-drag-reorder.ts b/src/renderer/src/lib/pane-manager/pane-drag-reorder.ts index 6b08dc4b48e..7c1803227c1 100644 --- a/src/renderer/src/lib/pane-manager/pane-drag-reorder.ts +++ b/src/renderer/src/lib/pane-manager/pane-drag-reorder.ts @@ -1,5 +1,12 @@ -import type { DropZone, ManagedPane, ManagedPaneInternal } from './pane-manager-types' -import type { PaneStyleOptions } from './pane-manager-types' +import type { + DropZone, + ManagedPane, + ManagedPaneInternal, + PaneExternalDropHandler, + PaneExternalDropResolver, + PaneExternalDropTarget, + PaneStyleOptions +} from './pane-manager-types' import { detachPaneFromTree, findPaneChildren, insertPaneNextTo } from './pane-tree-ops' // --------------------------------------------------------------------------- @@ -10,6 +17,7 @@ export type DragReorderState = { dragSourcePaneId: number | null dropOverlay: HTMLElement | null currentDropTarget: { paneId: number; zone: DropZone } | null + currentExternalDropTarget: PaneExternalDropTarget | null cleanupActiveDrag: ((commitDrop: boolean) => void) | null } @@ -25,6 +33,8 @@ export type DragReorderCallbacks = { requestPaneReparentFrame?: (callback: FrameRequestCallback) => void onLayoutChanged?: () => void onDragActiveChange?: (active: boolean) => void + resolveExternalDropTarget?: PaneExternalDropResolver + onExternalPaneDrop?: PaneExternalDropHandler } export function createDragReorderState(): DragReorderState { @@ -32,6 +42,7 @@ export function createDragReorderState(): DragReorderState { dragSourcePaneId: null, dropOverlay: null, currentDropTarget: null, + currentExternalDropTarget: null, cleanupActiveDrag: null } } @@ -44,6 +55,7 @@ export function cancelActivePaneDrag(state: DragReorderState): void { hideDropOverlay(state) state.dragSourcePaneId = null state.currentDropTarget = null + state.currentExternalDropTarget = null } /** True when dropping source onto target in zone would leave pane order unchanged. */ diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts index cb41bca09b8..16791a485b4 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts @@ -444,14 +444,32 @@ describe('openTerminal — Unicode 11 ordering', () => { } } - const fakeContainer = { + const fakePaneContainer = { appendChild: vi.fn(), addEventListener: vi.fn() } as unknown as HTMLDivElement + const fakeXtermContainer = { + appendChild: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn() + } as unknown as HTMLDivElement const fakeTooltip = {} as unknown as HTMLDivElement + const fakeTerminalElement = { + appendChild: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + querySelector: vi.fn(() => null), + classList: { contains: vi.fn(() => false) } + } as unknown as HTMLElement + vi.stubGlobal( + 'MutationObserver', + vi.fn(function MutationObserver() { + return { observe: vi.fn(), disconnect: vi.fn() } + }) + ) const terminal = { - element: null as HTMLElement | null, + element: fakeTerminalElement, textarea: null, cols: 80, rows: 24, @@ -485,8 +503,8 @@ describe('openTerminal — Unicode 11 ordering', () => { leafId, stablePaneId: leafId, terminal, - container: fakeContainer, - xtermContainer: fakeContainer, + container: fakePaneContainer, + xtermContainer: fakeXtermContainer, linkTooltip: fakeTooltip, terminalGpuAcceleration: 'off', gpuRenderingEnabled: false, @@ -507,19 +525,26 @@ describe('openTerminal — Unicode 11 ordering', () => { debugLabel: null } - openTerminal(pane) + try { + openTerminal(pane) - expect(events).toContain('loadAddon:unicode11') - expect(events).toContain('activeVersion=11') + expect(fakePaneContainer.appendChild).toHaveBeenCalledWith(fakeTooltip) + expect(fakeXtermContainer.appendChild).not.toHaveBeenCalled() + expect(fakeTerminalElement.appendChild).not.toHaveBeenCalled() + expect(events).toContain('loadAddon:unicode11') + expect(events).toContain('activeVersion=11') - const unicodeIdx = events.indexOf('activeVersion=11') - const writeIdx = events.indexOf('write') - if (writeIdx !== -1) { - expect(unicodeIdx).toBeLessThan(writeIdx) + const unicodeIdx = events.indexOf('activeVersion=11') + const writeIdx = events.indexOf('write') + if (writeIdx !== -1) { + expect(unicodeIdx).toBeLessThan(writeIdx) + } + + const loadUnicodeIdx = events.indexOf('loadAddon:unicode11') + expect(loadUnicodeIdx).toBeLessThan(unicodeIdx) + expect(events.indexOf('open')).toBeLessThan(loadUnicodeIdx) + } finally { + vi.unstubAllGlobals() } - - const loadUnicodeIdx = events.indexOf('loadAddon:unicode11') - expect(loadUnicodeIdx).toBeLessThan(unicodeIdx) - expect(events.indexOf('open')).toBeLessThan(loadUnicodeIdx) }) }) diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts index eb296a1d675..c6727c3c1c1 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts @@ -12,11 +12,12 @@ import { detachPaneFitResizeObserver } from './pane-fit-resize-observer' import { clearPendingSplitScrollRestore } from './pane-split-scroll' -import { activateOrcaTerminalUnicodeProvider } from './pane-terminal-unicode-provider' +import { activateOrcaTerminalUnicodeProvider } from '../../../../shared/terminal-unicode-provider' import { attachTerminalMouseWheelMultiplier } from './pane-terminal-mouse-wheel' import { attachTerminalScrollIntentTracking } from './terminal-scroll-intent' import { attachDomRendererFocusClassSync } from './pane-dom-focus-class-sync' import { attachWebgl, cancelPendingWebglRefresh, disposeWebgl } from './pane-webgl-renderer' +import { resolveCursorAgentImeAnchor } from './terminal-ime-anchor' // --------------------------------------------------------------------------- // Pane creation, terminal open/close, addon management @@ -28,6 +29,7 @@ export { createPaneDOM } from './pane-dom-creation' export function openTerminal(pane: ManagedPaneInternal): void { const { terminal, + container, xtermContainer, linkTooltip, terminalTuiScrollSensitivity, @@ -40,8 +42,9 @@ export function openTerminal(pane: ManagedPaneInternal): void { // Open terminal into DOM terminal.open(xtermContainer) - const linkTooltipContainer = terminal.element ?? xtermContainer - linkTooltipContainer.appendChild(linkTooltip) + // Why: terminal.element sits under the padded xterm container. Pane-level + // placement keeps the hover URL on the true bottom-left window corner. + container.appendChild(linkTooltip) // Load addons (order matters: WebGL must be after open()) terminal.loadAddon(fitAddon) @@ -70,11 +73,10 @@ export function openTerminal(pane: ManagedPaneInternal): void { activateOrcaTerminalUnicodeProvider(terminal) // Why: the OS reads the focused textarea's screen rect at compositionstart to - // decide where to display the IME candidate window. xterm.js only repositions - // the textarea on compositionupdate (via updateCompositionElements), not on - // compositionstart, so the window can appear at a stale cursor position. We - // force-sync the textarea position in a capture-phase listener so the OS sees - // the correct location before it opens the candidate window. + // decide where to display the IME candidate window. xterm positions that + // textarea from its own cursor, which can be stale or intentionally hidden by + // TUIs. We force-sync after xterm's own composition handlers so the OS sees + // the corrected location before it opens the candidate window. // // Cell dimensions are derived from the public .xterm-screen element's bounds // (xterm sizes that element to cols*cellWidth × rows*cellHeight) rather than @@ -94,11 +96,34 @@ export function openTerminal(pane: ManagedPaneInternal): void { return } const buf = terminal.buffer.active - const x = Math.min(buf.cursorX, terminal.cols - 1) - textarea.style.top = `${buf.cursorY * cellHeight}px` - textarea.style.left = `${x * cellWidth}px` + // Why: Cursor Agent draws its prompt UI while leaving xterm's public cursor + // on a blank row, so the OS IME anchor needs the rendered prompt row instead. + const cursorAgentAnchor = resolveCursorAgentImeAnchor({ + buffer: buf, + rows: terminal.rows, + cols: terminal.cols, + cursorX: buf.cursorX, + cursorY: buf.cursorY + }) + const anchor = cursorAgentAnchor ?? { + row: buf.cursorY, + column: Math.min(buf.cursorX, terminal.cols - 1) + } + const applyAnchor = (): void => { + textarea.style.top = `${anchor.row * cellHeight}px` + textarea.style.left = `${anchor.column * cellWidth}px` + } + applyAnchor() + if (cursorAgentAnchor) { + window.setTimeout(() => { + if (textarea.isConnected) { + applyAnchor() + } + }, 0) + } } - terminal.element.addEventListener('compositionstart', handler, true) + terminal.element.addEventListener('compositionstart', handler) + terminal.element.addEventListener('compositionupdate', handler) // Store so disposePane() can remove it and avoid a memory leak. pane.compositionHandler = handler } @@ -200,7 +225,8 @@ export function disposePane( pane.terminalScrollIntentDisposable?.dispose() pane.terminalScrollIntentDisposable = null if (pane.compositionHandler) { - pane.terminal.element?.removeEventListener('compositionstart', pane.compositionHandler, true) + pane.terminal.element?.removeEventListener('compositionstart', pane.compositionHandler) + pane.terminal.element?.removeEventListener('compositionupdate', pane.compositionHandler) pane.compositionHandler = null } try { @@ -213,11 +239,7 @@ export function disposePane( } catch { /* ignore */ } - try { - pane.webglAddon?.dispose() - } catch { - /* ignore */ - } + disposeWebgl(pane) try { pane.searchAddon.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 e4e002a5351..1ab542bf787 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-types.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-types.ts @@ -27,8 +27,26 @@ export type PaneSpawnHints = { export type ClosedPaneInfo = { paneId: number leafId: TerminalLeafId + reason?: 'close' | 'detach' } +export type PaneExternalDropTarget = { + id: string + rect: DOMRect + overlayKind?: 'area' | 'insertion' +} + +export type PaneExternalDropResolver = (args: { + sourcePaneId: number + clientX: number + clientY: number +}) => PaneExternalDropTarget | null + +export type PaneExternalDropHandler = ( + sourcePaneId: number, + target: PaneExternalDropTarget +) => boolean + export type PaneManagerOptions = { onPaneCreated?: (pane: ManagedPane, spawnHints?: PaneSpawnHints) => void | Promise onPaneClosed?: (paneId: number, closedPane?: ClosedPaneInfo) => void @@ -37,6 +55,8 @@ export type PaneManagerOptions = { /** Why: Electron webviews can steal pointer streams from renderer-owned * pane drags unless callers temporarily put them in pointer passthrough. */ onPaneDragActiveChange?: (active: boolean) => void + resolveExternalPaneDropTarget?: PaneExternalDropResolver + onExternalPaneDrop?: PaneExternalDropHandler terminalOptions?: (paneId: number) => Partial terminalTuiScrollSensitivity?: () => number | undefined onLinkClick?: (event: MouseEvent | undefined, url: string) => void diff --git a/src/renderer/src/lib/pane-manager/pane-manager.ts b/src/renderer/src/lib/pane-manager/pane-manager.ts index 4824af7cf57..77ad5398890 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager.ts @@ -5,7 +5,10 @@ import type { ManagedPane, ManagedPaneInternal, PaneRenderingDiagnostics, - DropZone + DropZone, + PaneExternalDropHandler, + PaneExternalDropResolver, + PaneExternalDropTarget } from './pane-manager-types' import type { SplitPaneAroundLeafIdsOptions } from './pane-subtree-split' import { @@ -38,12 +41,25 @@ import { } from './pane-rendering-control' import type { TerminalLeafId } from '../../../../shared/stable-pane-id' import { registerLivePaneManager, unregisterLivePaneManager } from './pane-manager-registry' +import { schedulePaneRevealRepaint } from './pane-reveal-repaint' import { PaneIdentityRegistry } from './pane-identity-registry' -import { closeManagedPane, splitManagedPane } from './pane-split-close' +import { + closeManagedPane, + detachManagedPaneForExternalMove, + splitManagedPane +} from './pane-split-close' import { FIRST_PANE_ID } from '../../../../shared/pane-key' import { splitPaneAroundMountedSubtree } from './pane-subtree-split' -export type { PaneManagerOptions, PaneStyleOptions, ManagedPane, DropZone } +export type { + PaneManagerOptions, + PaneStyleOptions, + ManagedPane, + DropZone, + PaneExternalDropTarget, + PaneExternalDropResolver, + PaneExternalDropHandler +} export class PaneManager { private root: HTMLElement @@ -157,6 +173,22 @@ export class PaneManager { }) } + detachPaneForExternalMove(paneId: number): boolean { + return detachManagedPaneForExternalMove({ + paneId, + activePaneId: this.activePaneId, + panes: this.panes, + root: this.root, + styleOptions: this.styleOptions, + managerOptions: this.options, + getDragCallbacks: () => this.getDragCallbacks(), + releasePaneIdentity: (numericPaneId) => this.identities.release(numericPaneId), + setActivePaneId: (id) => { + this.activePaneId = id + } + }) + } + getPanes(): ManagedPane[] { return Array.from(this.panes.values()).map(toPublicPane) } @@ -290,6 +322,13 @@ export class PaneManager { resetPaneWebglTextureAtlases(this.panes.values()) } + scheduleRevealRepaint(): void { + // Why: the settled-frame callback can fire after destroy(); repainting + // disposed panes could throw in attach and latch the global WebGL + // attach backoff, downgrading unrelated new panes to the DOM renderer. + schedulePaneRevealRepaint(() => (this.destroyed ? [] : this.panes.values())) + } + suspendRendering(): void { this.renderingSuspended = true suspendPaneRendering(this.panes.values()) @@ -396,7 +435,9 @@ export class PaneManager { this.requestPaneReparentFrame(callback) }, onLayoutChanged: this.options.onLayoutChanged, - onDragActiveChange: this.options.onPaneDragActiveChange + onDragActiveChange: this.options.onPaneDragActiveChange, + resolveExternalDropTarget: this.options.resolveExternalPaneDropTarget, + onExternalPaneDrop: this.options.onExternalPaneDrop } } diff --git a/src/renderer/src/lib/pane-manager/pane-rendering-control.ts b/src/renderer/src/lib/pane-manager/pane-rendering-control.ts index 8f0da555597..8ad22f07eca 100644 --- a/src/renderer/src/lib/pane-manager/pane-rendering-control.ts +++ b/src/renderer/src/lib/pane-manager/pane-rendering-control.ts @@ -2,6 +2,7 @@ import type { ManagedPaneInternal } from './pane-manager-types' import { safeFit } from './pane-tree-ops' import { attachWebgl, + clearTerminalWebglAttachBackoff, disposeWebgl, markComplexScriptOutput, resetWebglTextureAtlas @@ -49,8 +50,13 @@ export function suspendPaneRendering(panes: Iterable): void } export function resumePaneRendering(panes: Iterable): void { + // Why: resume (worktree foreground, window wake) is the WebGL retry + // boundary — Chromium may have restored the GPU process since a context + // loss, and bounding retries to resume events cannot loop on live loss. + clearTerminalWebglAttachBackoff() for (const pane of panes) { pane.webglAttachmentDeferred = false + pane.webglDisabledAfterContextLoss = false reattachWebglIfNeeded(pane) } } diff --git a/src/renderer/src/lib/pane-manager/pane-reveal-repaint.test.ts b/src/renderer/src/lib/pane-manager/pane-reveal-repaint.test.ts new file mode 100644 index 00000000000..564a57e9875 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-reveal-repaint.test.ts @@ -0,0 +1,147 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ManagedPaneInternal } from './pane-manager-types' +import { schedulePaneRevealRepaint } from './pane-reveal-repaint' +import { resetTerminalWebglSuggestion } from './pane-webgl-renderer' + +type FakeWebglAddon = { clearTextureAtlas: ReturnType } + +function createPane(options: { webglAddon?: FakeWebglAddon | null } = {}): ManagedPaneInternal { + const leafId = '33333333-3333-4333-8333-333333333333' as never + return { + id: 1, + leafId, + stablePaneId: leafId, + terminal: { + cols: 80, + rows: 24, + refresh: vi.fn(), + loadAddon: vi.fn() + } as never, + container: {} as never, + xtermContainer: {} as never, + linkTooltip: {} as never, + terminalGpuAcceleration: 'on', + gpuRenderingEnabled: true, + webglAttachmentDeferred: false, + webglDisabledAfterContextLoss: false, + hasComplexScriptOutput: false, + webglAddon: (options.webglAddon ?? null) as never, + ligaturesAddon: null, + fitResizeObserver: null, + pendingObservedFitRafId: null, + pendingWebglRefreshRafId: null, + fitAddon: { + proposeDimensions: vi.fn(() => ({ cols: 80, rows: 23 })), + fit: vi.fn() + } as never, + searchAddon: {} as never, + serializeAddon: {} as never, + unicode11Addon: {} as never, + webLinksAddon: {} as never, + compositionHandler: null, + pendingSplitScrollState: null, + debugLabel: null + } +} + +describe('schedulePaneRevealRepaint', () => { + let rafQueue: FrameRequestCallback[] + + function flushFrame(): void { + const queue = rafQueue + rafQueue = [] + for (const callback of queue) { + callback(16) + } + } + + beforeEach(() => { + resetTerminalWebglSuggestion() + rafQueue = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + rafQueue.push(callback) + return rafQueue.length + }) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it('repaints only after the post-reveal frame has settled', () => { + const webglAddon = { clearTextureAtlas: vi.fn() } + const pane = createPane({ webglAddon }) + schedulePaneRevealRepaint(() => [pane]) + + // First frame: reveal layout may still be in flight; redraw requests fired + // here can be dropped by the renderer without retry. + flushFrame() + expect(webglAddon.clearTextureAtlas).not.toHaveBeenCalled() + expect(pane.terminal.refresh).not.toHaveBeenCalled() + + flushFrame() + expect(webglAddon.clearTextureAtlas).toHaveBeenCalledTimes(1) + expect(pane.terminal.refresh).toHaveBeenCalledWith(0, 23) + }) + + it('reattaches a missing WebGL addon before repainting', () => { + const pane = createPane() + schedulePaneRevealRepaint(() => [pane]) + + flushFrame() + flushFrame() + + expect(pane.webglAddon).not.toBeNull() + expect(pane.terminal.refresh).toHaveBeenCalled() + }) + + it('resolves the pane list at repaint time, not at scheduling time', () => { + const stalePane = createPane({ webglAddon: { clearTextureAtlas: vi.fn() } }) + const livePane = createPane({ webglAddon: { clearTextureAtlas: vi.fn() } }) + const panes = [stalePane] + schedulePaneRevealRepaint(() => panes) + panes.splice(0, panes.length, livePane) + + flushFrame() + flushFrame() + + expect( + (stalePane.webglAddon as never as FakeWebglAddon).clearTextureAtlas + ).not.toHaveBeenCalled() + expect( + (livePane.webglAddon as never as FakeWebglAddon).clearTextureAtlas + ).toHaveBeenCalledTimes(1) + }) + + it('keeps repainting remaining panes when one pane throws', () => { + const explosivePane = { + get gpuRenderingEnabled(): boolean { + throw new Error('pane torn down mid-frame') + } + } as never as ManagedPaneInternal + const webglAddon = { clearTextureAtlas: vi.fn() } + const livePane = createPane({ webglAddon }) + schedulePaneRevealRepaint(() => [explosivePane, livePane]) + + flushFrame() + flushFrame() + + expect(webglAddon.clearTextureAtlas).toHaveBeenCalledTimes(1) + expect(livePane.terminal.refresh).toHaveBeenCalled() + }) + + it('falls back to a timeout when animation frames are unavailable', () => { + vi.useFakeTimers() + vi.stubGlobal('requestAnimationFrame', undefined) + const webglAddon = { clearTextureAtlas: vi.fn() } + const pane = createPane({ webglAddon }) + + schedulePaneRevealRepaint(() => [pane]) + vi.runAllTimers() + + expect(webglAddon.clearTextureAtlas).toHaveBeenCalledTimes(1) + vi.useRealTimers() + }) +}) diff --git a/src/renderer/src/lib/pane-manager/pane-reveal-repaint.ts b/src/renderer/src/lib/pane-manager/pane-reveal-repaint.ts new file mode 100644 index 00000000000..48df07b9b0c --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-reveal-repaint.ts @@ -0,0 +1,39 @@ +import type { ManagedPaneInternal } from './pane-manager-types' +import { reattachWebglIfNeeded } from './pane-webgl-reattach' +import { resetWebglTextureAtlas } from './pane-webgl-renderer' + +function scheduleSettledFrame(callback: () => void): void { + if (typeof globalThis.requestAnimationFrame !== 'function') { + globalThis.setTimeout(callback, 0) + return + } + // Why: the first frame after a reveal can still be laying out the tab + // overlay; the WebGL renderer silently drops redraw requests until the pane + // is attached and measured, so repaint on the frame after layout settles. + globalThis.requestAnimationFrame(() => { + globalThis.requestAnimationFrame(callback) + }) +} + +/** + * Repaints a revealed tab's panes from their xterm buffers. + * + * Why: while a pane is hidden, parsed output can update the WebGL renderer's + * per-cell model without ever presenting a frame. At reveal the model diff + * reports those cells unchanged, so plain refreshes skip them and the canvas + * keeps compositing pre-hide pixels until a selection or resize rebuilds the + * model. Clearing the model per pane — after (re)attach, once layout has + * settled — forces a full rebuild from the buffer without any PTY resize. + */ +export function schedulePaneRevealRepaint(getPanes: () => Iterable): void { + scheduleSettledFrame(() => { + for (const pane of getPanes()) { + try { + reattachWebglIfNeeded(pane) + resetWebglTextureAtlas(pane) + } catch { + /* ignore — one pane's failure must not block repaint of the rest */ + } + } + }) +} diff --git a/src/renderer/src/lib/pane-manager/pane-split-close.ts b/src/renderer/src/lib/pane-manager/pane-split-close.ts index 2d7c50a62f0..4bc6018372e 100644 --- a/src/renderer/src/lib/pane-manager/pane-split-close.ts +++ b/src/renderer/src/lib/pane-manager/pane-split-close.ts @@ -166,7 +166,7 @@ type CloseManagedPaneArgs = { setActivePaneId: (paneId: number | null) => void } -export function closeManagedPane(args: CloseManagedPaneArgs): void { +function teardownManagedPane(args: CloseManagedPaneArgs, reason: 'close' | 'detach'): void { const pane = args.panes.get(args.paneId) if (!pane) { return @@ -180,10 +180,30 @@ export function closeManagedPane(args: CloseManagedPaneArgs): void { safeFit(p) } updateMultiPaneState(args.getDragCallbacks()) - args.managerOptions.onPaneClosed?.(args.paneId, { paneId: args.paneId, leafId: closedLeafId }) + args.managerOptions.onPaneClosed?.(args.paneId, { + paneId: args.paneId, + leafId: closedLeafId, + reason + }) args.managerOptions.onLayoutChanged?.() } +export function closeManagedPane(args: CloseManagedPaneArgs): void { + teardownManagedPane(args, 'close') +} + +export function detachManagedPaneForExternalMove(args: CloseManagedPaneArgs): boolean { + // Why: refuse to detach the last pane — there is no other pane to fall back to. + if (!args.panes.has(args.paneId) || args.panes.size <= 1) { + return false + } + // Why: pane-to-tab detach tears down only this renderer pane; the PTY is + // adopted by the new tab, so the 'detach' reason tells TerminalPane to skip + // process-close cleanup. + teardownManagedPane(args, 'detach') + return true +} + function removePaneContainer(args: CloseManagedPaneArgs, pane: ManagedPaneInternal): void { const paneContainer = pane.container const parent = paneContainer.parentElement diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-gpu-acceleration.test.ts b/src/renderer/src/lib/pane-manager/pane-terminal-gpu-acceleration.test.ts index 7f2c4145efe..1ac49093354 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-gpu-acceleration.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-gpu-acceleration.test.ts @@ -77,6 +77,30 @@ describe('applyTerminalGpuAcceleration', () => { expect(pane.fitAddon.fit).not.toHaveBeenCalled() }) + it('clears context-loss latches when the acceleration mode changes', () => { + vi.stubGlobal('navigator', { + platform: 'MacIntel', + userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)' + }) + const pane = createPane() + pane.webglDisabledAfterContextLoss = true + const options: PaneManagerOptions = { terminalGpuAcceleration: 'auto' } + + applyTerminalGpuAcceleration([pane], options, 'on') + + expect(pane.webglDisabledAfterContextLoss).toBe(false) + }) + + it('keeps context-loss latches when the acceleration mode is unchanged', () => { + const pane = createPane() + pane.webglDisabledAfterContextLoss = true + const options: PaneManagerOptions = { terminalGpuAcceleration: 'on' } + + applyTerminalGpuAcceleration([pane], options, 'on') + + expect(pane.webglDisabledAfterContextLoss).toBe(true) + }) + it('returns Linux panes to DOM when switching from forced WebGL back to auto without a safe renderer', () => { vi.stubGlobal('navigator', { platform: 'Linux x86_64', diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-gpu-acceleration.ts b/src/renderer/src/lib/pane-manager/pane-terminal-gpu-acceleration.ts index be53b77e1f6..655f9447ebc 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-gpu-acceleration.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-gpu-acceleration.ts @@ -14,12 +14,18 @@ export function applyTerminalGpuAcceleration( ): void { const nextMode = mode ?? 'auto' const previousMode = options.terminalGpuAcceleration ?? 'auto' + const modeChanged = previousMode !== nextMode options.terminalGpuAcceleration = nextMode - if (previousMode !== nextMode) { + if (modeChanged) { resetTerminalWebglSuggestion() } for (const pane of panes) { pane.terminalGpuAcceleration = nextMode + if (modeChanged) { + // Why: an explicit setting change is user intent to re-evaluate the + // renderer; context-loss latches from the old mode should not pin DOM. + pane.webglDisabledAfterContextLoss = false + } if (!shouldUseTerminalWebgl(pane)) { disposeWebgl(pane, { refreshDimensions: true }) continue diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.test.ts b/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.test.ts index 5ced0c616d0..05190661d5d 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.test.ts @@ -1,13 +1,61 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER, + attachTerminalMouseWheelMultiplier, + createTerminalTuiMouseWheelDistanceState, normalizeTerminalTuiMouseWheelMultiplier, + resolveTerminalTuiMouseWheelReportCount, shouldMultiplyTerminalMouseWheel } from './pane-terminal-mouse-wheel' const DOM_DELTA_PIXEL = 0 const DOM_DELTA_LINE = 1 +class TestWheelEvent extends Event { + static readonly DOM_DELTA_PIXEL = DOM_DELTA_PIXEL + static readonly DOM_DELTA_LINE = DOM_DELTA_LINE + static readonly DOM_DELTA_PAGE = 2 + + readonly altKey: boolean + readonly button: number + readonly buttons: number + readonly clientX: number + readonly clientY: number + readonly ctrlKey: boolean + readonly deltaMode: number + readonly deltaX: number + readonly deltaY: number + readonly deltaZ: number + readonly detail: number + readonly metaKey: boolean + readonly relatedTarget: EventTarget | null + readonly screenX: number + readonly screenY: number + readonly shiftKey: boolean + readonly view: Window | null + + constructor(type: string, init: WheelEventInit = {}) { + super(type, init) + this.altKey = init.altKey ?? false + this.button = init.button ?? 0 + this.buttons = init.buttons ?? 0 + this.clientX = init.clientX ?? 0 + this.clientY = init.clientY ?? 0 + this.ctrlKey = init.ctrlKey ?? false + this.deltaMode = init.deltaMode ?? DOM_DELTA_PIXEL + this.deltaX = init.deltaX ?? 0 + this.deltaY = init.deltaY ?? 0 + this.deltaZ = init.deltaZ ?? 0 + this.detail = init.detail ?? 0 + this.metaKey = init.metaKey ?? false + this.relatedTarget = init.relatedTarget ?? null + this.screenX = init.screenX ?? 0 + this.screenY = init.screenY ?? 0 + this.shiftKey = init.shiftKey ?? false + this.view = init.view ?? null + } +} + function terminalElement(mouseReporting = true): HTMLElement { return { classList: { @@ -16,7 +64,9 @@ function terminalElement(mouseReporting = true): HTMLElement { } as HTMLElement } -function wheelEvent(init: Partial = {}): WheelEvent { +function wheelEvent( + init: Partial & { wheelDelta?: number; wheelDeltaY?: number } = {} +): WheelEvent { return { deltaY: 100, deltaMode: DOM_DELTA_PIXEL, @@ -25,17 +75,252 @@ function wheelEvent(init: Partial = {}): WheelEvent { } describe('terminal mouse wheel multiplier', () => { - it('uses a three-report multiplier for TUI mouse wheel scrolling', () => { - expect(TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER).toBe(3) + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('uses a one-report multiplier for TUI mouse wheel scrolling', () => { + expect(TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER).toBe(1) }) it('normalizes TUI wheel multipliers to the supported report range', () => { - expect(normalizeTerminalTuiMouseWheelMultiplier(undefined)).toBe(3) + expect(normalizeTerminalTuiMouseWheelMultiplier(undefined)).toBe(1) expect(normalizeTerminalTuiMouseWheelMultiplier(0)).toBe(1) expect(normalizeTerminalTuiMouseWheelMultiplier(4.4)).toBe(4) expect(normalizeTerminalTuiMouseWheelMultiplier(20)).toBe(10) }) + it('keeps deliberate TUI wheel ticks precise at the one-report setting', () => { + const state = createTerminalTuiMouseWheelDistanceState() + + const reports = [0, 200, 400, 600].map(() => + resolveTerminalTuiMouseWheelReportCount( + { deltaY: 12, deltaMode: DOM_DELTA_PIXEL, wheelDeltaY: -120 }, + 1, + state, + { cellHeight: 16 } + ) + ) + + expect(reports).toEqual([1, 1, 1, 1]) + }) + + it('scales notched TUI wheel ticks by the configured multiplier', () => { + const state = createTerminalTuiMouseWheelDistanceState() + + const reports = [0, 50, 100, 150].map(() => + resolveTerminalTuiMouseWheelReportCount( + { deltaY: 12, deltaMode: DOM_DELTA_PIXEL, wheelDeltaY: -120 }, + 5, + state, + { cellHeight: 16 } + ) + ) + + expect(reports).toEqual([5, 5, 5, 5]) + }) + + it('keeps paced 1x TUI wheel ticks precise', () => { + const state = createTerminalTuiMouseWheelDistanceState() + + const reports = [0, 80, 160, 240].map((timeStamp) => + resolveTerminalTuiMouseWheelReportCount( + { deltaY: 12, deltaMode: DOM_DELTA_PIXEL, wheelDeltaY: -120, timeStamp }, + 1, + state, + { cellHeight: 16 } + ) + ) + + expect(reports).toEqual([1, 1, 1, 1]) + }) + + it('adds a burst boost for very fast 1x TUI wheel scrolling', () => { + const state = createTerminalTuiMouseWheelDistanceState() + + const reports = [0, 16, 32, 48, 64].map((timeStamp) => + resolveTerminalTuiMouseWheelReportCount( + { deltaY: 12, deltaMode: DOM_DELTA_PIXEL, wheelDeltaY: -120, timeStamp }, + 1, + state, + { cellHeight: 16 } + ) + ) + + expect(reports).toEqual([1, 1, 3, 3, 4]) + }) + + it('uses a hotter compressed wheel distance curve for larger TUI wheel movements', () => { + const state = createTerminalTuiMouseWheelDistanceState() + + const reports = [ + resolveTerminalTuiMouseWheelReportCount( + { deltaY: 16, deltaMode: DOM_DELTA_PIXEL, wheelDeltaY: -120 }, + 1, + state, + { cellHeight: 16 } + ), + resolveTerminalTuiMouseWheelReportCount( + { deltaY: 16 * 12, deltaMode: DOM_DELTA_PIXEL, wheelDeltaY: -120 * 12 }, + 1, + state, + { cellHeight: 16 } + ) + ] + + expect(reports).toEqual([1, 6]) + }) + + it('caps a single accelerated TUI wheel event before it becomes a huge jump', () => { + const state = createTerminalTuiMouseWheelDistanceState() + + const reports = resolveTerminalTuiMouseWheelReportCount( + { deltaY: 16 * 200, deltaMode: DOM_DELTA_PIXEL, wheelDeltaY: -120 * 200 }, + 1, + state, + { cellHeight: 16 } + ) + + expect(reports).toBe(6) + }) + + it('lets aggressive repeated TUI wheel events exceed the single-event cap', () => { + const state = createTerminalTuiMouseWheelDistanceState() + + const reports = [0, 16, 32, 48, 64].map((timeStamp) => + resolveTerminalTuiMouseWheelReportCount( + { + deltaY: 16 * 200, + deltaMode: DOM_DELTA_PIXEL, + timeStamp, + wheelDeltaY: -120 * 200 + }, + 1, + state, + { cellHeight: 16 } + ) + ) + + expect(reports).toEqual([6, 6, 8, 8, 9]) + }) + + it('does not carry burst boost into a decaying momentum tail', () => { + const state = createTerminalTuiMouseWheelDistanceState() + + const reports = [ + [200, 0], + [200, 16], + [200, 32], + [80, 48], + [20, 64], + [5, 80] + ].map(([rows, timeStamp]) => + resolveTerminalTuiMouseWheelReportCount( + { + deltaY: 16 * rows, + deltaMode: DOM_DELTA_PIXEL, + timeStamp, + wheelDeltaY: -120 * rows + }, + 1, + state, + { cellHeight: 16 } + ) + ) + + expect(reports).toEqual([6, 6, 8, 6, 6, 4]) + }) + + it('retains fractional trackpad distance until it reaches a full row', () => { + const state = createTerminalTuiMouseWheelDistanceState() + + const reports = [4, 4, 4, 4].map((deltaY) => + resolveTerminalTuiMouseWheelReportCount({ deltaY, deltaMode: DOM_DELTA_PIXEL }, 1, state, { + cellHeight: 16 + }) + ) + + expect(reports).toEqual([0, 0, 0, 1]) + }) + + it('does not burst-boost rapid trackpad-like pixel deltas', () => { + const state = createTerminalTuiMouseWheelDistanceState() + + const reports = [0, 16, 32, 48].map((timeStamp) => + resolveTerminalTuiMouseWheelReportCount( + { deltaY: 4, deltaMode: DOM_DELTA_PIXEL, timeStamp }, + 1, + state, + { cellHeight: 16 } + ) + ) + + expect(reports).toEqual([0, 0, 0, 1]) + }) + + it('tracks a decaying trackpad-like momentum tail with linear row distance', () => { + const state = createTerminalTuiMouseWheelDistanceState() + + const reports = [16, 20, 24, 28, 20, 12, 6, 3].map((deltaY, index) => + resolveTerminalTuiMouseWheelReportCount( + { deltaY, deltaMode: DOM_DELTA_PIXEL, timeStamp: index * 16 }, + 1, + state, + { cellHeight: 16 } + ) + ) + + expect(reports).toEqual([1, 1, 1, 2, 1, 1, 0, 1]) + }) + + it('drops pending trackpad distance on each direction change', () => { + const state = createTerminalTuiMouseWheelDistanceState() + + const reports = [16, 20, 24, -16, -20, -24, -18, -10, 6, -4].map((deltaY, index) => + resolveTerminalTuiMouseWheelReportCount( + { deltaY, deltaMode: DOM_DELTA_PIXEL, timeStamp: index * 16 }, + 1, + state, + { cellHeight: 16 } + ) + ) + + expect(reports).toEqual([1, 1, 1, 1, 1, 1, 1, 1, 0, 0]) + }) + + it('emits the full linear distance for a fast trackpad-like flick event', () => { + const state = createTerminalTuiMouseWheelDistanceState() + + const reports = [16 * 12, 16 * 12, 16 * 12].map((deltaY, index) => + resolveTerminalTuiMouseWheelReportCount( + { deltaY, deltaMode: DOM_DELTA_PIXEL, timeStamp: index * 16 }, + 1, + state, + { cellHeight: 16 } + ) + ) + + expect(reports).toEqual([12, 12, 12]) + }) + + it('resets pending fractional distance when the user changes direction', () => { + const state = createTerminalTuiMouseWheelDistanceState() + + const reports = [ + resolveTerminalTuiMouseWheelReportCount({ deltaY: 4, deltaMode: DOM_DELTA_PIXEL }, 1, state, { + cellHeight: 16 + }), + resolveTerminalTuiMouseWheelReportCount( + { deltaY: -12, deltaMode: DOM_DELTA_PIXEL }, + 1, + state, + { cellHeight: 16 } + ) + ] + + expect(reports).toEqual([0, 0]) + }) + it('multiplies discrete wheel events when mouse reporting is active', () => { expect(shouldMultiplyTerminalMouseWheel(wheelEvent(), terminalElement())).toBe(true) }) @@ -44,7 +329,7 @@ describe('terminal mouse wheel multiplier', () => { expect(shouldMultiplyTerminalMouseWheel(wheelEvent(), terminalElement(false))).toBe(false) }) - it('leaves trackpad-like pixel scrolling one-to-one', () => { + it('handles trackpad-like TUI pixel scrolling while mouse reporting is active', () => { expect( shouldMultiplyTerminalMouseWheel( wheelEvent({ @@ -53,7 +338,20 @@ describe('terminal mouse wheel multiplier', () => { }), terminalElement() ) - ).toBe(false) + ).toBe(true) + }) + + it('multiplies notched mouse wheel ticks even when Chromium exposes a small pixel delta', () => { + expect( + shouldMultiplyTerminalMouseWheel( + wheelEvent({ + deltaY: 12, + deltaMode: DOM_DELTA_PIXEL, + wheelDeltaY: -120 + }), + terminalElement() + ) + ).toBe(true) }) it('multiplies non-pixel wheel deltas as discrete input', () => { @@ -78,4 +376,191 @@ describe('terminal mouse wheel multiplier', () => { ) ).toBe(false) }) + + it('replays discrete TUI wheel ticks as line-mode reports', async () => { + vi.stubGlobal('WheelEvent', TestWheelEvent) + const handlers: ((event: WheelEvent) => boolean)[] = [] + const target = Object.assign(new EventTarget(), { + classList: { + contains: (className: string) => className === 'enable-mouse-events' + } + }) as unknown as EventTarget & HTMLElement + const dispatched: WheelEvent[] = [] + target.addEventListener('wheel', (event) => dispatched.push(event as WheelEvent)) + attachTerminalMouseWheelMultiplier( + { + attachCustomWheelEventHandler: (handler) => { + handlers.push(handler) + }, + element: target, + rows: 24 + }, + { getTuiMouseWheelMultiplier: () => 1 } + ) + const event = new TestWheelEvent('wheel', { + bubbles: true, + cancelable: true, + deltaMode: DOM_DELTA_PIXEL, + deltaY: 12 + }) as WheelEvent + Object.defineProperty(event, 'wheelDeltaY', { + configurable: true, + value: -120 + }) + + expect(handlers).toHaveLength(1) + expect(handlers[0]?.(event)).toBe(false) + await Promise.resolve() + + expect(dispatched).toHaveLength(1) + expect(dispatched.map((entry) => entry.deltaMode)).toEqual([DOM_DELTA_LINE]) + expect(dispatched.map((entry) => entry.deltaY)).toEqual([1]) + expect(shouldMultiplyTerminalMouseWheel(dispatched[0]!, target)).toBe(false) + }) + + it('replays trackpad-like TUI pixel scrolling with responsive direction reversal', async () => { + vi.stubGlobal('WheelEvent', TestWheelEvent) + const handlers: ((event: WheelEvent) => boolean)[] = [] + const target = Object.assign(new EventTarget(), { + classList: { + contains: (className: string) => className === 'enable-mouse-events' + } + }) as unknown as EventTarget & HTMLElement + const dispatched: WheelEvent[] = [] + target.addEventListener('wheel', (event) => dispatched.push(event as WheelEvent)) + attachTerminalMouseWheelMultiplier( + { + attachCustomWheelEventHandler: (handler) => { + handlers.push(handler) + }, + element: target, + rows: 24 + }, + { getTuiMouseWheelMultiplier: () => 1 } + ) + + const events = [ + [4, 0], + [4, 16], + [4, 32], + [4, 48], + [-4, 64], + [-4, 80], + [-4, 96], + [-4, 112] + ].map(([deltaY, timeStamp]) => { + const event = new TestWheelEvent('wheel', { + bubbles: true, + cancelable: true, + deltaMode: DOM_DELTA_PIXEL, + deltaY + }) as WheelEvent + Object.defineProperty(event, 'timeStamp', { + configurable: true, + value: timeStamp + }) + return event + }) + + for (const event of events) { + expect(handlers[0]?.(event)).toBe(false) + await Promise.resolve() + } + + expect(dispatched.map((entry) => entry.deltaY)).toEqual([1, -1]) + }) + + it('replays a fast trackpad-like flick as one full synthetic report batch', async () => { + vi.stubGlobal('WheelEvent', TestWheelEvent) + const handlers: ((event: WheelEvent) => boolean)[] = [] + const target = Object.assign(new EventTarget(), { + classList: { + contains: (className: string) => className === 'enable-mouse-events' + } + }) as unknown as EventTarget & HTMLElement + const dispatched: WheelEvent[] = [] + target.addEventListener('wheel', (event) => dispatched.push(event as WheelEvent)) + attachTerminalMouseWheelMultiplier( + { + attachCustomWheelEventHandler: (handler) => { + handlers.push(handler) + }, + element: target, + rows: 24 + }, + { getTuiMouseWheelMultiplier: () => 1 } + ) + + const event = new TestWheelEvent('wheel', { + bubbles: true, + cancelable: true, + deltaMode: DOM_DELTA_PIXEL, + deltaY: 16 * 12 + }) as WheelEvent + + expect(handlers[0]?.(event)).toBe(false) + await Promise.resolve() + + expect(dispatched.map((entry) => entry.deltaY)).toEqual(Array(12).fill(1)) + }) + + it('drains resolved TUI wheel reports without a frame-rate cap', async () => { + vi.stubGlobal('WheelEvent', TestWheelEvent) + const handlers: ((event: WheelEvent) => boolean)[] = [] + const target = Object.assign(new EventTarget(), { + classList: { + contains: (className: string) => className === 'enable-mouse-events' + } + }) as unknown as EventTarget & HTMLElement + const dispatched: WheelEvent[] = [] + target.addEventListener('wheel', (event) => dispatched.push(event as WheelEvent)) + attachTerminalMouseWheelMultiplier( + { + attachCustomWheelEventHandler: (handler) => { + handlers.push(handler) + }, + element: target, + rows: 24 + }, + { + getTuiMouseWheelMultiplier: () => 3 + } + ) + const firstEvent = new TestWheelEvent('wheel', { + bubbles: true, + cancelable: true, + deltaMode: DOM_DELTA_PIXEL, + deltaY: 12 + }) as WheelEvent + Object.defineProperty(firstEvent, 'wheelDeltaY', { + configurable: true, + value: -120 + }) + Object.defineProperty(firstEvent, 'timeStamp', { + configurable: true, + value: 0 + }) + const secondEvent = new TestWheelEvent('wheel', { + bubbles: true, + cancelable: true, + deltaMode: DOM_DELTA_PIXEL, + deltaY: 12 + }) as WheelEvent + Object.defineProperty(secondEvent, 'wheelDeltaY', { + configurable: true, + value: -120 + }) + Object.defineProperty(secondEvent, 'timeStamp', { + configurable: true, + value: 50 + }) + + expect(handlers[0]?.(firstEvent)).toBe(false) + await Promise.resolve() + expect(dispatched).toHaveLength(3) + + expect(handlers[0]?.(secondEvent)).toBe(false) + await Promise.resolve() + expect(dispatched).toHaveLength(6) + }) }) diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.ts b/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.ts index 6e3ca37e996..f35c31efba7 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.ts @@ -1,14 +1,27 @@ import type { Terminal } from '@xterm/xterm' +import { + createTerminalTuiMouseWheelDistanceState, + normalizeTerminalTuiMouseWheelMultiplier, + resolveTerminalTuiMouseWheelReportCount, + resolveTerminalWheelDirection +} from './pane-terminal-tui-wheel-reports' +import type { TerminalTuiMouseWheelDistanceState } from './pane-terminal-tui-wheel-reports' + +export { + TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER, + TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER_MAX, + TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER_MIN, + createTerminalTuiMouseWheelDistanceState, + normalizeTerminalTuiMouseWheelMultiplier, + resolveTerminalTuiMouseWheelReportCount +} from './pane-terminal-tui-wheel-reports' +export type { TerminalTuiMouseWheelDistanceState } from './pane-terminal-tui-wheel-reports' const XTERM_MOUSE_REPORTING_CLASS = 'enable-mouse-events' const REPLAYED_WHEEL_EVENT_PROPERTY = '__orcaReplayedTerminalWheelEvent' -const DOM_DELTA_PIXEL = 0 +const DOM_DELTA_LINE = 1 -export const TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER = 3 -export const TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER_MIN = 1 -export const TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER_MAX = 10 - -type TerminalWheelTarget = Pick +type TerminalWheelTarget = Pick type TerminalMouseWheelMultiplierOptions = { getTuiMouseWheelMultiplier?: () => number | undefined @@ -18,6 +31,26 @@ type ReplayedWheelEvent = WheelEvent & { [REPLAYED_WHEEL_EVENT_PROPERTY]?: boolean } +type TerminalTuiMouseWheelReplayState = { + distance: TerminalTuiMouseWheelDistanceState + drainScheduled: boolean + pendingDirection: -1 | 0 | 1 + pendingEvent: WheelEvent | null + pendingReports: number + pendingTarget: EventTarget | null +} + +function createTerminalTuiMouseWheelReplayState(): TerminalTuiMouseWheelReplayState { + return { + distance: createTerminalTuiMouseWheelDistanceState(), + drainScheduled: false, + pendingDirection: 0, + pendingEvent: null, + pendingReports: 0, + pendingTarget: null + } +} + function isReplayedWheelEvent(event: WheelEvent): boolean { return (event as ReplayedWheelEvent)[REPLAYED_WHEEL_EVENT_PROPERTY] === true } @@ -29,32 +62,7 @@ function markReplayedWheelEvent(event: WheelEvent): void { }) } -function isDiscreteWheelEvent(event: WheelEvent): boolean { - if (event.deltaMode !== DOM_DELTA_PIXEL) { - return true - } - - return Math.abs(event.deltaY) >= 50 -} - -export function shouldMultiplyTerminalMouseWheel( - event: WheelEvent, - terminalElement: HTMLElement | null | undefined -): boolean { - if ( - isReplayedWheelEvent(event) || - !terminalElement?.classList.contains(XTERM_MOUSE_REPORTING_CLASS) || - event.deltaY === 0 || - event.shiftKey || - !isDiscreteWheelEvent(event) - ) { - return false - } - - return true -} - -function cloneWheelEvent(event: WheelEvent): WheelEvent { +function cloneWheelReportEvent(event: WheelEvent): WheelEvent { const clone = new WheelEvent(event.type, { bubbles: event.bubbles, cancelable: event.cancelable, @@ -72,31 +80,99 @@ function cloneWheelEvent(event: WheelEvent): WheelEvent { button: event.button, buttons: event.buttons, relatedTarget: event.relatedTarget, - deltaX: event.deltaX, - deltaY: event.deltaY, - deltaZ: event.deltaZ, - deltaMode: event.deltaMode + deltaX: 0, + deltaY: event.deltaY < 0 ? -1 : 1, + deltaZ: 0, + deltaMode: DOM_DELTA_LINE }) markReplayedWheelEvent(clone) return clone } -export function normalizeTerminalTuiMouseWheelMultiplier(value: number | undefined): number { - if (typeof value !== 'number' || !Number.isFinite(value)) { - return TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER +function resolveTerminalWheelCellHeight(terminal: TerminalWheelTarget): number | undefined { + if (typeof terminal.element?.querySelector !== 'function') { + return undefined } - return Math.round( - Math.min( - TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER_MAX, - Math.max(TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER_MIN, value) - ) - ) + const screen = terminal.element?.querySelector('.xterm-screen') + const rect = screen?.getBoundingClientRect() + if (!rect || rect.height <= 0 || terminal.rows <= 0) { + return undefined + } + return rect.height / terminal.rows +} + +export function shouldMultiplyTerminalMouseWheel( + event: WheelEvent, + terminalElement: HTMLElement | null | undefined +): boolean { + if ( + isReplayedWheelEvent(event) || + !terminalElement?.classList.contains(XTERM_MOUSE_REPORTING_CLASS) || + event.deltaY === 0 || + event.shiftKey + ) { + return false + } + + return true +} + +function drainTerminalTuiWheelReports(state: TerminalTuiMouseWheelReplayState): void { + const target = state.pendingTarget + const event = state.pendingEvent + if (!target || !event || state.pendingReports <= 0) { + state.drainScheduled = false + return + } + + const reportsToDispatch = state.pendingReports + for (let i = 0; i < reportsToDispatch; i += 1) { + target.dispatchEvent(cloneWheelReportEvent(event)) + } + state.pendingReports = 0 + state.drainScheduled = false + state.pendingDirection = 0 + state.pendingEvent = null + state.pendingTarget = null +} + +function queueTerminalTuiWheelReports( + state: TerminalTuiMouseWheelReplayState, + target: EventTarget, + event: WheelEvent, + reportCount: number +): void { + if (reportCount <= 0) { + return + } + + const direction = resolveTerminalWheelDirection(event) + if (state.pendingDirection !== 0 && state.pendingDirection !== direction) { + state.pendingReports = 0 + } + + state.pendingDirection = direction + state.pendingEvent = event + state.pendingTarget = target + state.pendingReports += reportCount + + if (state.drainScheduled) { + return + } + + state.drainScheduled = true + // Why: dispatch after xterm returns from the original wheel handler, but do + // not frame-cap reports; fullscreen TUIs need the full wheel distance. + queueMicrotask(() => { + drainTerminalTuiWheelReports(state) + }) } export function attachTerminalMouseWheelMultiplier( terminal: TerminalWheelTarget, options: TerminalMouseWheelMultiplierOptions = {} ): void { + const replayState = createTerminalTuiMouseWheelReplayState() terminal.attachCustomWheelEventHandler((event) => { if (!shouldMultiplyTerminalMouseWheel(event, terminal.element)) { return true @@ -108,17 +184,19 @@ export function attachTerminalMouseWheelMultiplier( return true } - // Why: mouse-reporting TUIs receive wheel input as reports, not viewport - // scrollback, so normal xterm scrollSensitivity cannot tune their speed. - queueMicrotask(() => { - const multiplier = normalizeTerminalTuiMouseWheelMultiplier( - options.getTuiMouseWheelMultiplier?.() - ) - for (let i = 1; i < multiplier; i++) { - target.dispatchEvent(cloneWheelEvent(event)) + // Why: xterm dampens small pixel deltas before emitting mouse reports; + // line-mode replays let fullscreen TUIs receive one report per resolved row. + const reportCount = resolveTerminalTuiMouseWheelReportCount( + event, + normalizeTerminalTuiMouseWheelMultiplier(options.getTuiMouseWheelMultiplier?.()), + replayState.distance, + { + cellHeight: resolveTerminalWheelCellHeight(terminal), + rows: terminal.rows } - }) + ) + queueTerminalTuiWheelReports(replayState, target, event, reportCount) - return true + return false }) } diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-options.ts b/src/renderer/src/lib/pane-manager/pane-terminal-options.ts index 89bbaf4b118..911d3fa7545 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-options.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-options.ts @@ -59,7 +59,7 @@ export function buildDefaultTerminalOptions(): ITerminalOptions { // this as a gutter, costing ~1 column per pane — accepted tradeoff so the // scrollbar never covers content (evidence in PR #5051). The v1.4.51 // table corruption #4877 fixed by zeroing this was actually the ZWJ - // width bug; it stays fixed by pane-terminal-unicode-provider.ts. Width + // width bug; it stays fixed by shared/terminal-unicode-provider.ts. Width // also enables the overview ruler, whose border is hidden in // composeActiveTerminalTheme. width: 7 diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts index 0312419a560..bca02b5fcc5 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts @@ -69,26 +69,26 @@ describe('pane terminal output scheduler', () => { expect(terminal.write).toHaveBeenCalledWith('foreground', expect.any(Function)) }) - it('runs foreground parsed callbacks after immediate foreground output parses', async () => { + it('runs parsed callbacks after immediate foreground output parses', async () => { const { writeTerminalOutput } = await loadScheduler() const terminal = createTerminal() let parseCallback: (() => void) | undefined terminal.write.mockImplementation((_data: string, callback?: () => void) => { parseCallback = callback }) - const onForegroundParsed = vi.fn() + const onParsed = vi.fn() writeTerminalOutput(terminal, 'foreground', { foreground: true, - onForegroundParsed + onParsed }) - expect(onForegroundParsed).not.toHaveBeenCalled() + expect(onParsed).not.toHaveBeenCalled() parseCallback?.() - expect(onForegroundParsed).toHaveBeenCalledTimes(1) + expect(onParsed).toHaveBeenCalledTimes(1) }) - it('runs foreground parsed callbacks after queued foreground output parses', async () => { + it('runs parsed callbacks after queued foreground output parses', async () => { vi.useFakeTimers() const { writeTerminalOutput } = await loadScheduler() const terminal = createTerminal() @@ -96,20 +96,20 @@ describe('pane terminal output scheduler', () => { terminal.write.mockImplementation((_data: string, callback?: () => void) => { parseCallback = callback }) - const onForegroundParsed = vi.fn() + const onParsed = vi.fn() writeTerminalOutput(terminal, 'queued', { foreground: true, latencySensitive: false, - onForegroundParsed + onParsed }) vi.advanceTimersByTime(0) expect(terminal.write).toHaveBeenCalledWith('queued', expect.any(Function)) - expect(onForegroundParsed).not.toHaveBeenCalled() + expect(onParsed).not.toHaveBeenCalled() parseCallback?.() - expect(onForegroundParsed).toHaveBeenCalledTimes(1) + expect(onParsed).toHaveBeenCalledTimes(1) }) it('synchronously refreshes visible rows after foreground output parses', async () => { @@ -244,6 +244,71 @@ describe('pane terminal output scheduler', () => { expect(terminal.write).toHaveBeenCalledWith('ab') }) + it('runs parsed callbacks after background output parses without foreground refresh', async () => { + vi.useFakeTimers() + const { writeTerminalOutput } = await loadScheduler() + const terminal = createForegroundTerminal() + const writes: string[] = [] + const parseCallbacks: (() => void)[] = [] + terminal.write = function write(data: string, callback?: () => void): void { + writes.push(data) + if (callback) { + parseCallbacks.push(callback) + } + } as typeof terminal.write + const onParsed = vi.fn() + + writeTerminalOutput(terminal, 'hidden redraw', { + foreground: false, + forceForegroundRefresh: true, + followupForegroundRefresh: true, + onParsed + }) + + vi.advanceTimersByTime(50) + + expect(writes).toEqual(['hidden redraw']) + expect(onParsed).not.toHaveBeenCalled() + expect(terminal._core.refresh).not.toHaveBeenCalled() + + parseCallbacks[0]?.() + + expect(onParsed).toHaveBeenCalledTimes(1) + expect(terminal._core.refresh).not.toHaveBeenCalled() + }) + + it('keeps parsed callbacks on large background chunks split by the scheduler', async () => { + vi.useFakeTimers() + const { writeTerminalOutput } = await loadScheduler() + const terminal = createTerminal() + const writes: string[] = [] + const parseCallbacks: (() => void)[] = [] + terminal.write = function write(data: string, callback?: () => void): void { + writes.push(data) + if (callback) { + parseCallbacks.push(callback) + } + } as typeof terminal.write + const onParsed = vi.fn() + + writeTerminalOutput(terminal, 'x'.repeat(20 * 1024), { + foreground: false, + onParsed + }) + + vi.advanceTimersByTime(50) + + expect(writes.map((data) => data.length)).toEqual([16 * 1024, 4 * 1024]) + expect(parseCallbacks).toHaveLength(2) + expect(onParsed).not.toHaveBeenCalled() + + parseCallbacks[0]?.() + + expect(onParsed).toHaveBeenCalledTimes(1) + parseCallbacks[1]?.() + expect(onParsed).toHaveBeenCalledTimes(2) + }) + it('defers throughput foreground output to the shared high-priority drain', async () => { vi.useFakeTimers() const { writeTerminalOutput } = await loadScheduler() @@ -833,10 +898,66 @@ describe('pane terminal output scheduler', () => { expect(terminal.write).not.toHaveBeenCalled() vi.advanceTimersByTime(0) - expect(terminal.write).toHaveBeenCalledTimes(16) + expect(terminal.write).toHaveBeenCalledTimes(2) - vi.advanceTimersByTime(1) - expect(terminal.write).toHaveBeenCalledTimes(32) + vi.advanceTimersByTime(4) + expect(terminal.write).toHaveBeenCalledTimes(4) + }) + + it('yields high-priority backlog drains when writes spend the frame budget', async () => { + vi.useFakeTimers() + const { writeTerminalOutput } = await loadScheduler() + const terminal = createTerminal() + const chunk = 'x'.repeat(16 * 1024) + let now = 0 + const nowSpy = vi.spyOn(performance, 'now').mockImplementation(() => now) + terminal.write.mockImplementation((_data: string, callback?: () => void) => { + now += 9 + callback?.() + }) + + try { + for (let i = 0; i < 64; i++) { + writeTerminalOutput(terminal, chunk, { foreground: false }) + } + + vi.advanceTimersByTime(0) + expect(terminal.write).toHaveBeenCalledTimes(1) + + vi.advanceTimersByTime(4) + expect(terminal.write).toHaveBeenCalledTimes(2) + } finally { + nowSpy.mockRestore() + } + }) + + it('uses Date.now for drain budgeting when performance is unavailable', async () => { + vi.useFakeTimers() + vi.stubGlobal('performance', undefined) + const { writeTerminalOutput } = await loadScheduler() + const terminal = createTerminal() + const chunk = 'x'.repeat(16 * 1024) + let now = 0 + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => now) + terminal.write.mockImplementation((_data: string, callback?: () => void) => { + now += 9 + callback?.() + }) + + try { + for (let i = 0; i < 64; i++) { + writeTerminalOutput(terminal, chunk, { foreground: false }) + } + + vi.advanceTimersByTime(0) + expect(terminal.write).toHaveBeenCalledTimes(1) + expect(nowSpy).toHaveBeenCalled() + + vi.advanceTimersByTime(4) + expect(terminal.write).toHaveBeenCalledTimes(2) + } finally { + nowSpy.mockRestore() + } }) it('caps hidden backlog memory and writes a warning instead of retaining all output', async () => { diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts index e56df22c0df..20a3b8138ca 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts @@ -21,7 +21,7 @@ type TerminalOutputParsedCallback = () => void type WriteTerminalOutputOptions = { foreground: boolean beforeWrite?: TerminalOutputBeforeWrite - onForegroundParsed?: TerminalOutputParsedCallback + onParsed?: TerminalOutputParsedCallback onBackgroundBacklogDropped?: () => void latencySensitive?: boolean forceForegroundRefresh?: boolean @@ -37,7 +37,7 @@ type QueueChunk = { forceForegroundRefresh: boolean followupForegroundRefresh: boolean stripTransientCursorShows: boolean - onForegroundParsed?: TerminalOutputParsedCallback + onParsed?: TerminalOutputParsedCallback } type QueuedWrite = { @@ -46,7 +46,7 @@ type QueuedWrite = { forceForegroundRefresh: boolean followupForegroundRefresh: boolean stripTransientCursorShows: boolean - onForegroundParsed?: TerminalOutputParsedCallback + onParsed?: TerminalOutputParsedCallback } type QueueEntry = { @@ -68,10 +68,11 @@ type QueueEntry = { const BACKGROUND_FLUSH_DELAY_MS = 50 const BACKGROUND_DRAIN_INTERVAL_MS = 16 -const HIGH_PRIORITY_DRAIN_INTERVAL_MS = 1 +const HIGH_PRIORITY_DRAIN_INTERVAL_MS = 4 const BACKGROUND_CHUNK_CHARS = 16 * 1024 const MAX_WRITES_PER_DRAIN = 2 -const HIGH_PRIORITY_MAX_WRITES_PER_DRAIN = 16 +const HIGH_PRIORITY_MAX_WRITES_PER_DRAIN = 2 +const DRAIN_TIME_BUDGET_MS = 8 const LARGE_BACKLOG_CHARS = 512 * 1024 const SYNC_FOREGROUND_FLUSH_CHARS = 256 * 1024 const MAX_BACKGROUND_QUEUE_CHARS = 2 * 1024 * 1024 @@ -499,13 +500,16 @@ function takeQueuedChunk(entry: QueueEntry, limit: number): QueuedWrite | null { remaining -= chunk.data.length entry.queuedChars -= chunk.data.length entry.chunkIndex += 1 - if (chunk.foreground && chunk.onForegroundParsed) { - parsedCallbacks.push(chunk.onForegroundParsed) + if (chunk.onParsed) { + parsedCallbacks.push(chunk.onParsed) } continue } data += chunk.data.slice(0, remaining) + if (chunk.onParsed) { + parsedCallbacks.push(chunk.onParsed) + } entry.chunks[entry.chunkIndex] = { ...chunk, data: chunk.data.slice(remaining) @@ -526,7 +530,7 @@ function takeQueuedChunk(entry: QueueEntry, limit: number): QueuedWrite | null { forceForegroundRefresh, followupForegroundRefresh, stripTransientCursorShows, - onForegroundParsed: + onParsed: parsedCallbacks.length > 0 ? () => { for (const callback of parsedCallbacks) { @@ -561,7 +565,7 @@ function enqueueChunk( forceForegroundRefresh?: boolean followupForegroundRefresh?: boolean stripTransientCursorShows?: boolean - onForegroundParsed?: TerminalOutputParsedCallback + onParsed?: TerminalOutputParsedCallback } ): void { entry.chunks.push({ @@ -570,7 +574,7 @@ function enqueueChunk( forceForegroundRefresh: options?.forceForegroundRefresh === true, followupForegroundRefresh: options?.followupForegroundRefresh === true, stripTransientCursorShows: options?.stripTransientCursorShows === true, - onForegroundParsed: options?.onForegroundParsed + onParsed: options?.onParsed }) entry.queuedChars += data.length recordQueueDebugPressure() @@ -628,19 +632,30 @@ function hasDrainableBacklog(): boolean { return false } -function writeBackgroundTerminalChunk(terminal: TerminalOutputTarget, data: string): void { +function writeBackgroundTerminalChunk( + terminal: TerminalOutputTarget, + data: string, + onParsed?: TerminalOutputParsedCallback +): void { const scrollIntent = captureTerminalWriteScrollIntent(terminal) if (!scrollIntent) { - terminal.write(data) + if (!onParsed || terminal.write.length < 2) { + terminal.write(data) + onParsed?.() + return + } + terminal.write(data, onParsed) return } if (terminal.write.length < 2) { terminal.write(data) enforceTerminalWriteScrollIntent(terminal, scrollIntent) + onParsed?.() return } terminal.write(data, () => { enforceTerminalWriteScrollIntent(terminal, scrollIntent) + onParsed?.() }) } @@ -693,11 +708,11 @@ function writeQueuedChunk(entry: QueueEntry): 'foreground' | 'background' | null { forceViewportRefresh: queuedWrite.forceForegroundRefresh, followupViewportRefresh: queuedWrite.followupForegroundRefresh, - onParsed: queuedWrite.onForegroundParsed + onParsed: queuedWrite.onParsed } ) } else { - writeBackgroundTerminalChunk(entry.terminal, queuedWrite.data) + writeBackgroundTerminalChunk(entry.terminal, queuedWrite.data, queuedWrite.onParsed) } } catch { // Why: pane.terminal.dispose() can race with a queued late-arriving PTY ping; @@ -714,10 +729,18 @@ function writeQueuedChunk(entry: QueueEntry): 'foreground' | 'background' | null return queuedWrite.foreground ? 'foreground' : 'background' } +function getDrainNow(): number { + if (typeof performance !== 'undefined') { + return performance.now() + } + return Date.now() +} + function drainQueuedOutput(): void { drainTimer = null drainTimerDelayMs = null let writes = 0 + const startedAt = getDrainNow() const maxWrites = hasHighPriorityBacklog() ? HIGH_PRIORITY_MAX_WRITES_PER_DRAIN : MAX_WRITES_PER_DRAIN @@ -746,6 +769,11 @@ function drainQueuedOutput(): void { clearForegroundCoalesce(entry) clearForegroundHoldSafety(entry) } + // Why: xterm parsing and DOM work share the renderer thread with input. + // Keep backlog draining cooperative so WSL/agent output cannot pin the UI. + if (writes > 0 && getDrainNow() - startedAt >= DRAIN_TIME_BUDGET_MS) { + break + } } if (debugEnabled && writes > 0) { @@ -782,7 +810,7 @@ export function writeTerminalOutput( forceForegroundRefresh: options.forceForegroundRefresh, followupForegroundRefresh: options.followupForegroundRefresh, stripTransientCursorShows: options.stripTransientCursorShows, - onForegroundParsed: options.onForegroundParsed + onParsed: options.onParsed }) if (debugEnabled) { debugState.foregroundWriteCount++ @@ -849,7 +877,7 @@ export function writeTerminalOutput( forceForegroundRefresh: options.forceForegroundRefresh, followupForegroundRefresh: options.followupForegroundRefresh, stripTransientCursorShows: options.stripTransientCursorShows, - onForegroundParsed: options.onForegroundParsed + onParsed: options.onParsed }) if (debugEnabled) { debugState.foregroundWriteCount++ @@ -876,7 +904,7 @@ export function writeTerminalOutput( forceForegroundRefresh: options.forceForegroundRefresh, followupForegroundRefresh: options.followupForegroundRefresh, stripTransientCursorShows: options.stripTransientCursorShows, - onForegroundParsed: options.onForegroundParsed + onParsed: options.onParsed }) if (debugEnabled) { debugState.foregroundWriteCount++ @@ -899,7 +927,7 @@ export function writeTerminalOutput( { forceViewportRefresh: options.forceForegroundRefresh === true, followupViewportRefresh: options.followupForegroundRefresh === true, - onParsed: options.onForegroundParsed + onParsed: options.onParsed } ) return @@ -914,7 +942,9 @@ export function writeTerminalOutput( entry.beforeWrite = options.beforeWrite entry.onBackgroundBacklogDropped = options.onBackgroundBacklogDropped } - enqueueChunk(entry, data) + enqueueChunk(entry, data, { + onParsed: options.onParsed + }) if ( entry.queuedChars > MAX_BACKGROUND_QUEUE_CHARS || entry.chunks.length - entry.chunkIndex > MAX_BACKGROUND_QUEUE_CHUNKS @@ -975,11 +1005,11 @@ export function flushTerminalOutput( { forceViewportRefresh: queuedWrite.forceForegroundRefresh, followupViewportRefresh: queuedWrite.followupForegroundRefresh, - onParsed: queuedWrite.onForegroundParsed + onParsed: queuedWrite.onParsed } ) } else { - writeBackgroundTerminalChunk(terminal, queuedWrite.data) + writeBackgroundTerminalChunk(terminal, queuedWrite.data, queuedWrite.onParsed) } } catch { // Why: pane.terminal.dispose() can race with a queued late-arriving PTY ping; diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-tui-wheel-reports.ts b/src/renderer/src/lib/pane-manager/pane-terminal-tui-wheel-reports.ts new file mode 100644 index 00000000000..8736dc6504e --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-terminal-tui-wheel-reports.ts @@ -0,0 +1,256 @@ +const DOM_DELTA_PIXEL = 0 +const DOM_DELTA_LINE = 1 +const DOM_DELTA_PAGE = 2 +const DISCRETE_PIXEL_WHEEL_DELTA_MIN = 50 +const LEGACY_MOUSE_WHEEL_DELTA_MIN = 100 +const LEGACY_MOUSE_WHEEL_DELTA_UNIT = 120 +const DEFAULT_TERMINAL_CELL_HEIGHT = 16 +const TUI_WHEEL_ACCELERATED_DISTANCE_GAIN = 1.6 +const TUI_WHEEL_BURST_FULL_INTERVAL_MS = 16 +const TUI_WHEEL_BURST_MAX_INTERVAL_MS = 45 +const TUI_WHEEL_BURST_MAX_BONUS_ROWS = 3 +const TUI_WHEEL_BURST_RAMP_EVENTS = 4 +const TUI_WHEEL_MOMENTUM_TAIL_DECAY_RATIO = 0.85 +const TUI_WHEEL_COMPRESSED_MAX_DISTANCE_ROWS_PER_EVENT = 6 +const TUI_WHEEL_BURST_MAX_DISTANCE_ROWS_PER_EVENT = 9 + +export const TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER = 1 +export const TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER_MIN = 1 +export const TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER_MAX = 10 + +type WheelEventWithLegacyDelta = WheelEvent & { + wheelDelta?: number + wheelDeltaY?: number +} + +type TerminalTuiWheelEventInput = Pick & + Partial> & { + wheelDelta?: number + wheelDeltaY?: number + } + +type TerminalTuiMouseWheelMetrics = { + cellHeight?: number + rows?: number +} + +export type TerminalTuiMouseWheelDistanceState = { + fastStreak: number + lastDistanceRows: number | null + lastInputAt: number | null + pendingDirection: -1 | 0 | 1 + pendingRows: number +} + +export function createTerminalTuiMouseWheelDistanceState(): TerminalTuiMouseWheelDistanceState { + return { + fastStreak: 0, + lastDistanceRows: null, + lastInputAt: null, + pendingDirection: 0, + pendingRows: 0 + } +} + +export function resolveTerminalWheelDirection(event: Pick): -1 | 1 { + return event.deltaY < 0 ? -1 : 1 +} + +function legacyVerticalWheelDelta(event: TerminalTuiWheelEventInput): number | null { + const wheelEvent = event as WheelEventWithLegacyDelta + if (typeof wheelEvent.wheelDeltaY === 'number' && Number.isFinite(wheelEvent.wheelDeltaY)) { + return wheelEvent.wheelDeltaY + } + if (typeof wheelEvent.wheelDelta === 'number' && Number.isFinite(wheelEvent.wheelDelta)) { + return wheelEvent.wheelDelta + } + return null +} + +function hasDiscreteLegacyWheelDelta(event: TerminalTuiWheelEventInput): boolean { + const legacyDelta = legacyVerticalWheelDelta(event) + return legacyDelta !== null && Math.abs(legacyDelta) >= LEGACY_MOUSE_WHEEL_DELTA_MIN +} + +export function isDiscreteTerminalTuiWheelEvent(event: TerminalTuiWheelEventInput): boolean { + if ((event.deltaMode ?? DOM_DELTA_PIXEL) !== DOM_DELTA_PIXEL) { + return true + } + + if (Math.abs(event.deltaY) >= DISCRETE_PIXEL_WHEEL_DELTA_MIN) { + return true + } + + return hasDiscreteLegacyWheelDelta(event) +} + +function canBurstBoostWheelEvent(event: TerminalTuiWheelEventInput): boolean { + if ((event.deltaMode ?? DOM_DELTA_PIXEL) !== DOM_DELTA_PIXEL) { + return true + } + + return hasDiscreteLegacyWheelDelta(event) +} + +function isTrackpadLikePixelWheelEvent(event: TerminalTuiWheelEventInput): boolean { + return ( + (event.deltaMode ?? DOM_DELTA_PIXEL) === DOM_DELTA_PIXEL && !hasDiscreteLegacyWheelDelta(event) + ) +} + +function wheelInputTime(event: TerminalTuiWheelEventInput): number | null { + if (typeof event.timeStamp === 'number' && Number.isFinite(event.timeStamp)) { + return event.timeStamp + } + return null +} + +function normalizeCellHeight(cellHeight: number | undefined): number { + if (typeof cellHeight === 'number' && Number.isFinite(cellHeight) && cellHeight > 0) { + return cellHeight + } + return DEFAULT_TERMINAL_CELL_HEIGHT +} + +function resolveWheelDistanceRows( + event: TerminalTuiWheelEventInput, + metrics: TerminalTuiMouseWheelMetrics +): number { + const deltaMode = event.deltaMode ?? DOM_DELTA_PIXEL + const deltaY = Math.abs(event.deltaY) + const rowsFromDelta = + deltaMode === DOM_DELTA_LINE + ? deltaY + : deltaMode === DOM_DELTA_PAGE + ? deltaY * Math.max(1, metrics.rows ?? 1) + : deltaY / normalizeCellHeight(metrics.cellHeight) + const legacyDelta = legacyVerticalWheelDelta(event) + const rowsFromLegacy = + legacyDelta === null ? 0 : Math.abs(legacyDelta) / LEGACY_MOUSE_WHEEL_DELTA_UNIT + const rows = Math.max(rowsFromDelta, rowsFromLegacy) + + return isDiscreteTerminalTuiWheelEvent(event) ? Math.max(1, rows) : rows +} + +function compressWheelDistanceRows(rows: number): number { + if (rows <= 1) { + return rows + } + + return Math.min( + TUI_WHEEL_COMPRESSED_MAX_DISTANCE_ROWS_PER_EVENT, + 1 + Math.log2(rows) * TUI_WHEEL_ACCELERATED_DISTANCE_GAIN + ) +} + +function resolveBurstWheelDistanceRows( + event: TerminalTuiWheelEventInput, + state: TerminalTuiMouseWheelDistanceState, + distanceRows: number +): number { + if (!canBurstBoostWheelEvent(event)) { + state.fastStreak = 0 + state.lastDistanceRows = null + state.lastInputAt = null + return 0 + } + + const currentInputAt = wheelInputTime(event) + if (currentInputAt === null) { + state.fastStreak = 0 + state.lastDistanceRows = null + state.lastInputAt = null + return 0 + } + + const elapsedMs = state.lastInputAt === null ? null : currentInputAt - state.lastInputAt + const isMomentumTail = + state.lastDistanceRows !== null && + distanceRows < state.lastDistanceRows * TUI_WHEEL_MOMENTUM_TAIL_DECAY_RATIO + state.lastDistanceRows = distanceRows + state.lastInputAt = currentInputAt + + if ( + isMomentumTail || + elapsedMs === null || + elapsedMs < 0 || + elapsedMs > TUI_WHEEL_BURST_MAX_INTERVAL_MS + ) { + state.fastStreak = 0 + return 0 + } + + const cadence = + elapsedMs <= TUI_WHEEL_BURST_FULL_INTERVAL_MS + ? 1 + : (TUI_WHEEL_BURST_MAX_INTERVAL_MS - elapsedMs) / + (TUI_WHEEL_BURST_MAX_INTERVAL_MS - TUI_WHEEL_BURST_FULL_INTERVAL_MS) + state.fastStreak = Math.min(TUI_WHEEL_BURST_RAMP_EVENTS, state.fastStreak + 1) + + return TUI_WHEEL_BURST_MAX_BONUS_ROWS * cadence * (state.fastStreak / TUI_WHEEL_BURST_RAMP_EVENTS) +} + +function resolveTrackpadPixelWheelReportCount( + event: TerminalTuiWheelEventInput, + state: TerminalTuiMouseWheelDistanceState, + distanceRows: number +): number | null { + if (!isTrackpadLikePixelWheelEvent(event)) { + return null + } + + // Why: trackpad pixel streams map 1:1 to physical distance: one report per + // terminal row scrolled, fractional remainder carried. No per-event cap and + // no momentum-tail suppression: the input write queue batches whatever a + // busy frame accumulates into a single PTY write, so the TUI applies it at + // once instead of replaying it, and inertial scrolling stays real-time. + const totalRows = state.pendingRows + distanceRows + const reports = Math.trunc(totalRows) + state.pendingRows = totalRows - reports + return reports +} + +export function normalizeTerminalTuiMouseWheelMultiplier(value: number | undefined): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER + } + return Math.round( + Math.min( + TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER_MAX, + Math.max(TERMINAL_TUI_MOUSE_WHEEL_MULTIPLIER_MIN, value) + ) + ) +} + +export function resolveTerminalTuiMouseWheelReportCount( + event: TerminalTuiWheelEventInput, + multiplier: number, + state: TerminalTuiMouseWheelDistanceState, + metrics: TerminalTuiMouseWheelMetrics = {} +): number { + const direction = resolveTerminalWheelDirection(event) + if (state.pendingDirection !== 0 && state.pendingDirection !== direction) { + state.fastStreak = 0 + state.lastDistanceRows = null + state.lastInputAt = null + state.pendingRows = 0 + } + state.pendingDirection = direction + + const distanceRows = resolveWheelDistanceRows(event, metrics) + const trackpadReportCount = resolveTrackpadPixelWheelReportCount(event, state, distanceRows) + if (trackpadReportCount !== null) { + return trackpadReportCount + } + + const rows = + Math.min( + TUI_WHEEL_BURST_MAX_DISTANCE_ROWS_PER_EVENT, + compressWheelDistanceRows(distanceRows) + + resolveBurstWheelDistanceRows(event, state, distanceRows) + ) * normalizeTerminalTuiMouseWheelMultiplier(multiplier) + const totalRows = state.pendingRows + rows + const reports = Math.trunc(totalRows) + state.pendingRows = totalRows - reports + return reports +} diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts b/src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts new file mode 100644 index 00000000000..3f16c655f1a --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ManagedPaneInternal } from './pane-manager-types' +import { resumePaneRendering } from './pane-rendering-control' +import { attachWebgl, resetTerminalWebglSuggestion } from './pane-webgl-renderer' + +function createPane(options: { loadAddon?: () => void } = {}): ManagedPaneInternal { + const leafId = '11111111-1111-4111-8111-111111111111' as never + return { + id: 1, + leafId, + stablePaneId: leafId, + terminal: { + cols: 80, + rows: 24, + refresh: vi.fn(), + loadAddon: vi.fn(options.loadAddon) + } as never, + container: {} as never, + xtermContainer: {} as never, + linkTooltip: {} as never, + terminalGpuAcceleration: 'on', + gpuRenderingEnabled: true, + webglAttachmentDeferred: false, + webglDisabledAfterContextLoss: false, + hasComplexScriptOutput: false, + webglAddon: null, + ligaturesAddon: null, + fitResizeObserver: null, + pendingObservedFitRafId: null, + pendingWebglRefreshRafId: null, + fitAddon: { + proposeDimensions: vi.fn(() => ({ cols: 80, rows: 23 })), + fit: vi.fn() + } as never, + searchAddon: {} as never, + serializeAddon: {} as never, + unicode11Addon: {} as never, + webLinksAddon: {} as never, + compositionHandler: null, + pendingSplitScrollState: null, + debugLabel: null + } +} + +function throwWebglUnavailable(): never { + // Mirrors the addon's activate() throw when getContext('webgl2') returns null. + throw new Error('WebGL2 not supported null') +} + +function fireContextLoss(pane: ManagedPaneInternal): void { + // Fire the addon's real context-loss emitter so the loss -> latch -> resume + // cycle runs through the production onContextLoss handler. + const addon = pane.webglAddon as unknown as { _onContextLoss: { fire: () => void } } + addon._onContextLoss.fire() +} + +describe('terminal WebGL context recovery', () => { + beforeEach(() => { + resetTerminalWebglSuggestion() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + callback(16) + return 1 + }) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it('backs off after a failed attach instead of retrying on every call', () => { + const pane = createPane({ loadAddon: throwWebglUnavailable }) + + attachWebgl(pane) + expect(pane.terminal.loadAddon).toHaveBeenCalledTimes(1) + expect(pane.webglAddon).toBeNull() + + // Title changes re-enter attach via setPaneGpuRendering; while WebGL is + // blocked these must not construct new addons or log again. + attachWebgl(pane) + attachWebgl(pane) + expect(pane.terminal.loadAddon).toHaveBeenCalledTimes(1) + expect(console.warn).toHaveBeenCalledTimes(1) + }) + + it('retries a backed-off attach on the next rendering resume', () => { + const pane = createPane({ loadAddon: throwWebglUnavailable }) + + attachWebgl(pane) + attachWebgl(pane) + expect(pane.terminal.loadAddon).toHaveBeenCalledTimes(1) + + resumePaneRendering([pane]) + expect(pane.terminal.loadAddon).toHaveBeenCalledTimes(2) + }) + + it('recovers a context-lost pane on the next rendering resume', () => { + const pane = createPane() + + attachWebgl(pane) + expect(pane.webglAddon).not.toBeNull() + + fireContextLoss(pane) + expect(pane.webglDisabledAfterContextLoss).toBe(true) + expect(pane.webglAddon).toBeNull() + + resumePaneRendering([pane]) + expect(pane.webglDisabledAfterContextLoss).toBe(false) + expect(pane.webglAddon).not.toBeNull() + }) + + it('re-latches when the retried context is lost again', () => { + const pane = createPane() + + attachWebgl(pane) + fireContextLoss(pane) + resumePaneRendering([pane]) + expect(pane.webglAddon).not.toBeNull() + + fireContextLoss(pane) + expect(pane.webglDisabledAfterContextLoss).toBe(true) + expect(pane.webglAddon).toBeNull() + }) +}) diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-reattach.ts b/src/renderer/src/lib/pane-manager/pane-webgl-reattach.ts index a975a66f588..82f8c5d2224 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-reattach.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-reattach.ts @@ -1,5 +1,5 @@ import type { ManagedPaneInternal } from './pane-manager-types' -import { attachWebgl, disposeWebgl } from './pane-webgl-renderer' +import { attachWebgl, clearTerminalWebglAttachBackoff, disposeWebgl } from './pane-webgl-renderer' export function reattachWebglIfNeeded(pane: ManagedPaneInternal): void { if (pane.gpuRenderingEnabled && !pane.webglAddon && !pane.webglDisabledAfterContextLoss) { @@ -12,5 +12,8 @@ export function rebuildAttachedWebgl(pane: ManagedPaneInternal): void { return } disposeWebgl(pane) + // Why: the live addon just proved context creation works, so a stale attach + // backoff from an earlier failure must not downgrade this pane to DOM. + clearTerminalWebglAttachBackoff() attachWebgl(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 6cff7287564..91b95f2caf2 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 @@ -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 { suspendPaneRendering } from './pane-rendering-control' import { disposeWebgl } from './pane-webgl-renderer' function createPane( @@ -65,6 +66,41 @@ describe('pane WebGL refresh lifecycle', () => { expect(pane.pendingWebglRefreshRafId).toBe(29) }) + it('actively releases the xterm WebGL context before disposing the addon', () => { + const loseContext = vi.fn() + const canvas = { width: 120, height: 40 } + const dispose = vi.fn() + const pane = createPane({ + webglAddon: { + dispose, + _renderer: { + _gl: { + getExtension: vi.fn(() => ({ loseContext })) + }, + _canvas: canvas + } + } as never + }) + + disposeWebgl(pane) + + expect(loseContext).toHaveBeenCalledTimes(1) + expect(dispose).toHaveBeenCalledTimes(1) + expect(canvas).toEqual({ width: 0, height: 0 }) + expect(pane.webglAddon).toBeNull() + }) + + it('disposes WebGL when rendering is suspended', () => { + const dispose = vi.fn() + const pane = createPane({ webglAddon: { dispose } as never }) + + suspendPaneRendering([pane]) + + expect(pane.webglAttachmentDeferred).toBe(true) + expect(dispose).toHaveBeenCalledTimes(1) + expect(pane.webglAddon).toBeNull() + }) + it('cancels a pending WebGL refresh when the pane is disposed', () => { const cancelAnimationFrame = vi.fn() vi.stubGlobal('cancelAnimationFrame', cancelAnimationFrame) diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts new file mode 100644 index 00000000000..26919ed4d2d --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { WebglAddon } from '@xterm/addon-webgl' +import type { ManagedPaneInternal } from './pane-manager-types' +import { + attachWebgl, + resetTerminalWebglSuggestion, + resetWebglTextureAtlas +} from './pane-webgl-renderer' + +function createPane(options: { loadAddon?: () => void } = {}): ManagedPaneInternal { + const leafId = '22222222-2222-4222-8222-222222222222' as never + return { + id: 1, + leafId, + stablePaneId: leafId, + terminal: { + cols: 80, + rows: 24, + refresh: vi.fn(), + loadAddon: vi.fn(options.loadAddon) + } as never, + container: {} as never, + xtermContainer: {} as never, + linkTooltip: {} as never, + terminalGpuAcceleration: 'on', + gpuRenderingEnabled: true, + webglAttachmentDeferred: false, + webglDisabledAfterContextLoss: false, + hasComplexScriptOutput: false, + webglAddon: null, + ligaturesAddon: null, + fitResizeObserver: null, + pendingObservedFitRafId: null, + pendingWebglRefreshRafId: null, + fitAddon: { + proposeDimensions: vi.fn(() => ({ cols: 80, rows: 23 })), + fit: vi.fn() + } as never, + searchAddon: {} as never, + serializeAddon: {} as never, + unicode11Addon: {} as never, + webLinksAddon: {} as never, + compositionHandler: null, + pendingSplitScrollState: null, + debugLabel: null + } +} + +describe('terminal WebGL addon lifecycle', () => { + beforeEach(() => { + resetTerminalWebglSuggestion() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + callback(16) + return 1 + }) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it('disposes a live addon when attach bails instead of orphaning it', () => { + const pane = createPane() + attachWebgl(pane) + const liveAddon = pane.webglAddon + expect(liveAddon).not.toBeNull() + const disposeSpy = vi.spyOn(liveAddon as WebglAddon, 'dispose') + + // An undisposed addon here kept painting stale frames while atlas resets, + // reattach checks, and diagnostics all treated the pane as DOM-rendered. + pane.webglAttachmentDeferred = true + attachWebgl(pane) + + expect(disposeSpy).toHaveBeenCalledTimes(1) + expect(pane.webglAddon).toBeNull() + }) + + it('disposes the previous addon before attaching a replacement', () => { + const pane = createPane() + attachWebgl(pane) + const firstAddon = pane.webglAddon + expect(firstAddon).not.toBeNull() + const disposeSpy = vi.spyOn(firstAddon as WebglAddon, 'dispose') + + attachWebgl(pane) + + expect(disposeSpy).toHaveBeenCalledTimes(1) + expect(pane.webglAddon).not.toBeNull() + expect(pane.webglAddon).not.toBe(firstAddon) + }) + + it('disposes the constructed addon when loading it fails', () => { + const disposeSpy = vi.spyOn(WebglAddon.prototype, 'dispose') + const pane = createPane({ + loadAddon: () => { + throw new Error('WebGL2 not supported null') + } + }) + + attachWebgl(pane) + + expect(pane.webglAddon).toBeNull() + expect(disposeSpy).toHaveBeenCalledTimes(1) + }) + + it('still refreshes the terminal when resetting a pane without a WebGL addon', () => { + const pane = createPane() + expect(pane.webglAddon).toBeNull() + + resetWebglTextureAtlas(pane) + + expect(pane.terminal.refresh).toHaveBeenCalledWith(0, 23) + }) + + it('skips the reset while WebGL is latched off after a context loss', () => { + const pane = createPane() + pane.webglDisabledAfterContextLoss = true + + resetWebglTextureAtlas(pane) + + expect(pane.terminal.refresh).not.toHaveBeenCalled() + }) +}) 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 e68cdafcffa..d7c80e76220 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts @@ -7,14 +7,36 @@ import { export const ENABLE_WEBGL_RENDERER = true let suggestedRendererType: 'dom' | undefined +// Why: while Chromium refuses WebGL context creation (GPU process crashed or +// WebGL blocked after repeated resets), every attach attempt burns a canvas + +// failed getContext and logs a full-stack warning — and title changes retrigger +// attach constantly in "on" mode. Latch the first failure and skip attempts +// until the next recovery boundary (rendering resume or GPU-setting change). +let webglAttachFailedSinceRecovery = false + +type ReleasableWebglContext = { + getExtension(name: 'WEBGL_lose_context'): WEBGL_lose_context | null +} + +type XtermWebglAddonInternals = { + _renderer?: { + _gl?: ReleasableWebglContext + _canvas?: HTMLCanvasElement + } +} export function resetTerminalWebglSuggestion(): void { // Why: toggling GPU settings should let "auto" retry WebGL after an earlier // attach failure suggested DOM rendering for this app session. suggestedRendererType = undefined + webglAttachFailedSinceRecovery = false resetTerminalWebglAutoDecision() } +export function clearTerminalWebglAttachBackoff(): void { + webglAttachFailedSinceRecovery = false +} + export function shouldUseTerminalWebgl(pane: ManagedPaneInternal): boolean { if (pane.terminalGpuAcceleration === 'on') { return true @@ -36,10 +58,13 @@ function refreshTerminalAfterWebglAttach(pane: ManagedPaneInternal): void { } export function cancelPendingWebglRefresh(pane: ManagedPaneInternal): void { - if (pane.pendingWebglRefreshRafId != null) { - cancelAnimationFrame(pane.pendingWebglRefreshRafId) - pane.pendingWebglRefreshRafId = null + if (pane.pendingWebglRefreshRafId == null) { + return } + if (typeof globalThis.cancelAnimationFrame === 'function') { + globalThis.cancelAnimationFrame(pane.pendingWebglRefreshRafId) + } + pane.pendingWebglRefreshRafId = null } export function disposeWebgl( @@ -50,6 +75,7 @@ export function disposeWebgl( if (!pane.webglAddon) { return } + releaseXtermWebglContext(pane.webglAddon) try { pane.webglAddon.dispose() } catch { @@ -71,19 +97,37 @@ export function disposeWebgl( } } +function releaseXtermWebglContext(webglAddon: ManagedPaneInternal['webglAddon']): void { + try { + // Why: xterm removes the canvas on dispose, but Windows/ANGLE can keep the + // driver context alive long enough for rapid terminal activation to hit + // Chromium's active WebGL context budget (#6874). + const renderer = (webglAddon as unknown as XtermWebglAddonInternals | null)?._renderer + renderer?._gl?.getExtension('WEBGL_lose_context')?.loseContext() + if (renderer?._canvas) { + renderer._canvas.width = 0 + renderer._canvas.height = 0 + } + } catch { + /* ignore - WebGL teardown must not block fallback to the DOM renderer */ + } +} + export function markComplexScriptOutput(pane: ManagedPaneInternal): void { pane.hasComplexScriptOutput = true } export function resetWebglTextureAtlas(pane: ManagedPaneInternal): void { - if (!pane.webglAddon || pane.webglDisabledAfterContextLoss) { + if (pane.webglDisabledAfterContextLoss) { return } try { // Why: rapid TUI redraws can corrupt xterm's WebGL glyph atlas without a // context-loss event. Clearing the atlas preserves GPU rendering and forces // a fresh paint when the pane becomes visible/focused again. - pane.webglAddon.clearTextureAtlas() + pane.webglAddon?.clearTextureAtlas() + // Why: refresh even without a WebGL addon so recovery never silently + // no-ops — a DOM-rendered pane can hold stale pixels after reveal too. pane.terminal.refresh(0, pane.terminal.rows - 1) } catch { /* ignore — pane may have been disposed in the meantime */ @@ -96,14 +140,23 @@ export function attachWebgl(pane: ManagedPaneInternal): void { !pane.gpuRenderingEnabled || !shouldUseTerminalWebgl(pane) || pane.webglAttachmentDeferred || - pane.webglDisabledAfterContextLoss + pane.webglDisabledAfterContextLoss || + webglAttachFailedSinceRecovery ) { - pane.webglAddon = null + // Why: nulling the reference here used to leak a still-loaded addon that + // kept painting stale frames while every recovery path (atlas reset, + // reattach, diagnostics) treated the pane as DOM-rendered. Dispose so the + // pane genuinely falls back to the DOM renderer. + disposeWebgl(pane, { refreshDimensions: true }) return } + // Single-addon invariant: never stack a second addon on a live one. + disposeWebgl(pane) + let webglAddon: WebglAddon | null = null try { - const webglAddon = new WebglAddon() - webglAddon.onContextLoss(() => { + webglAddon = new WebglAddon() + const addon = webglAddon + addon.onContextLoss(() => { console.warn( '[terminal] WebGL context lost for pane', pane.id, @@ -111,12 +164,13 @@ export function attachWebgl(pane: ManagedPaneInternal): void { ) // Why: Chromium starts reclaiming terminal contexts under pressure. // Recreating WebGL for this pane can loop context loss and leave xterm - // visually blank, so keep the pane on the DOM renderer until remount. + // visually blank, so keep the pane on the DOM renderer until the next + // rendering resume (worktree foreground / window wake) retries it. pane.webglDisabledAfterContextLoss = true disposeWebgl(pane, { refreshDimensions: true }) }) - pane.terminal.loadAddon(webglAddon) - pane.webglAddon = webglAddon + pane.terminal.loadAddon(addon) + pane.webglAddon = addon refreshTerminalAfterWebglAttach(pane) } catch (err) { if (pane.terminalGpuAcceleration === 'auto') { @@ -124,8 +178,14 @@ export function attachWebgl(pane: ManagedPaneInternal): void { // enough signal to keep new auto panes on DOM until the setting changes. suggestedRendererType = 'dom' } + webglAttachFailedSinceRecovery = true // WebGL not available — default DOM renderer is fine, but log it for debugging console.warn('[terminal] WebGL unavailable for pane', pane.id, '— using DOM renderer:', err) + try { + webglAddon?.dispose() + } catch { + /* ignore — a half-constructed addon may throw on dispose */ + } pane.webglAddon = null } } diff --git a/src/renderer/src/lib/pane-manager/terminal-ime-anchor.test.ts b/src/renderer/src/lib/pane-manager/terminal-ime-anchor.test.ts new file mode 100644 index 00000000000..19de8afef2c --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-ime-anchor.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from 'vitest' +import type { IBuffer, IBufferCell, IBufferLine } from '@xterm/xterm' +import { resolveCursorAgentImeAnchor } from './terminal-ime-anchor' + +type FakeCell = { + chars: string + width: number +} + +function makeCell(cell: FakeCell): IBufferCell { + return { + getWidth: () => cell.width, + getChars: () => cell.chars + } as IBufferCell +} + +function makeLine(text: string, cols = 80): IBufferLine { + const cells: FakeCell[] = [] + for (const char of Array.from(text)) { + const width = char === '你' ? 2 : 1 + cells.push({ chars: char, width }) + if (width === 2) { + cells.push({ chars: '', width: 0 }) + } + } + while (cells.length < cols) { + cells.push({ chars: '', width: 1 }) + } + + return { + isWrapped: false, + length: cells.length, + getCell: (column: number) => { + const cell = cells[column] + return cell ? makeCell(cell) : undefined + }, + translateToString: (trimRight = false, startColumn = 0, endColumn = cells.length) => { + let result = '' + for (let column = startColumn; column < endColumn; column++) { + const cell = cells[column] + if (!cell || cell.width === 0) { + continue + } + result += cell.chars || ' ' + } + return trimRight ? result.replace(/\s+$/, '') : result + } + } as IBufferLine +} + +function makeBuffer(lines: string[], cols = 80): IBuffer { + const bufferLines = lines.map((line) => makeLine(line, cols)) + return { + baseY: 0, + cursorX: 0, + cursorY: 0, + length: bufferLines.length, + getLine: (row: number) => bufferLines[row], + getNullCell: () => makeCell({ chars: '', width: 1 }) + } as IBuffer +} + +describe('resolveCursorAgentImeAnchor', () => { + it('anchors an empty Cursor Agent prompt at the visible prompt caret', () => { + const buffer = makeBuffer([ + '', + ' Cursor Agent', + ' v2026.06.29-2ad2186', + ' Tip: Use /config to customize Cursor settings and behavior.', + '', + '', + '', + '', + ' → Plan, search, build anything', + '', + '', + ' Composer 2.5', + ' ~/development/code/xinyue/app_android · develop/app6.5.1', + '' + ]) + + expect( + resolveCursorAgentImeAnchor({ + buffer, + rows: 14, + cols: 80, + cursorX: 0, + cursorY: 13 + }) + ).toEqual({ row: 8, column: 4 }) + }) + + it('does not override normal terminal cursor positioning', () => { + const buffer = makeBuffer(['', ' → Plan, search, build anything', '', '']) + + expect( + resolveCursorAgentImeAnchor({ + buffer, + rows: 4, + cols: 80, + cursorX: 0, + cursorY: 3 + }) + ).toBeNull() + }) + + it('does not override when xterm already exposes a non-stale cursor position', () => { + const buffer = makeBuffer(['', ' Cursor Agent', '', ' → Plan, search, build anything']) + + expect( + resolveCursorAgentImeAnchor({ + buffer, + rows: 4, + cols: 80, + cursorX: 4, + cursorY: 3 + }) + ).toBeNull() + }) + + it('anchors the input row, not a transcript line containing an arrow', () => { + const buffer = makeBuffer([ + '', + ' Cursor Agent', + '', + ' Renamed a.ts → b.ts', + '', + ' → Plan, search, build anything', + '' + ]) + + expect( + resolveCursorAgentImeAnchor({ + buffer, + rows: 7, + cols: 80, + cursorX: 0, + cursorY: 6 + }) + ).toEqual({ row: 5, column: 4 }) + }) + + it('uses cell width when anchoring after typed Cursor Agent input', () => { + const buffer = makeBuffer(['', ' Cursor Agent', '', ' → hi你', '']) + + expect( + resolveCursorAgentImeAnchor({ + buffer, + rows: 5, + cols: 80, + cursorX: 0, + cursorY: 4 + }) + ).toEqual({ row: 3, column: 8 }) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/terminal-ime-anchor.ts b/src/renderer/src/lib/pane-manager/terminal-ime-anchor.ts new file mode 100644 index 00000000000..bda4c751a88 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-ime-anchor.ts @@ -0,0 +1,122 @@ +import type { IBuffer, IBufferCell, IBufferLine } from '@xterm/xterm' + +export type TerminalImeAnchor = { + row: number + column: number +} + +const CURSOR_AGENT_HEADER = 'Cursor Agent' +const CURSOR_AGENT_INPUT_MARKER = '→' +const CURSOR_AGENT_EMPTY_PROMPT = 'Plan, search, build anything' +const CURSOR_AGENT_HEADER_SCAN_ROWS = 6 + +export function resolveCursorAgentImeAnchor(args: { + buffer: IBuffer + rows: number + cols: number + cursorX: number + cursorY: number +}): TerminalImeAnchor | null { + const cursorLine = getVisibleLine(args.buffer, args.cursorY) + if (args.cursorX !== 0 || !isBlankLine(cursorLine)) { + return null + } + return findCursorAgentScreenInputAnchor(args) +} + +function findCursorAgentScreenInputAnchor(args: { + buffer: IBuffer + rows: number + cols: number +}): TerminalImeAnchor | null { + if (!hasCursorAgentHeader(args.buffer, args.rows)) { + return null + } + + // Why: the input box sits below the transcript, so scan bottom-up — a + // transcript line containing "→ " (e.g. a rename diff) must not win. + for (let row = args.rows - 1; row >= 0; row--) { + const line = getVisibleLine(args.buffer, row) + if (!line) { + continue + } + const column = resolveCursorAgentInputColumn(line, args.cols) + if (column !== null) { + return { row, column: Math.min(column, Math.max(args.cols - 1, 0)) } + } + } + + return null +} + +function getVisibleLine(buffer: IBuffer, row: number): IBufferLine | undefined { + return buffer.getLine(buffer.baseY + row) +} + +function hasCursorAgentHeader(buffer: IBuffer, rows: number): boolean { + const scanRows = Math.min(rows, CURSOR_AGENT_HEADER_SCAN_ROWS) + for (let row = 0; row < scanRows; row++) { + if (getVisibleLine(buffer, row)?.translateToString(true).trim() === CURSOR_AGENT_HEADER) { + return true + } + } + return false +} + +function resolveCursorAgentInputColumn(line: IBufferLine, cols: number): number | null { + const inputColumn = findCursorAgentInputStartColumn(line, cols) + if (inputColumn === null) { + return null + } + + const inputText = line.translateToString(true, inputColumn, cols) + if (!inputText.trim() || inputText.startsWith(CURSOR_AGENT_EMPTY_PROMPT)) { + return inputColumn + } + + return findLineContentEndColumn(line, inputColumn, cols) ?? inputColumn +} + +function findCursorAgentInputStartColumn(line: IBufferLine, cols: number): number | null { + const maxColumn = Math.min(line.length, cols) + for (let column = 0; column < maxColumn - 1; column++) { + const markerCell = line.getCell(column) + if (!isCellChar(markerCell, CURSOR_AGENT_INPUT_MARKER)) { + continue + } + + const nextColumn = column + Math.max(markerCell.getWidth(), 1) + if (nextColumn < maxColumn && isCellChar(line.getCell(nextColumn), ' ')) { + return nextColumn + 1 + } + } + return null +} + +function findLineContentEndColumn( + line: IBufferLine, + startColumn: number, + cols: number +): number | null { + const maxColumn = Math.min(line.length, cols) + for (let column = maxColumn - 1; column >= startColumn; column--) { + const cell = line.getCell(column) + if (!cell || cell.getWidth() === 0 || getCellChars(cell) === ' ') { + continue + } + return column + Math.max(cell.getWidth(), 1) + } + return null +} + +function isBlankLine(line: IBufferLine | undefined): boolean { + return !line || line.translateToString(true).trim() === '' +} + +function isCellChar(cell: IBufferCell | undefined, expected: string): cell is IBufferCell { + return !!cell && cell.getWidth() > 0 && getCellChars(cell) === expected +} + +function getCellChars(cell: IBufferCell): string { + return cell.getChars() || ' ' +} diff --git a/src/renderer/src/lib/pane-manager/windows-pty-compatibility.test.ts b/src/renderer/src/lib/pane-manager/windows-pty-compatibility.test.ts index 8e32f7d7246..bfd67604707 100644 --- a/src/renderer/src/lib/pane-manager/windows-pty-compatibility.test.ts +++ b/src/renderer/src/lib/pane-manager/windows-pty-compatibility.test.ts @@ -229,6 +229,18 @@ describe('isLocalNativeWindowsConpty', () => { ).toBe(false) }) + it('does NOT treat unresolved connection ownership as native ConPTY', () => { + expect( + isLocalNativeWindowsConpty({ + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', + connectionId: undefined, + cwd: '/home/me/repo', + shellOverride: null, + executionHostId: 'local' + }) + ).toBe(false) + }) + it('stays false on a local host when the pane is not a native Windows PTY', () => { // Non-Windows client: the raw heuristic is false, so the gate result is false // regardless of the local execution host. diff --git a/src/renderer/src/lib/passive-macos-app-data-access.ts b/src/renderer/src/lib/passive-macos-app-data-access.ts index 5475adbc1c9..5be004d612e 100644 --- a/src/renderer/src/lib/passive-macos-app-data-access.ts +++ b/src/renderer/src/lib/passive-macos-app-data-access.ts @@ -20,7 +20,7 @@ export function isMacAppDataPath(path: string | null | undefined, userAgent?: st return MAC_APP_DATA_SEGMENT_RE.test(path.replace(/\\/g, '/')) } -export function shouldPollActiveGitStatus(args: { +export type ActiveGitStatusPollingArgs = { activeWorktreeId: string | null worktreePath: string | null rightSidebarOpen: boolean @@ -28,7 +28,9 @@ export function shouldPollActiveGitStatus(args: { rightSidebarExplorerView?: RightSidebarExplorerView openFiles?: OpenFile[] userAgent?: string -}): boolean { +} + +export function hasInteractiveActiveGitStatusConsumer(args: ActiveGitStatusPollingArgs): boolean { if (!args.activeWorktreeId || !args.worktreePath) { return false } @@ -43,6 +45,16 @@ export function shouldPollActiveGitStatus(args: { if ((args.openFiles ?? []).some((file) => file.worktreeId === args.activeWorktreeId)) { return true } + return false +} + +export function shouldPollActiveGitStatus(args: ActiveGitStatusPollingArgs): boolean { + if (!args.activeWorktreeId || !args.worktreePath) { + return false + } + if (hasInteractiveActiveGitStatusConsumer(args)) { + return true + } // Why: macOS app-container paths can trigger the "data from other apps" // prompt. Keep terminal-only workspace switching from passively probing them. return !isMacAppDataPath(args.worktreePath, args.userAgent) diff --git a/src/renderer/src/lib/pending-worktree-creation.ts b/src/renderer/src/lib/pending-worktree-creation.ts index cad46eab62d..b7f042a7b36 100644 --- a/src/renderer/src/lib/pending-worktree-creation.ts +++ b/src/renderer/src/lib/pending-worktree-creation.ts @@ -77,6 +77,9 @@ export type WorktreeCreationRequest = { * agent launch is self-contained; otherwise the renderer drives startup via * `startupPlan`. */ startup?: WorktreeStartupLaunch + /** Repo Custom GitHub Issue Command to run in a side-pane split after the + * workspace's first terminal is created. Mirrors the composer's trust-gated issueCommand. */ + issueCommand?: { command: string; env?: Record } pendingFirstAgentMessageRename: boolean /** Post-create note persisted as the worktree comment. */ note: string diff --git a/src/renderer/src/lib/running-agent-targets.test.ts b/src/renderer/src/lib/running-agent-targets.test.ts index 47e24fbf061..847052d2e79 100644 --- a/src/renderer/src/lib/running-agent-targets.test.ts +++ b/src/renderer/src/lib/running-agent-targets.test.ts @@ -205,7 +205,127 @@ describe('running agent send targets', () => { ]) }) - it('disables stale agent status rows even when the pane still has a leaf PTY', () => { + it('keeps stale agent status rows disabled when no live title proves the agent is sendable', () => { + const stalePaneKey = makePaneKey(TAB_ID, RIGHT_LEAF_ID) + const target = resolveRunningAgentSendTarget( + state({ + agentStatusByPaneKey: { + [stalePaneKey]: entry(stalePaneKey, 'waiting', NOW - 31 * 60 * 1000) + }, + terminalLayoutsByTabId: { + [TAB_ID]: { + root: { type: 'leaf', leafId: RIGHT_LEAF_ID }, + activeLeafId: RIGHT_LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [RIGHT_LEAF_ID]: 'pty-right' } + } + } + }), + WORKTREE_ID, + stalePaneKey, + NOW + ) + + expect(target).toMatchObject({ + paneKey: stalePaneKey, + ptyId: 'pty-right', + status: 'disabled', + disabledReason: 'Agent status is stale' + }) + }) + + it('keeps stale agent status rows disabled when only a bare agent title remains', () => { + const stalePaneKey = makePaneKey(TAB_ID, RIGHT_LEAF_ID) + const target = resolveRunningAgentSendTarget( + state({ + agentStatusByPaneKey: { + [stalePaneKey]: entry(stalePaneKey, 'done', NOW - 31 * 60 * 1000) + }, + tabsByWorktree: { [WORKTREE_ID]: [{ ...tab(TAB_ID), title: 'Codex' }] }, + terminalLayoutsByTabId: { + [TAB_ID]: { + root: { type: 'leaf', leafId: RIGHT_LEAF_ID }, + activeLeafId: RIGHT_LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [RIGHT_LEAF_ID]: 'pty-right' } + } + } + }), + WORKTREE_ID, + stalePaneKey, + NOW + ) + + expect(target).toMatchObject({ + paneKey: stalePaneKey, + ptyId: 'pty-right', + status: 'disabled', + disabledReason: 'Agent status is stale' + }) + }) + + it('promotes stale agent status rows when a live pane title proves the agent is sendable', () => { + const stalePaneKey = makePaneKey(TAB_ID, RIGHT_LEAF_ID) + const target = resolveRunningAgentSendTarget( + state({ + agentStatusByPaneKey: { + [stalePaneKey]: entry(stalePaneKey, 'done', NOW - 31 * 60 * 1000) + }, + terminalLayoutsByTabId: { + [TAB_ID]: { + root: { type: 'leaf', leafId: RIGHT_LEAF_ID }, + activeLeafId: RIGHT_LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [RIGHT_LEAF_ID]: 'pty-right' } + } + }, + runtimePaneTitlesByTabId: { [TAB_ID]: { 1: 'Codex ready' } } + }), + WORKTREE_ID, + stalePaneKey, + NOW + ) + + expect(target).toMatchObject({ + paneKey: stalePaneKey, + ptyId: 'pty-right', + status: 'eligible' + }) + expect(target).not.toHaveProperty('disabledReason') + }) + + it('treats a missing tab title as absent live title evidence', () => { + const paneKey = makePaneKey(TAB_ID, RIGHT_LEAF_ID) + const target = resolveRunningAgentSendTarget( + state({ + agentStatusByPaneKey: { + [paneKey]: entry(paneKey, 'done') + }, + tabsByWorktree: { + [WORKTREE_ID]: [{ ...tab(TAB_ID), title: undefined } as unknown as TerminalTab] + }, + terminalLayoutsByTabId: { + [TAB_ID]: { + root: { type: 'leaf', leafId: RIGHT_LEAF_ID }, + activeLeafId: RIGHT_LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [RIGHT_LEAF_ID]: 'pty-right' } + } + } + }), + WORKTREE_ID, + paneKey, + NOW + ) + + expect(target).toMatchObject({ + paneKey, + ptyId: 'pty-right', + status: 'eligible' + }) + }) + + it('keeps stale agent status rows disabled when the live pane title needs permission', () => { const stalePaneKey = makePaneKey(TAB_ID, RIGHT_LEAF_ID) const target = resolveRunningAgentSendTarget( state({ @@ -231,7 +351,7 @@ describe('running agent send targets', () => { paneKey: stalePaneKey, ptyId: 'pty-right', status: 'disabled', - disabledReason: 'Agent status is stale' + disabledReason: 'Agent needs permission' }) }) diff --git a/src/renderer/src/lib/running-agent-targets.ts b/src/renderer/src/lib/running-agent-targets.ts index 8e310c86d0b..332f419dbd2 100644 --- a/src/renderer/src/lib/running-agent-targets.ts +++ b/src/renderer/src/lib/running-agent-targets.ts @@ -5,12 +5,9 @@ import { } from '../../../shared/agent-status-types' import type { TerminalTab } from '../../../shared/types' import { parsePaneKey } from '../../../shared/stable-pane-id' -import { - detectAgentStatusFromTitle, - getAgentLabel, - isExplicitAgentStatusFresh -} from './agent-status' -import { resolveRuntimePaneTitleForLeaf } from './runtime-pane-title-leaf-id' +import { isExplicitAgentStatusFresh } from './agent-status' +import { detectAgentSendTitleStatus } from './agent-send-title-status' +import { resolveRuntimePaneTitleLeafResolution } from './runtime-pane-title-leaf-id' export type RunningAgentTargetState = Pick< AppState, @@ -61,13 +58,22 @@ export function deriveRunningAgentSendTargets( : null let disabledReason: string | undefined + // Why: hook-backed rows can go stale while the same PTY is still a live + // agent; live titles are the runtime proof that the row remains targetable. + const liveTitleStatus = ptyId + ? detectLiveAgentPaneStatus(state, parsed.tabId, parsed.leafId, tab.title) + : null if (!isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) { - disabledReason = 'Agent status is stale' + if (liveTitleStatus === 'permission') { + disabledReason = 'Agent needs permission' + } else if (liveTitleStatus === null) { + disabledReason = 'Agent status is stale' + } } else if (!ptyId) { disabledReason = 'Terminal is no longer available' } else if (entry.state === 'blocked' || entry.state === 'waiting') { disabledReason = 'Agent needs permission' - } else if (hasPermissionPaneTitle(state, parsed.tabId, parsed.leafId, tab.title)) { + } else if (liveTitleStatus === 'permission') { disabledReason = 'Agent needs permission' } @@ -86,22 +92,22 @@ export function deriveRunningAgentSendTargets( return targets } -function hasPermissionPaneTitle( +function detectLiveAgentPaneStatus( state: RunningAgentTargetState, tabId: string, leafId: string, tabTitle: string -): boolean { +): ReturnType { const layout = state.terminalLayoutsByTabId[tabId] - const paneTitle = resolveRuntimePaneTitleForLeaf( - layout, - state.runtimePaneTitlesByTabId?.[tabId], - leafId - ) + const paneTitles = state.runtimePaneTitlesByTabId?.[tabId] + const paneTitleResolution = resolveRuntimePaneTitleLeafResolution(layout, paneTitles, leafId) // Why: runtime pane titles are the freshest title signal for split panes; use // the tab title only before the runtime has reported a pane title for the leaf. - const title = paneTitle ?? tabTitle - return detectAgentStatusFromTitle(title) === 'permission' && getAgentLabel(title) !== null + const title = paneTitleResolution.title ?? (paneTitleResolution.hasAnyPaneTitle ? null : tabTitle) + if (title === null) { + return null + } + return detectAgentSendTitleStatus(title) } export function resolveRunningAgentSendTarget( diff --git a/src/renderer/src/lib/runtime-pane-title-leaf-id.test.ts b/src/renderer/src/lib/runtime-pane-title-leaf-id.test.ts index 3ae0b9a49bd..be00d6b7098 100644 --- a/src/renderer/src/lib/runtime-pane-title-leaf-id.test.ts +++ b/src/renderer/src/lib/runtime-pane-title-leaf-id.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest' import type { TerminalLayoutSnapshot } from '../../../shared/types' -import { resolveRuntimePaneTitleForLeaf } from './runtime-pane-title-leaf-id' +import { + resolveRuntimePaneTitleForLeaf, + resolveRuntimePaneTitleLeafResolution +} from './runtime-pane-title-leaf-id' const LEAF_A = '11111111-1111-4111-8111-111111111111' const LEAF_B = '22222222-2222-4222-8222-222222222222' @@ -45,3 +48,28 @@ describe('resolveRuntimePaneTitleForLeaf', () => { expect(resolveRuntimePaneTitleForLeaf(undefined, { 7: 'Codex' }, LEAF_A)).toBe('Codex') }) }) + +describe('resolveRuntimePaneTitleLeafResolution', () => { + it('reports when no pane titles are present', () => { + expect(resolveRuntimePaneTitleLeafResolution(leafLayout, {}, LEAF_A)).toEqual({ + title: null, + hasAnyPaneTitle: false + }) + }) + + it('reports an unrelated sparse split title without attributing it to the leaf', () => { + expect(resolveRuntimePaneTitleLeafResolution(splitLayout, { 2: 'Codex' }, LEAF_A)).toEqual({ + title: null, + hasAnyPaneTitle: true + }) + }) + + it('reports the matching leaf title when one resolves', () => { + expect( + resolveRuntimePaneTitleLeafResolution(splitLayout, { 1: 'zsh', 2: 'Codex' }, LEAF_B) + ).toEqual({ + title: 'Codex', + hasAnyPaneTitle: true + }) + }) +}) diff --git a/src/renderer/src/lib/runtime-pane-title-leaf-id.ts b/src/renderer/src/lib/runtime-pane-title-leaf-id.ts index dd7eb849d03..0847f1e3de2 100644 --- a/src/renderer/src/lib/runtime-pane-title-leaf-id.ts +++ b/src/renderer/src/lib/runtime-pane-title-leaf-id.ts @@ -2,6 +2,11 @@ import { FIRST_PANE_ID } from '../../../shared/pane-key' import { isTerminalLeafId } from '../../../shared/stable-pane-id' import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode } from '../../../shared/types' +export type RuntimePaneTitleLeafResolution = { + title: string | null + hasAnyPaneTitle: boolean +} + function getLeftmostLeafId(node: TerminalPaneLayoutNode): string { return node.type === 'leaf' ? node.leafId : getLeftmostLeafId(node.first) } @@ -55,34 +60,48 @@ export function resolveRuntimePaneTitleForLeaf( paneTitles: Record | undefined, leafId: string ): string | null { + return resolveRuntimePaneTitleLeafResolution(tabLayout, paneTitles, leafId).title +} + +export function resolveRuntimePaneTitleLeafResolution( + tabLayout: { root?: TerminalLayoutSnapshot['root'] } | undefined, + paneTitles: Record | undefined, + leafId: string +): RuntimePaneTitleLeafResolution { if (!paneTitles) { - return null + return { title: null, hasAnyPaneTitle: false } } - const titleEntries = Object.entries(paneTitles) - if (titleEntries.length === 0) { - return null - } + const titlesByPaneId = paneTitles as Record + let firstTitle: string | null = null + let hasOnePaneTitle = false + let hasMultiplePaneTitles = false - if (tabLayout?.root) { - for (const [runtimePaneId, title] of titleEntries) { - if (resolveRuntimePaneTitleLeafId(tabLayout, runtimePaneId) === leafId) { - return title - } + for (const runtimePaneId in titlesByPaneId) { + if (!Object.prototype.hasOwnProperty.call(titlesByPaneId, runtimePaneId)) { + continue } - return null - } - if (titleEntries.length === 1) { - return titleEntries[0][1] - } + const title = titlesByPaneId[runtimePaneId] + if (hasOnePaneTitle) { + hasMultiplePaneTitles = true + } else { + firstTitle = title + hasOnePaneTitle = true + } - for (const [runtimePaneId, title] of titleEntries) { if (resolveRuntimePaneTitleLeafId(tabLayout, runtimePaneId) === leafId) { - return title + return { title, hasAnyPaneTitle: true } } } - return null + + // Why: without a layout root, only a single reported pane title can be + // attributed; any pane title still suppresses stale tab-title fallback. + if (!tabLayout?.root && hasOnePaneTitle && !hasMultiplePaneTitles) { + return { title: firstTitle, hasAnyPaneTitle: true } + } + + return { title: null, hasAnyPaneTitle: hasOnePaneTitle } } export function resolveRuntimePaneTitleLeafIdFromRoot( diff --git a/src/renderer/src/lib/smart-github-submit.test.ts b/src/renderer/src/lib/smart-github-submit.test.ts index 43ec3c36325..64e58824e0c 100644 --- a/src/renderer/src/lib/smart-github-submit.test.ts +++ b/src/renderer/src/lib/smart-github-submit.test.ts @@ -299,4 +299,26 @@ describe('getSmartGitHubSubmitResolution', () => { expect(resolution.linkedIssueNumber).toBe(2050) expect(resolution.linkedPR).toBeNull() }) + + it('uses the URL path to normalize stale PR-typed issue results', () => { + expect( + getSmartGitHubSubmitResolution({ + type: 'pr', + number: 6933, + title: 'The board columns are displayed backwards', + url: 'https://github.com/stablyai/orca/issues/6933' + }) + ).toEqual({ + workspaceName: 'the-board-columns-are-displayed-backwards', + displayName: 'The board columns are displayed backwards', + linkedWorkItem: { + type: 'issue', + number: 6933, + title: 'The board columns are displayed backwards', + url: 'https://github.com/stablyai/orca/issues/6933' + }, + linkedIssueNumber: 6933, + linkedPR: null + }) + }) }) diff --git a/src/renderer/src/lib/smart-github-submit.ts b/src/renderer/src/lib/smart-github-submit.ts index c6ee5abff47..1b4e818f26a 100644 --- a/src/renderer/src/lib/smart-github-submit.ts +++ b/src/renderer/src/lib/smart-github-submit.ts @@ -4,6 +4,7 @@ import { getTaskSourceCacheScope } from '../../../shared/task-source-context' import { getLinkedWorkItemWorkspaceName } from '../../../shared/workspace-name' import type { LinkedWorkItemSummary } from './new-workspace' import { parseGitHubIssueOrPRLink } from './github-links' +import { resolveGitHubWorkItemIdentity } from '@/lib/github-work-item-identity' export type SmartGitHubSubmitIntent = | { @@ -196,12 +197,14 @@ export function getSmartGitHubSubmitLookupCacheSizeForTests(): number { export function getSmartGitHubSubmitResolution( item: Pick ): SmartGitHubSubmitResolution { - const fallbackName = `${item.type}-${item.number}` - const titleName = getLinkedWorkItemWorkspaceName(item) + const identity = resolveGitHubWorkItemIdentity(item) + const normalizedItem = { ...item, type: identity.type, number: identity.number } + const fallbackName = `${identity.type}-${identity.number}` + const titleName = getLinkedWorkItemWorkspaceName(normalizedItem) const workspaceName = titleName?.seedName || fallbackName const linkedWorkItem: LinkedWorkItemSummary = { - type: item.type, - number: item.number, + type: identity.type, + number: identity.number, title: item.title, url: item.url } @@ -210,7 +213,7 @@ export function getSmartGitHubSubmitResolution( workspaceName, displayName: titleName?.displayName ?? fallbackName, linkedWorkItem, - linkedIssueNumber: item.type === 'issue' ? item.number : null, - linkedPR: item.type === 'pr' ? item.number : null + linkedIssueNumber: identity.type === 'issue' ? identity.number : null, + linkedPR: identity.type === 'pr' ? identity.number : null } } diff --git a/src/renderer/src/lib/source-control-launch-agent-selection.test.ts b/src/renderer/src/lib/source-control-launch-agent-selection.test.ts new file mode 100644 index 00000000000..63a053f0186 --- /dev/null +++ b/src/renderer/src/lib/source-control-launch-agent-selection.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from 'vitest' +import { getDefaultSettings } from '../../../shared/constants' +import type { GlobalSettings, Repo, TuiAgent } from '../../../shared/types' +import { + resolveSourceControlLaunchAgentScope, + summarizeReposOverridingActionRecipe +} from './source-control-launch-agent-selection' + +function settingsWithGlobalResolveAgent(agentId: TuiAgent): GlobalSettings { + const base = getDefaultSettings('/tmp') + return { + ...base, + defaultTuiAgent: agentId, + sourceControlAi: { + ...base.sourceControlAi!, + enabled: true, + actions: { + ...base.sourceControlAi!.actions, + resolveConflicts: { agentId, commandInputTemplate: '{basePrompt}', agentArgs: '' } + } + } + } +} + +function repoWithResolveAgent(agentId: TuiAgent): Pick { + return { + sourceControlAi: { + actionOverrides: { + resolveConflicts: { agentId, commandInputTemplate: '{basePrompt}', agentArgs: '' } + } + } + } +} + +describe('resolveSourceControlLaunchAgentScope', () => { + it('flags a repo override that pins a different agent than the global default', () => { + const scope = resolveSourceControlLaunchAgentScope({ + settings: settingsWithGlobalResolveAgent('claude'), + repo: repoWithResolveAgent('codex'), + actionId: 'resolveConflicts' + }) + expect(scope).toEqual({ + effectiveAgentId: 'codex', + globalAgentId: 'claude', + overridesGlobalAgent: true + }) + }) + + it('does not flag a repo override that matches the global agent', () => { + const scope = resolveSourceControlLaunchAgentScope({ + settings: settingsWithGlobalResolveAgent('claude'), + repo: repoWithResolveAgent('claude'), + actionId: 'resolveConflicts' + }) + expect(scope.overridesGlobalAgent).toBe(false) + }) + + it('does not flag when there is no repo override', () => { + const scope = resolveSourceControlLaunchAgentScope({ + settings: settingsWithGlobalResolveAgent('claude'), + repo: null, + actionId: 'resolveConflicts' + }) + expect(scope).toEqual({ + effectiveAgentId: 'claude', + globalAgentId: 'claude', + overridesGlobalAgent: false + }) + }) + + it('falls back to the default agent when no global recipe agent is set', () => { + const base = getDefaultSettings('/tmp') + const settings: GlobalSettings = { + ...base, + defaultTuiAgent: 'claude', + sourceControlAi: { ...base.sourceControlAi!, enabled: true } + } + const scope = resolveSourceControlLaunchAgentScope({ + settings, + repo: repoWithResolveAgent('codex'), + actionId: 'resolveConflicts' + }) + expect(scope.globalAgentId).toBe('claude') + expect(scope.overridesGlobalAgent).toBe(true) + }) +}) + +function repoOverriding( + id: string, + displayName: string, + agentId: TuiAgent +): Pick { + return { id, displayName, ...repoWithResolveAgent(agentId) } +} + +describe('summarizeReposOverridingActionRecipe', () => { + it('lists repos with any action recipe override', () => { + const summary = summarizeReposOverridingActionRecipe({ + repos: [ + repoOverriding('repo-1', 'App', 'claude'), + { + id: 'repo-2', + displayName: 'Web', + sourceControlAi: { + actionOverrides: { + resolveConflicts: { + commandInputTemplate: '{basePrompt}\n\nUse project conflict rules.', + agentArgs: '--model gpt-5.4-mini' + } + } + } + }, + { id: 'repo-3', displayName: 'Docs' } + ], + actionId: 'resolveConflicts' + }) + + expect(summary).toEqual({ + count: 2, + overrides: [ + { repoId: 'repo-1', repoName: 'App', fields: ['agent', 'commandTemplate', 'agentArgs'] }, + { repoId: 'repo-2', repoName: 'Web', fields: ['commandTemplate', 'agentArgs'] } + ] + }) + }) + + it('includes legacy repo instructions as command template overrides', () => { + const summary = summarizeReposOverridingActionRecipe({ + repos: [ + { + id: 'repo-1', + displayName: 'App', + sourceControlAi: { + instructionsByOperation: { + commitMessage: 'Use project style.' + } + } + } + ], + actionId: 'commitMessage' + }) + + expect(summary).toEqual({ + count: 1, + overrides: [{ repoId: 'repo-1', repoName: 'App', fields: ['commandTemplate'] }] + }) + }) +}) diff --git a/src/renderer/src/lib/source-control-launch-agent-selection.ts b/src/renderer/src/lib/source-control-launch-agent-selection.ts index 38660a74ce5..e8c00812590 100644 --- a/src/renderer/src/lib/source-control-launch-agent-selection.ts +++ b/src/renderer/src/lib/source-control-launch-agent-selection.ts @@ -1,8 +1,15 @@ import { getAgentCatalog } from '@/lib/agent-catalog' import { isCustomAgentId } from '../../../shared/commit-message-agent-spec' -import type { SourceControlActionRecipe } from '../../../shared/source-control-ai-actions' +import { + normalizeRepoSourceControlAiOverrides, + resolveSourceControlActionRecipe +} from '../../../shared/source-control-ai' +import type { + SourceControlActionId, + SourceControlActionRecipe +} from '../../../shared/source-control-ai-actions' import { filterEnabledTuiAgents } from '../../../shared/tui-agent-selection' -import type { TuiAgent } from '../../../shared/types' +import type { GlobalSettings, Repo, TuiAgent } from '../../../shared/types' export function readSourceControlLaunchRecipeAgentId( recipe: Pick | null | undefined @@ -30,3 +37,122 @@ export function pickSourceControlLaunchAgent(args: { } return getAgentCatalog().find((entry) => enabledAgents.includes(entry.id))?.id ?? null } + +export type SourceControlLaunchAgentScope = { + /** Agent the button would launch, after applying any repo override. */ + effectiveAgentId: TuiAgent | null + /** Agent the button would launch without a repo override (global recipe or default). */ + globalAgentId: TuiAgent | null + /** + * True when this repo pins a different launch agent than the global default — + * the silent-override case we surface instead of auto-launching blindly. + */ + overridesGlobalAgent: boolean +} + +export function resolveSourceControlLaunchAgentScope(input: { + settings: + | Pick + | null + | undefined + repo?: Pick | null + actionId: SourceControlActionId +}): SourceControlLaunchAgentScope { + const effectiveAgentId = readSourceControlLaunchRecipeAgentId( + resolveSourceControlActionRecipe({ + settings: input.settings, + repo: input.repo, + actionId: input.actionId + }) + ) + const globalRecipeAgentId = readSourceControlLaunchRecipeAgentId( + resolveSourceControlActionRecipe({ + settings: input.settings, + repo: null, + actionId: input.actionId + }) + ) + // Why: the note compares against what would run with no override, so fall back + // to the global default agent when no global recipe agent is set. + const defaultTuiAgent = input.settings?.defaultTuiAgent + const globalAgentId = + globalRecipeAgentId ?? (defaultTuiAgent && defaultTuiAgent !== 'blank' ? defaultTuiAgent : null) + const hasRepoAgentOverride = + normalizeRepoSourceControlAiOverrides(input.repo?.sourceControlAi)?.actionOverrides?.[ + input.actionId + ]?.agentId !== undefined + return { + effectiveAgentId, + globalAgentId, + overridesGlobalAgent: + hasRepoAgentOverride && effectiveAgentId !== null && effectiveAgentId !== globalAgentId + } +} + +export type SourceControlActionRecipeOverrideField = 'agent' | 'commandTemplate' | 'agentArgs' + +export type SourceControlActionRecipeOverride = { + repoId: string + repoName: string + fields: SourceControlActionRecipeOverrideField[] +} + +export type SourceControlActionRecipeOverrideSummary = { + count: number + overrides: SourceControlActionRecipeOverride[] +} + +type NormalizedRepoSourceControlAiOverrides = NonNullable< + ReturnType +> + +function hasActionOverride( + overrides: NormalizedRepoSourceControlAiOverrides['actionOverrides'] | undefined, + actionId: SourceControlActionId +): boolean { + return Object.prototype.hasOwnProperty.call(overrides ?? {}, actionId) +} + +function readRecipeOverrideFields( + recipe: + | NonNullable[SourceControlActionId] + | null + | undefined +): SourceControlActionRecipeOverrideField[] { + const fields: SourceControlActionRecipeOverrideField[] = [] + if (Object.prototype.hasOwnProperty.call(recipe ?? {}, 'agentId')) { + fields.push('agent') + } + if (Object.prototype.hasOwnProperty.call(recipe ?? {}, 'commandInputTemplate')) { + fields.push('commandTemplate') + } + if (Object.prototype.hasOwnProperty.call(recipe ?? {}, 'agentArgs')) { + fields.push('agentArgs') + } + return fields +} + +/** + * Which repos own any recipe setting for an action — lets the global recipe + * editor warn that saving globally will not update those repo-specific recipes. + */ +export function summarizeReposOverridingActionRecipe(input: { + repos: readonly Pick[] + actionId: SourceControlActionId +}): SourceControlActionRecipeOverrideSummary { + const overrides: SourceControlActionRecipeOverride[] = [] + for (const repo of input.repos) { + const actionOverrides = normalizeRepoSourceControlAiOverrides( + repo.sourceControlAi + )?.actionOverrides + if (!hasActionOverride(actionOverrides, input.actionId)) { + continue + } + overrides.push({ + repoId: repo.id, + repoName: repo.displayName, + fields: readRecipeOverrideFields(actionOverrides?.[input.actionId]) + }) + } + return { count: overrides.length, overrides } +} diff --git a/src/renderer/src/lib/tab-agent.test.ts b/src/renderer/src/lib/tab-agent.test.ts index e14583269df..770e5df9912 100644 --- a/src/renderer/src/lib/tab-agent.test.ts +++ b/src/renderer/src/lib/tab-agent.test.ts @@ -1,15 +1,22 @@ import { describe, expect, it } from 'vitest' import { - hasCompletedTabAgent, - resolveCompletedTabAgent, resolveFocusedCompletedTabAgent, resolveFocusedTabAgent, resolveSiblingCompletedTabAgent, - resolveSiblingTabAgent, - resolveTabAgent + resolveSiblingTabAgent } from './tab-agent' import type { AgentStatusEntry, AgentType } from '../../../shared/agent-status-types' -import type { TerminalLayoutSnapshot } from '../../../shared/types' +import type { TerminalLayoutSnapshot, TuiAgent } from '../../../shared/types' + +// Composed exactly the way useTabAgent layers the resolvers: focused pane +// first, then any sibling agent pane in the tab. +function resolveTabAgent( + map: Record, + layout: TerminalLayoutSnapshot | undefined, + tabId: string +): TuiAgent | null { + return resolveFocusedTabAgent(map, layout, tabId) ?? resolveSiblingTabAgent(map, layout, tabId) +} const LEAF_A = '11111111-1111-4111-8111-111111111111' const LEAF_B = '22222222-2222-4222-8222-222222222222' @@ -97,8 +104,8 @@ describe('resolveTabAgent', () => { state: 'done' as const } } - expect(hasCompletedTabAgent(map, 'tab-1')).toBe(true) - expect(resolveCompletedTabAgent(map, 'tab-1')).toBe('openclaude') + expect(resolveFocusedCompletedTabAgent(map, undefined, 'tab-1')).toBe('openclaude') + expect(resolveSiblingCompletedTabAgent(map, undefined, 'tab-1')).toBeNull() }) it('exposes focused and sibling completed hook identity separately', () => { diff --git a/src/renderer/src/lib/tab-agent.ts b/src/renderer/src/lib/tab-agent.ts index 2879734fada..38b6a2dac12 100644 --- a/src/renderer/src/lib/tab-agent.ts +++ b/src/renderer/src/lib/tab-agent.ts @@ -4,27 +4,13 @@ import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../../shared/sta import { agentTypeToIconAgent } from './agent-status' /** - * Resolve a terminal tab's agent from hook-reported status. This is the - * FALLBACK signal for the tab-bar icon — the live foreground process - * (see useTabAgent) is the primary, dev-friendly source. Hook status is what - * drives the icon for SSH/remote panes (where foreground polling is too - * costly) and during the brief window before the first foreground poll lands. - * - * Prefers the focused pane's agent so a split tab's icon tracks the pane in - * view; falls back to any agent pane in the tab. Returns null when no pane - * reports an iconable agent. + * Resolve a terminal tab's agent from hook-reported status — the PRIMARY + * identity signal for the tab-bar icon (composed by useTabAgent): the same + * already-computed state that drives the sidebar agent rows, kept live by the + * OSC 133 command-finished machinery that drops entries when a process exits. + * Focused-pane resolvers track the pane in view; sibling resolvers cover the + * rest of a split tab. */ -export function resolveTabAgent( - agentStatusByPaneKey: Record, - layout: TerminalLayoutSnapshot | undefined, - tabId: string -): TuiAgent | null { - return ( - resolveFocusedTabAgent(agentStatusByPaneKey, layout, tabId) ?? - resolveSiblingTabAgent(agentStatusByPaneKey, layout, tabId) - ) -} - export function resolveFocusedTabAgent( agentStatusByPaneKey: Record, layout: TerminalLayoutSnapshot | undefined, @@ -76,24 +62,6 @@ function agentFromStatusEntry(entry: AgentStatusEntry | undefined): TuiAgent | n return agentTypeToIconAgent(entry.agentType) } -export function hasCompletedTabAgent( - agentStatusByPaneKey: Record, - tabId: string -): boolean { - return resolveCompletedTabAgent(agentStatusByPaneKey, tabId) !== null -} - -export function resolveCompletedTabAgent( - agentStatusByPaneKey: Record, - tabId: string, - layout?: TerminalLayoutSnapshot -): TuiAgent | null { - return ( - resolveFocusedCompletedTabAgent(agentStatusByPaneKey, layout, tabId) ?? - resolveSiblingCompletedTabAgent(agentStatusByPaneKey, layout, tabId) - ) -} - export function resolveFocusedCompletedTabAgent( agentStatusByPaneKey: Record, layout: TerminalLayoutSnapshot | undefined, diff --git a/src/renderer/src/lib/terminal-links.test.ts b/src/renderer/src/lib/terminal-links.test.ts index 887f87ee19e..f6aa3501a0a 100644 --- a/src/renderer/src/lib/terminal-links.test.ts +++ b/src/renderer/src/lib/terminal-links.test.ts @@ -1,5 +1,9 @@ import { performance } from 'node:perf_hooks' import { describe, expect, it } from 'vitest' +import { + TERMINAL_FILE_LINK_TAP_CONFORMANCE_CASES, + columnForTerminalFileLinkTap +} from '../../../shared/terminal-file-link-conformance' import { extractTerminalFileLinks, isPathInsideWorktree, @@ -8,7 +12,27 @@ import { toWorktreeRelativePath } from './terminal-links' +function extractTerminalFileLinkAtColumn(lineText: string, column: number) { + return ( + extractTerminalFileLinks(lineText).find( + (link) => column >= link.startIndex && column < link.endIndex + ) ?? null + ) +} + describe('terminal path helpers', () => { + describe('shared terminal file-link tap conformance', () => { + it.each(TERMINAL_FILE_LINK_TAP_CONFORMANCE_CASES)('$name', (testCase) => { + const link = extractTerminalFileLinkAtColumn( + testCase.lineText, + columnForTerminalFileLinkTap(testCase) + ) + expect( + link ? { pathText: link.pathText, line: link.line, column: link.column } : null + ).toEqual(testCase.expected) + }) + }) + it('keeps worktree-relative paths on Windows external files', () => { expect(isPathInsideWorktree('C:\\repo\\src\\file.ts', 'C:\\repo')).toBe(true) expect(toWorktreeRelativePath('C:\\repo\\src\\file.ts', 'C:\\repo')).toBe('src/file.ts') diff --git a/src/renderer/src/lib/terminal-links.ts b/src/renderer/src/lib/terminal-links.ts index a882e71099e..605360e4245 100644 --- a/src/renderer/src/lib/terminal-links.ts +++ b/src/renderer/src/lib/terminal-links.ts @@ -255,21 +255,47 @@ function insertClaimedRange(claimedRanges: [number, number][], range: [number, n } function trimSpacedPathTrailingProse(range: DetectedRange): DetectedRange { - const filenameBeforeProseMatch = /^(.+\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?)(?:\s+.+)$/.exec( - range.text - ) - if (!filenameBeforeProseMatch) { + // Why: keep one extension-terminated path, but drop trailing prose or a + // second unrelated path that the broad spaced-path scan also captured. A + // line-end extension token only extends the span when the added segment is + // path-like (contains a separator) — "v1.2 reports/result.json" extends, + // prose like "failed to start app.py" must not be swallowed. + let selected: string | null = null + const extensionPrefixPattern = /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?(?=\s+|$)/g + let match: RegExpExecArray | null + while ((match = extensionPrefixPattern.exec(range.text)) !== null) { + const end = match.index + match[0].length + const text = range.text.slice(0, end) + if (countPathStarts(text) > 1) { + continue + } + if ( + end < range.text.length || + selected === null || + /[\\/]/.test(range.text.slice(selected.length, end)) + ) { + selected = text + } + } + if (!selected) { return range } - - const text = filenameBeforeProseMatch[1] return { - text, + text: selected, startIndex: range.startIndex, - endIndex: range.startIndex + text.length + endIndex: range.startIndex + selected.length } } +function countPathStarts(text: string): number { + let count = 0 + for (const match of text.matchAll(/(?:^|\s)(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/])/g)) { + void match + count += 1 + } + return count +} + function trimTrailingWhitespace(range: DetectedRange): DetectedRange { const text = range.text.trimEnd() return { diff --git a/src/renderer/src/lib/update-check-click-options.test.ts b/src/renderer/src/lib/update-check-click-options.test.ts new file mode 100644 index 00000000000..02f66401301 --- /dev/null +++ b/src/renderer/src/lib/update-check-click-options.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { getUpdateCheckClickOptions, getUpdateCheckHint } from './update-check-click-options' + +function clickEvent(overrides: Partial>) { + return { + ctrlKey: false, + metaKey: false, + shiftKey: false, + ...overrides + } as Pick +} + +describe('getUpdateCheckClickOptions', () => { + it('uses Cmd on macOS for perf prerelease checks', () => { + expect(getUpdateCheckClickOptions(clickEvent({ metaKey: true }), true)).toEqual({ + includePrerelease: false, + includePerfPrerelease: true + }) + expect(getUpdateCheckClickOptions(clickEvent({ ctrlKey: true }), true)).toEqual({ + includePrerelease: false, + includePerfPrerelease: false + }) + }) + + it('uses Ctrl outside macOS for perf prerelease checks', () => { + expect(getUpdateCheckClickOptions(clickEvent({ ctrlKey: true }), false)).toEqual({ + includePrerelease: false, + includePerfPrerelease: true + }) + expect(getUpdateCheckClickOptions(clickEvent({ metaKey: true }), false)).toEqual({ + includePrerelease: false, + includePerfPrerelease: false + }) + }) + + it('keeps Shift as the RC prerelease modifier', () => { + expect( + getUpdateCheckClickOptions(clickEvent({ shiftKey: true, ctrlKey: true }), false) + ).toEqual({ + includePrerelease: true, + includePerfPrerelease: true + }) + }) + + it('formats the tooltip hint by platform', () => { + expect(getUpdateCheckHint(true)).toBe( + '⇧+click checks the latest RC; ⌘+click checks the latest perf build.' + ) + expect(getUpdateCheckHint(false)).toBe( + 'Shift+click checks the latest RC; Ctrl+click checks the latest perf build.' + ) + }) +}) diff --git a/src/renderer/src/lib/update-check-click-options.ts b/src/renderer/src/lib/update-check-click-options.ts new file mode 100644 index 00000000000..2ee4191c34b --- /dev/null +++ b/src/renderer/src/lib/update-check-click-options.ts @@ -0,0 +1,24 @@ +import type { UpdateCheckOptions } from '../../../shared/types' +import { getShortcutPlatform } from './shortcut-platform' + +type UpdateCheckClickEvent = Pick + +function isMacShortcutPlatform(): boolean { + return getShortcutPlatform() === 'darwin' +} + +export function getUpdateCheckHint(isMac = isMacShortcutPlatform()): string { + const rcClickLabel = isMac ? '⇧+click' : 'Shift+click' + const perfClickLabel = isMac ? '⌘+click' : 'Ctrl+click' + return `${rcClickLabel} checks the latest RC; ${perfClickLabel} checks the latest perf build.` +} + +export function getUpdateCheckClickOptions( + event: UpdateCheckClickEvent, + isMac = isMacShortcutPlatform() +): UpdateCheckOptions { + return { + includePrerelease: event.shiftKey, + includePerfPrerelease: isMac ? event.metaKey : event.ctrlKey + } +} diff --git a/src/renderer/src/lib/use-tab-agent-process-signals.test.ts b/src/renderer/src/lib/use-tab-agent-process-signals.test.ts new file mode 100644 index 00000000000..e69b27a814b --- /dev/null +++ b/src/renderer/src/lib/use-tab-agent-process-signals.test.ts @@ -0,0 +1,228 @@ +// @vitest-environment happy-dom + +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { makePaneKey } from '../../../shared/stable-pane-id' +import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent' +import type { TerminalTab, TuiAgent } from '../../../shared/types' +import { + resolveLaunchedAgentExitEvidence, + resolveTabAgentFromSignals, + useTabAgent +} from './use-tab-agent' + +const initialAppState = useAppStore.getInitialState() +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const PANE_KEY = makePaneKey('tab-1', LEAF_ID) +let latestHookAgent: TuiAgent | null | undefined +const hookRoots: Root[] = [] + +function HookProbe({ tab }: { tab: TerminalTab }): null { + latestHookAgent = useTabAgent(tab) + return null +} + +async function renderHookProbe(tab: TerminalTab): Promise { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + hookRoots.push(root) + await act(async () => { + root.render(createElement(HookProbe, { tab })) + }) + return root +} + +async function setPaneForeground(entry: PaneForegroundAgentEntry): Promise { + await act(async () => { + useAppStore.getState().setPaneForegroundAgent(PANE_KEY, entry) + }) +} + +describe('resolveTabAgentFromSignals process identity', () => { + it('ranks the recognized foreground process above title and launch bootstrap', () => { + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: false, + title: '✦ Gemini CLI', + hookAgent: null, + processAgent: 'aider', + launchAgent: 'codex' + }) + ).toBe('aider') + }) + + it('keeps live hook identity above process identity', () => { + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: false, + title: 'Terminal 1', + hookAgent: 'claude', + processAgent: 'aider', + launchAgent: undefined + }) + ).toBe('claude') + }) + + it('suppresses launch identity on shell-foreground evidence despite a stale agent title', () => { + // Why: OSC 133;D is process-grade exit proof — a TUI that died without + // restoring its title must not keep painting the tab. + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: false, + title: '✳ Claude Code', + hookAgent: null, + processAgent: null, + processShellForeground: true, + launchAgent: 'claude' + }) + ).toBeNull() + }) + + it('suppresses stuck-title identity once shell foreground is proven on a manual pane', () => { + // Why: pre-probe-removal, the foreground read cleared icons for TUIs that + // died with a stuck title; shell-foreground evidence restores that. + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: false, + title: '✳ Claude Code', + hookAgent: null, + processAgent: null, + processShellForeground: true, + launchAgent: undefined + }) + ).toBeNull() + }) + + it('keeps remote title identity even when a shell-foreground flag is passed', () => { + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: true, + title: '✳ Claude Code', + hookAgent: null, + processAgent: null, + processShellForeground: true, + launchAgent: undefined + }) + ).toBe('claude') + }) + + it('keeps launch identity while the recognized process is still in the foreground', () => { + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: false, + title: 'zsh', + hookAgent: null, + processAgent: 'codex', + launchAgent: 'codex' + }) + ).toBe('codex') + }) +}) + +describe('resolveLaunchedAgentExitEvidence shell-foreground gate', () => { + const baseArgs = { + title: '✳ Claude Code', + hasObservedAgentSignal: true, + hookAgent: null, + hasCompletedHook: false, + processShellForeground: true + } + + it('counts shell-foreground proof as local launched-agent exit evidence', () => { + expect(resolveLaunchedAgentExitEvidence({ ...baseArgs, isRemote: false })).toBe(true) + }) + + it('never counts a shell-foreground flag as remote launched-agent exit evidence', () => { + expect(resolveLaunchedAgentExitEvidence({ ...baseArgs, isRemote: true })).toBe(false) + }) +}) + +describe('useTabAgent process signals', () => { + const clearTabLaunchAgent = vi.fn() + const baseTab: TerminalTab = { + id: 'tab-1', + ptyId: 'pty-1', + worktreeId: 'wt-1', + title: 'Terminal 1', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + + beforeEach(() => { + latestHookAgent = undefined + clearTabLaunchAgent.mockReset() + useAppStore.setState(initialAppState, true) + useAppStore.setState({ + ptyIdsByTabId: { 'tab-1': ['pty-1'] }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: 'pty-1' } + } + }, + agentStatusByPaneKey: {}, + clearTabLaunchAgent + }) + }) + + afterEach(() => { + hookRoots.splice(0).forEach((root) => { + act(() => root.unmount()) + }) + document.body.replaceChildren() + useAppStore.setState(initialAppState, true) + }) + + it('shows the recognized foreground agent for a manual launch with no hooks or titles', async () => { + await renderHookProbe(baseTab) + expect(latestHookAgent).toBeNull() + + await setPaneForeground({ agent: 'aider', shellForeground: false }) + + expect(latestHookAgent).toBe('aider') + expect(clearTabLaunchAgent).not.toHaveBeenCalled() + }) + + it('clears hookless launch identity on shell-foreground evidence despite a stale title', async () => { + const launchedTab = { ...baseTab, launchAgent: 'aider' as const, title: '⠸ aider working' } + const root = await renderHookProbe(launchedTab) + + await setPaneForeground({ agent: 'aider', shellForeground: false }) + expect(latestHookAgent).toBe('aider') + expect(clearTabLaunchAgent).not.toHaveBeenCalled() + + // Why: OSC 133;D marks the launched command exiting; the stale spinner + // title must not keep the launch identity alive. + await setPaneForeground({ agent: null, shellForeground: true }) + await act(async () => { + root.render(createElement(HookProbe, { tab: launchedTab })) + }) + + expect(clearTabLaunchAgent).toHaveBeenCalledWith('tab-1') + }) + + it('does not clear launch identity from shell foreground before any agent activity', async () => { + const launchedTab = { ...baseTab, launchAgent: 'aider' as const } + await renderHookProbe(launchedTab) + + // Pre-start window: the shell prompt (or a quick setup command) finishing + // is not evidence the launched agent ever ran. + await setPaneForeground({ agent: null, shellForeground: true }) + + expect(latestHookAgent).toBe('aider') + expect(clearTabLaunchAgent).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/use-tab-agent.test.ts b/src/renderer/src/lib/use-tab-agent.test.ts index 9880fb9ce85..73978e4f339 100644 --- a/src/renderer/src/lib/use-tab-agent.test.ts +++ b/src/renderer/src/lib/use-tab-agent.test.ts @@ -79,61 +79,37 @@ function twoPaneLayout(): TerminalLayoutSnapshot { } describe('resolveTabAgentFromSignals', () => { - it('uses a recognized foreground agent as the live local source of truth', () => { - expect( - resolveTabAgentFromSignals({ - foreground: 'codex', - hasObservedAgentSignal: true, - shellForegroundAfterAgentSignal: false, - isRemote: false, - title: 'Terminal 1', - hookAgent: 'claude', - hasCompletedHook: false, - launchAgent: 'claude' - }) - ).toBe('codex') - }) - it('keeps launch intent during the pre-start shell window', () => { expect( resolveTabAgentFromSignals({ - foreground: null, hasObservedAgentSignal: false, - shellForegroundAfterAgentSignal: false, isRemote: false, title: 'Terminal 1', hookAgent: null, - hasCompletedHook: false, launchAgent: 'claude' }) ).toBe('claude') }) - it('lets shell foreground clear stale identity even when the title still names an agent', () => { + it('trusts live hook identity at a shell title until the hook row is dropped', () => { expect( resolveTabAgentFromSignals({ - foreground: null, hasObservedAgentSignal: true, - shellForegroundAfterAgentSignal: true, isRemote: false, - title: '✳ Claude Code', + title: 'zsh', hookAgent: 'claude', - hasCompletedHook: false, launchAgent: 'claude' }) - ).toBeNull() + ).toBe('claude') }) it('maps OpenClaude titles to the distinct OpenClaude tab icon', () => { expect( resolveTabAgentFromSignals({ - foreground: undefined, hasObservedAgentSignal: false, - shellForegroundAfterAgentSignal: false, isRemote: false, title: '⠋ OpenClaude', hookAgent: null, - hasCompletedHook: false, launchAgent: undefined }) ).toBe('openclaude') @@ -142,39 +118,30 @@ describe('resolveTabAgentFromSignals', () => { it('keeps title fallback for real Gemini, MiMo, and Pi titles', () => { expect( resolveTabAgentFromSignals({ - foreground: undefined, hasObservedAgentSignal: false, - shellForegroundAfterAgentSignal: false, isRemote: false, title: '✦ Gemini CLI', hookAgent: null, - hasCompletedHook: false, launchAgent: undefined }) ).toBe('gemini') expect( resolveTabAgentFromSignals({ - foreground: undefined, hasObservedAgentSignal: false, - shellForegroundAfterAgentSignal: false, isRemote: false, title: 'MiMo Code', hookAgent: null, - hasCompletedHook: false, launchAgent: undefined }) ).toBe('mimo-code') expect( resolveTabAgentFromSignals({ - foreground: undefined, hasObservedAgentSignal: false, - shellForegroundAfterAgentSignal: false, isRemote: false, title: 'π - my-project', hookAgent: null, - hasCompletedHook: false, launchAgent: undefined }) ).toBe('pi') @@ -183,14 +150,11 @@ describe('resolveTabAgentFromSignals', () => { it("uses completed OpenClaude hook identity over Claude's generic task-title heuristic", () => { expect( resolveTabAgentFromSignals({ - foreground: undefined, hasObservedAgentSignal: true, - shellForegroundAfterAgentSignal: false, isRemote: false, title: '✳ Say hi', hookAgent: null, - hasCompletedHook: true, - completedHookAgent: 'openclaude', + focusedCompletedHookAgent: 'openclaude', launchAgent: 'openclaude' }) ).toBe('openclaude') @@ -199,13 +163,10 @@ describe('resolveTabAgentFromSignals', () => { it('keeps launch identity over title identity while hooks have not arrived', () => { expect( resolveTabAgentFromSignals({ - foreground: undefined, hasObservedAgentSignal: false, - shellForegroundAfterAgentSignal: false, isRemote: false, title: '✳ Say hi', hookAgent: null, - hasCompletedHook: false, launchAgent: 'openclaude' }) ).toBe('openclaude') @@ -214,13 +175,10 @@ describe('resolveTabAgentFromSignals', () => { it("keeps Codex launch intent over Claude's generic spinner title fallback", () => { expect( resolveTabAgentFromSignals({ - foreground: undefined, hasObservedAgentSignal: false, - shellForegroundAfterAgentSignal: false, isRemote: false, title: '⠸ codex-quarter-flash-202606191419', hookAgent: null, - hasCompletedHook: false, launchAgent: 'codex' }) ).toBe('codex') @@ -229,13 +187,10 @@ describe('resolveTabAgentFromSignals', () => { it('does not infer Claude identity from a generic spinner title without context', () => { expect( resolveTabAgentFromSignals({ - foreground: undefined, hasObservedAgentSignal: false, - shellForegroundAfterAgentSignal: false, isRemote: false, title: '⠸ investigating startup', hookAgent: null, - hasCompletedHook: false, launchAgent: undefined }) ).toBeNull() @@ -245,13 +200,10 @@ describe('resolveTabAgentFromSignals', () => { for (const title of ['. investigating startup', '* investigating startup', '✳ investigating']) { expect( resolveTabAgentFromSignals({ - foreground: undefined, hasObservedAgentSignal: false, - shellForegroundAfterAgentSignal: false, isRemote: false, title, hookAgent: null, - hasCompletedHook: false, launchAgent: undefined }) ).toBeNull() @@ -261,13 +213,10 @@ describe('resolveTabAgentFromSignals', () => { it('keeps launch identity over explicit title identity until stronger signals arrive', () => { expect( resolveTabAgentFromSignals({ - foreground: undefined, hasObservedAgentSignal: false, - shellForegroundAfterAgentSignal: false, isRemote: false, title: '⠸ Claude Code', hookAgent: null, - hasCompletedHook: false, launchAgent: 'codex' }) ).toBe('codex') @@ -276,28 +225,22 @@ describe('resolveTabAgentFromSignals', () => { it("uses Codex hook identity over Claude's generic task-title heuristic", () => { expect( resolveTabAgentFromSignals({ - foreground: undefined, hasObservedAgentSignal: true, - shellForegroundAfterAgentSignal: false, isRemote: false, title: '✳ improve-pr-actions-customization', hookAgent: 'codex', - hasCompletedHook: false, launchAgent: 'codex' }) ).toBe('codex') }) - it('keeps launch identity over explicit Claude Code titles without hook or foreground evidence', () => { + it('keeps launch identity over explicit Claude Code titles without hook evidence', () => { expect( resolveTabAgentFromSignals({ - foreground: undefined, hasObservedAgentSignal: false, - shellForegroundAfterAgentSignal: false, isRemote: false, title: '✳ Claude Code', hookAgent: null, - hasCompletedHook: false, launchAgent: 'openclaude' }) ).toBe('openclaude') @@ -306,13 +249,10 @@ describe('resolveTabAgentFromSignals', () => { it('lets an explicit title override stale launch identity after the pane shows newer activity', () => { expect( resolveTabAgentFromSignals({ - foreground: undefined, hasObservedAgentSignal: true, - shellForegroundAfterAgentSignal: false, isRemote: false, title: '✳ Claude Code', hookAgent: null, - hasCompletedHook: false, launchAgent: 'codex' }) ).toBe('claude') @@ -321,43 +261,10 @@ describe('resolveTabAgentFromSignals', () => { it('does not let an explicit title override launch identity before any activity is observed', () => { expect( resolveTabAgentFromSignals({ - foreground: undefined, hasObservedAgentSignal: false, - shellForegroundAfterAgentSignal: false, isRemote: false, title: '✳ Claude Code', hookAgent: null, - hasCompletedHook: false, - launchAgent: 'codex' - }) - ).toBe('codex') - }) - - it('lets shell foreground clear the icon after an agent was observed running', () => { - expect( - resolveTabAgentFromSignals({ - foreground: null, - hasObservedAgentSignal: true, - shellForegroundAfterAgentSignal: true, - isRemote: false, - title: 'zsh', - hookAgent: 'claude', - hasCompletedHook: false, - launchAgent: 'claude' - }) - ).toBeNull() - }) - - it('does not let a pre-start shell sample suppress a later hook signal', () => { - expect( - resolveTabAgentFromSignals({ - foreground: null, - hasObservedAgentSignal: true, - shellForegroundAfterAgentSignal: false, - isRemote: false, - title: 'Terminal 1', - hookAgent: 'codex', - hasCompletedHook: false, launchAgent: 'codex' }) ).toBe('codex') @@ -366,13 +273,10 @@ describe('resolveTabAgentFromSignals', () => { it('prefers explicit hook identity over a conflicting title mention', () => { expect( resolveTabAgentFromSignals({ - foreground: undefined, hasObservedAgentSignal: true, - shellForegroundAfterAgentSignal: false, isRemote: false, title: '✳ Gemini CLI', hookAgent: 'claude', - hasCompletedHook: false, launchAgent: 'claude' }) ).toBe('claude') @@ -381,13 +285,10 @@ describe('resolveTabAgentFromSignals', () => { it('prefers explicit hook identity over ordinary non-Claude title identity', () => { expect( resolveTabAgentFromSignals({ - foreground: undefined, hasObservedAgentSignal: true, - shellForegroundAfterAgentSignal: false, isRemote: false, title: '✦ Gemini CLI', hookAgent: 'claude', - hasCompletedHook: false, launchAgent: 'gemini' }) ).toBe('claude') @@ -396,14 +297,11 @@ describe('resolveTabAgentFromSignals', () => { it('lets focused-pane hook identity override launch metadata in split tabs', () => { expect( resolveTabAgentFromSignals({ - foreground: undefined, hasObservedAgentSignal: true, - shellForegroundAfterAgentSignal: false, isRemote: false, title: 'Terminal 1', hookAgent: 'claude', siblingHookAgent: 'gemini', - hasCompletedHook: false, launchAgent: 'codex' }) ).toBe('claude') @@ -412,14 +310,11 @@ describe('resolveTabAgentFromSignals', () => { it('keeps unresolved launch metadata ahead of sibling-pane hook fallback', () => { expect( resolveTabAgentFromSignals({ - foreground: undefined, hasObservedAgentSignal: false, - shellForegroundAfterAgentSignal: false, isRemote: false, title: 'Terminal 1', hookAgent: null, siblingHookAgent: 'claude', - hasCompletedHook: false, launchAgent: 'codex' }) ).toBe('codex') @@ -428,29 +323,23 @@ describe('resolveTabAgentFromSignals', () => { it('uses sibling-pane hook fallback when no launch metadata exists', () => { expect( resolveTabAgentFromSignals({ - foreground: undefined, hasObservedAgentSignal: false, - shellForegroundAfterAgentSignal: false, isRemote: false, title: 'Terminal 1', hookAgent: null, siblingHookAgent: 'claude', - hasCompletedHook: false, launchAgent: undefined }) ).toBe('claude') }) - it('keeps launch identity over Claude-owned task text without hook or foreground evidence', () => { + it('keeps launch identity over Claude-owned task text without hook evidence', () => { expect( resolveTabAgentFromSignals({ - foreground: undefined, hasObservedAgentSignal: false, - shellForegroundAfterAgentSignal: false, isRemote: false, title: '✳ Gemini CLI', hookAgent: null, - hasCompletedHook: false, launchAgent: 'gemini' }) ).toBe('gemini') @@ -459,26 +348,20 @@ describe('resolveTabAgentFromSignals', () => { it('keeps launch identity over Claude-owned punctuation-prefixed task text', () => { expect( resolveTabAgentFromSignals({ - foreground: undefined, hasObservedAgentSignal: false, - shellForegroundAfterAgentSignal: false, isRemote: false, title: '. Compare Opencode Vs Orca', hookAgent: null, - hasCompletedHook: false, launchAgent: 'opencode' }) ).toBe('opencode') expect( resolveTabAgentFromSignals({ - foreground: undefined, hasObservedAgentSignal: false, - shellForegroundAfterAgentSignal: false, isRemote: false, title: '* Review Codex behavior', hookAgent: null, - hasCompletedHook: false, launchAgent: 'codex' }) ).toBe('codex') @@ -487,56 +370,87 @@ describe('resolveTabAgentFromSignals', () => { it('treats Claude-prefixed title text as Claude only when it names Claude', () => { expect( resolveTabAgentFromSignals({ - foreground: undefined, hasObservedAgentSignal: false, - shellForegroundAfterAgentSignal: false, isRemote: false, title: '✳ Claude Code', hookAgent: null, - hasCompletedHook: false, launchAgent: undefined }) ).toBe('claude') expect( resolveTabAgentFromSignals({ - foreground: undefined, hasObservedAgentSignal: false, - shellForegroundAfterAgentSignal: false, isRemote: false, title: '. Claude Code compare Opencode', hookAgent: null, - hasCompletedHook: false, launchAgent: undefined }) ).toBe('claude') }) - it('keeps local launch identity when only a shell title suggests exit', () => { + it('clears local launch identity once observed activity vanishes at a shell title', () => { + // Why: matches the clear effect — the dropped hook row plus a shell title + // is the crash/kill exit evidence, so the resolver must not lag it. expect( resolveTabAgentFromSignals({ - foreground: undefined, hasObservedAgentSignal: true, - shellForegroundAfterAgentSignal: false, isRemote: false, title: 'zsh', hookAgent: null, - hasCompletedHook: false, launchAgent: 'codex' }) - ).toBe('codex') + ).toBeNull() }) - it('skips local foreground authority for remote worktrees', () => { + it('keeps launch identity at a shell title while a sibling hook row is live', () => { + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: false, + title: 'zsh', + hookAgent: null, + siblingHookAgent: 'gemini', + launchAgent: 'claude' + }) + ).toBe('claude') + }) + + it('keeps remote launch identity at a shell title without completed-hook evidence', () => { + // Why: remote hook rows also drop on transport blips, so vanished activity + // alone must not count as exit evidence for remote panes. + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: true, + title: 'zsh', + hookAgent: null, + launchAgent: 'claude' + }) + ).toBe('claude') + }) + + it('treats the neutral default title as exit evidence alongside shell titles', () => { + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: false, + title: 'Terminal 2', + defaultTitle: 'Terminal 2', + hookAgent: null, + focusedCompletedHookAgent: 'claude', + launchAgent: 'claude' + }) + ).toBeNull() + }) + + it('keeps hook identity for remote panes', () => { expect( resolveTabAgentFromSignals({ - foreground: null, hasObservedAgentSignal: true, - shellForegroundAfterAgentSignal: true, isRemote: true, title: 'Terminal 1', hookAgent: 'codex', - hasCompletedHook: false, launchAgent: 'claude' }) ).toBe('codex') @@ -545,32 +459,30 @@ describe('resolveTabAgentFromSignals', () => { it('keeps completed remote hook identity after the terminal title returns to a shell', () => { expect( resolveTabAgentFromSignals({ - foreground: undefined, hasObservedAgentSignal: true, - shellForegroundAfterAgentSignal: false, isRemote: true, title: 'zsh', hookAgent: null, - hasCompletedHook: true, - completedHookAgent: 'codex', + focusedCompletedHookAgent: 'codex', launchAgent: 'codex' }) ).toBe('codex') }) - it('keeps local launch identity after a completed hook until foreground proves shell exit', () => { + it('clears local launch identity once a completed hook and shell title prove exit', () => { + // Why: without foreground probing, a completed hook plus the title back at + // a shell is the process-gone evidence — the same signals that clear the + // sidebar row — so stale launch identity must not keep painting the tab. expect( resolveTabAgentFromSignals({ - foreground: undefined, hasObservedAgentSignal: true, - shellForegroundAfterAgentSignal: false, isRemote: false, title: 'zsh', hookAgent: null, - hasCompletedHook: true, + focusedCompletedHookAgent: 'claude', launchAgent: 'claude' }) - ).toBe('claude') + ).toBeNull() }) }) @@ -619,103 +531,84 @@ describe('useTabAgent', () => { window.api = originalApi }) - it('uses unrecognized non-shell foreground as launch lifecycle evidence', async () => { - getForegroundProcess.mockResolvedValueOnce('node').mockResolvedValueOnce('zsh') + it('never probes the foreground process', async () => { + const paneKey = makePaneKey('tab-1', LEAF_ID) + useAppStore.setState({ + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: 'pty-1' } + } + }, + agentStatusByPaneKey: { + [paneKey]: workingAgentStatus(paneKey) + } + }) + + const root = await renderHookProbe(baseTab) + await rerenderHookProbe(root, { ...baseTab, title: '✳ Codex' }) + await rerenderHookProbe(root, { ...baseTab, title: 'zsh' }) + + expect(latestHookAgent).toBe('codex') + expect(getForegroundProcess).not.toHaveBeenCalled() + }) + + it('does not clear launch identity while the live hook row persists at a shell title', async () => { + const paneKey = makePaneKey('tab-1', LEAF_ID) + useAppStore.setState({ + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: 'pty-1' } + } + }, + agentStatusByPaneKey: { + [paneKey]: workingAgentStatus(paneKey) + } + }) + + await renderHookProbe({ ...baseTab, title: 'zsh' }) + + expect(latestHookAgent).toBe('codex') + expect(clearTabLaunchAgent).not.toHaveBeenCalled() + }) + + it('clears launch identity when a previously observed hook row drops at a shell title', async () => { + const paneKey = makePaneKey('tab-1', LEAF_ID) + useAppStore.setState({ + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: 'pty-1' } + } + }, + agentStatusByPaneKey: { + [paneKey]: workingAgentStatus(paneKey) + } + }) const root = await renderHookProbe(baseTab) expect(latestHookAgent).toBe('codex') expect(clearTabLaunchAgent).not.toHaveBeenCalled() + // Why: crash/kill exits drop the live row without a completed hook. + await act(async () => { + useAppStore.setState({ agentStatusByPaneKey: {} }) + }) await rerenderHookProbe(root, { ...baseTab, title: 'zsh' }) - expect(clearTabLaunchAgent).toHaveBeenCalledExactlyOnceWith('tab-1') - expect(latestHookAgent).toBeNull() - expect(getForegroundProcess).toHaveBeenCalledTimes(2) - }) - - it('retries helper foreground so daemon-derived agent beats stale launch identity', async () => { - vi.useFakeTimers() - getForegroundProcess.mockResolvedValueOnce('uv').mockResolvedValueOnce('claude') - - try { - await renderHookProbe({ ...baseTab, launchAgent: 'opencode' }) - - expect(latestHookAgent).toBe('opencode') - expect(getForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1') - - await act(async () => { - await vi.advanceTimersByTimeAsync(250) - }) - await flushHookEffects() - - expect(getForegroundProcess).toHaveBeenCalledTimes(2) - expect(latestHookAgent).toBe('claude') - } finally { - vi.clearAllTimers() - vi.useRealTimers() - } - }) - - it('does not retry unknown helper foreground without launch intent', async () => { - vi.useFakeTimers() - getForegroundProcess.mockResolvedValue('uv') - - try { - await renderHookProbe({ ...baseTab, launchAgent: undefined }) - - await act(async () => { - await vi.advanceTimersByTimeAsync(10_000) - }) - await flushHookEffects() - - expect(getForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1') - expect(latestHookAgent).toBeNull() - } finally { - vi.clearAllTimers() - vi.useRealTimers() - } - }) - - it('keeps one post-throttle shell retry to observe daemon-derived launch identity', async () => { - vi.useFakeTimers() - getForegroundProcess - .mockResolvedValueOnce('zsh') - .mockResolvedValueOnce('zsh') - .mockResolvedValueOnce('zsh') - .mockResolvedValueOnce('zsh') - .mockResolvedValueOnce('claude') - - try { - await renderHookProbe({ ...baseTab, launchAgent: 'opencode' }) - - expect(latestHookAgent).toBe('opencode') - expect(getForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1') - - await act(async () => { - await vi.advanceTimersByTimeAsync(250 + 1250 + 3500) - }) - await flushHookEffects() - - expect(getForegroundProcess).toHaveBeenCalledTimes(4) - expect(latestHookAgent).toBe('opencode') - - await act(async () => { - await vi.advanceTimersByTimeAsync(750) - }) - await flushHookEffects() - - expect(getForegroundProcess).toHaveBeenCalledTimes(5) - expect(latestHookAgent).toBe('claude') - } finally { - vi.clearAllTimers() - vi.useRealTimers() - } + expect(clearTabLaunchAgent).toHaveBeenCalledWith('tab-1') }) it('uses completed local hook status as launch lifecycle evidence after remount', async () => { const paneKey = makePaneKey('tab-1', LEAF_ID) - getForegroundProcess.mockResolvedValueOnce('zsh') useAppStore.setState({ agentStatusByPaneKey: { [paneKey]: completedAgentStatus(paneKey) @@ -724,9 +617,9 @@ describe('useTabAgent', () => { await renderHookProbe({ ...baseTab, title: 'zsh' }) - expect(clearTabLaunchAgent).toHaveBeenCalledExactlyOnceWith('tab-1') + expect(clearTabLaunchAgent).toHaveBeenCalledWith('tab-1') expect(latestHookAgent).toBeNull() - expect(getForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1') + expect(getForegroundProcess).not.toHaveBeenCalled() }) it('treats paired runtime PTYs as remote-like for completed hook fallback', async () => { @@ -771,7 +664,6 @@ describe('useTabAgent', () => { it('does not use completed sibling hook status as focused launch lifecycle evidence', async () => { const siblingPaneKey = makePaneKey('tab-1', SECOND_LEAF_ID) - getForegroundProcess.mockResolvedValueOnce('zsh') useAppStore.setState({ ptyIdsByTabId: { 'tab-1': ['pty-focus', 'pty-sibling'] }, terminalLayoutsByTabId: { 'tab-1': twoPaneLayout() }, @@ -789,6 +681,144 @@ describe('useTabAgent', () => { expect(latestHookAgent).toBe('claude') expect(clearTabLaunchAgent).not.toHaveBeenCalled() - expect(getForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-focus') + expect(getForegroundProcess).not.toHaveBeenCalled() + }) + + it('does not clear remote launch identity when the hook row drops at a shell title', async () => { + const remotePtyId = 'remote:web-env-1@@terminal-1' + const paneKey = makePaneKey('tab-1', LEAF_ID) + useAppStore.setState({ + ptyIdsByTabId: { 'tab-1': [remotePtyId] }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: remotePtyId } + } + }, + agentStatusByPaneKey: { + [paneKey]: workingAgentStatus(paneKey) + } + }) + const remoteTab = { ...baseTab, ptyId: remotePtyId, launchAgent: 'claude' as const } + + const root = await renderHookProbe(remoteTab) + + // Why: remote rows also drop on transport blips (reconnect, snapshot gaps) + // that say nothing about the process — only a completed hook may clear. + await act(async () => { + useAppStore.setState({ agentStatusByPaneKey: {} }) + }) + await rerenderHookProbe(root, { ...remoteTab, title: 'zsh' }) + + expect(clearTabLaunchAgent).not.toHaveBeenCalled() + expect(latestHookAgent).toBe('claude') + }) + + it('does not clear launch identity while a sibling hook row is still live', async () => { + const focusedPaneKey = makePaneKey('tab-1', LEAF_ID) + const siblingPaneKey = makePaneKey('tab-1', SECOND_LEAF_ID) + useAppStore.setState({ + ptyIdsByTabId: { 'tab-1': ['pty-focus', 'pty-sibling'] }, + terminalLayoutsByTabId: { 'tab-1': twoPaneLayout() }, + agentStatusByPaneKey: { + [focusedPaneKey]: workingAgentStatus(focusedPaneKey), + [siblingPaneKey]: workingAgentStatus(siblingPaneKey) + } + }) + const splitTab = { ...baseTab, ptyId: 'pty-focus', launchAgent: 'claude' as const } + + const root = await renderHookProbe(splitTab) + + // Focused row drops but the sibling agent still runs: no exit evidence yet. + await act(async () => { + useAppStore.setState({ + agentStatusByPaneKey: { [siblingPaneKey]: workingAgentStatus(siblingPaneKey) } + }) + }) + await rerenderHookProbe(root, { ...splitTab, title: 'zsh' }) + expect(clearTabLaunchAgent).not.toHaveBeenCalled() + + await act(async () => { + useAppStore.setState({ agentStatusByPaneKey: {} }) + }) + await rerenderHookProbe(root, { ...splitTab, title: 'zsh' }) + expect(clearTabLaunchAgent).toHaveBeenCalledWith('tab-1') + }) + + it('clears launch identity at the neutral default title after a completed hook', async () => { + const paneKey = makePaneKey('tab-1', LEAF_ID) + useAppStore.setState({ + agentStatusByPaneKey: { + [paneKey]: completedAgentStatus(paneKey) + } + }) + + // Why: the inferred-interrupt flow resets titles to the tab's default + // ("Terminal N"), not a shell name — that must still count as exit. + await renderHookProbe({ ...baseTab, title: 'Terminal 3', defaultTitle: 'Terminal 3' }) + + expect(clearTabLaunchAgent).toHaveBeenCalledWith('tab-1') + }) + + it('clears hookless launch identity once its own title evidence ends at a shell', async () => { + const geminiTab = { ...baseTab, launchAgent: 'gemini' as const, title: '✦ Gemini CLI' } + + // Why: agents without hook integration prove activity via a title naming + // the launched agent; the later shell title is then exit evidence. + const root = await renderHookProbe(geminiTab) + expect(clearTabLaunchAgent).not.toHaveBeenCalled() + + await rerenderHookProbe(root, { ...geminiTab, title: 'zsh' }) + + expect(clearTabLaunchAgent).toHaveBeenCalledWith('tab-1') + }) + + it('does not treat a layout-less multi-pane completed row as focused exit evidence', async () => { + const siblingPaneKey = makePaneKey('tab-1', SECOND_LEAF_ID) + useAppStore.setState({ + ptyIdsByTabId: { 'tab-1': ['pty-focus', 'pty-sibling'] }, + terminalLayoutsByTabId: {}, + agentStatusByPaneKey: { + [siblingPaneKey]: completedAgentStatus(siblingPaneKey) + } + }) + + await renderHookProbe({ ...baseTab, ptyId: 'pty-focus', title: 'zsh', launchAgent: 'claude' }) + + expect(clearTabLaunchAgent).not.toHaveBeenCalled() + }) + + it('does not clear launch identity on the commit that switches pane generations', async () => { + const paneKey = makePaneKey('tab-1', LEAF_ID) + const boundLayout = { + root: { type: 'leaf', leafId: LEAF_ID } as const, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: 'pty-1' } + } + useAppStore.setState({ + terminalLayoutsByTabId: { 'tab-1': boundLayout }, + agentStatusByPaneKey: { [paneKey]: workingAgentStatus(paneKey) } + }) + + const root = await renderHookProbe(baseTab) + expect(latestHookAgent).toBe('codex') + + // Why: a respawn/focus switch can land the new ptyId, the dropped row, and + // a shell title in one commit — the previous generation's observed signal + // must not clear the new generation's launch identity. + await act(async () => { + useAppStore.setState({ + terminalLayoutsByTabId: { + 'tab-1': { ...boundLayout, ptyIdsByLeafId: { [LEAF_ID]: 'pty-2' } } + }, + agentStatusByPaneKey: {} + }) + }) + await rerenderHookProbe(root, { ...baseTab, title: 'zsh' }) + + expect(clearTabLaunchAgent).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/lib/use-tab-agent.ts b/src/renderer/src/lib/use-tab-agent.ts index 0b24f252eb4..6253ced22f4 100644 --- a/src/renderer/src/lib/use-tab-agent.ts +++ b/src/renderer/src/lib/use-tab-agent.ts @@ -1,10 +1,9 @@ -/* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: tab agent foreground state is synchronized from PTY/remote agent signals and shell foreground events. */ import { useEffect, useRef, useState } from 'react' import { useAppStore } from '@/store' -import { recognizeAgentProcess } from '../../../shared/agent-process-recognition' import { isShellProcess } from '../../../shared/agent-detection' import { worktreeUsesRemoteConnection } from '@/store/slices/terminals' import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' +import { isTerminalLeafId, makePaneKey } from '../../../shared/stable-pane-id' import { resolveFocusedCompletedTabAgent, resolveFocusedTabAgent, @@ -16,37 +15,58 @@ import type { TerminalTab, TuiAgent } from '../../../shared/types' export { resolveExplicitTerminalTitleAgentType as resolveTabAgentFromTitle } from '../../../shared/terminal-title-agent-type' -const HELPER_FOREGROUND_RETRY_DELAYS_MS = [250, 1250, 3500, 750] as const - -function getTitleForegroundKey(title: string, launchAgent?: TuiAgent): string { - const titleAgent = launchAgent ? null : resolveExplicitTerminalTitleAgentType(title) - if (titleAgent) { - return `agent:${titleAgent}` - } - if (isShellProcess(title)) { - return 'shell' - } - const stableTitle = title - .trim() - .toLowerCase() - // Why: unknown agents may still animate leading status glyphs. Include the - // stable title body so first launch from "Terminal 1" triggers one poll, - // without polling on every spinner frame. - .replace(/^(?:[✳✦⏲◇✋⠀-⣿]+|[.*]\s)\s*/, '') - .slice(0, 48) - return `unknown:${stableTitle}` +// A shell name, or the tab's neutral default title — where Orca's +// inferred-interrupt reset parks it. Blank titles are no evidence either way. +function titleShowsNoAgent(title: string, defaultTitle?: string): boolean { + const trimmed = title.trim() + return trimmed.length > 0 && (isShellProcess(trimmed) || trimmed === defaultTitle?.trim()) } -export function resolveTabAgentFromSignals(args: { - foreground: TuiAgent | null | undefined - hasObservedAgentSignal: boolean - shellForegroundAfterAgentSignal: boolean - isRemote: boolean +/** + * Probe-free evidence that a launched agent exited: the title shows no agent, + * no live hook row remains in the tab, and either the hook completed or + * previously observed activity vanished. The vanished-activity disjunct is + * local-only: remote rows also drop on transport blips that say nothing about + * the process. + */ +export function resolveLaunchedAgentExitEvidence(args: { title: string + defaultTitle?: string + isRemote: boolean + hasObservedAgentSignal: boolean hookAgent: TuiAgent | null siblingHookAgent?: TuiAgent | null hasCompletedHook: boolean - completedHookAgent?: TuiAgent | null + processAgent?: TuiAgent | null + processShellForeground?: boolean +}): boolean { + if (args.hookAgent || args.siblingHookAgent || args.processAgent) { + return false + } + // Why: OSC 133;D proved the foreground returned to the shell — process-grade + // exit evidence that doesn't depend on the agent leaving a clean title. + // Local-only by construction (remote panes have no shell-foreground producer); + // the gate keeps the invariant even for out-of-pipeline callers. + if (!args.isRemote && args.processShellForeground && args.hasObservedAgentSignal) { + return true + } + if (!titleShowsNoAgent(args.title, args.defaultTitle)) { + return false + } + return args.hasCompletedHook || (!args.isRemote && args.hasObservedAgentSignal) +} + +export function resolveTabAgentFromSignals(args: { + hasObservedAgentSignal: boolean + isRemote: boolean + title: string + defaultTitle?: string + hookAgent: TuiAgent | null + siblingHookAgent?: TuiAgent | null + focusedCompletedHookAgent?: TuiAgent | null + siblingCompletedHookAgent?: TuiAgent | null + processAgent?: TuiAgent | null + processShellForeground?: boolean launchAgent?: TuiAgent }): TuiAgent | null { const launchAgent = args.launchAgent ?? null @@ -55,62 +75,71 @@ export function resolveTabAgentFromSignals(args: { // A live title that explicitly names a *different* agent, once the pane has // shown any activity, overrides that stale launch identity so the tab icon // tracks what is actually running (codex launch reused for claude, etc.). + // Why: OSC 133;D proved this local pane's foreground is back at the shell, + // so any title-derived identity is stale by definition — a TUI that died + // with a stuck title must not keep painting the tab through the title layer. + const processProvesShell = !args.isRemote && args.processShellForeground === true const titleOverridesLaunch = launchAgent !== null && explicitTitleAgent !== null && explicitTitleAgent !== launchAgent && args.hasObservedAgentSignal - const titleAgent = titleOverridesLaunch - ? explicitTitleAgent - : launchAgent - ? null - : explicitTitleAgent - const titleLooksShell = isShellProcess(args.title) - // Why: remote panes cannot cheaply prove shell foreground after hook exit, - // so keep the last completed hook identity instead of flashing unknown. + const titleAgent = processProvesShell + ? null + : titleOverridesLaunch + ? explicitTitleAgent + : launchAgent + ? null + : explicitTitleAgent + const hasCompletedHook = (args.focusedCompletedHookAgent ?? null) !== null + const noAgentTitle = titleShowsNoAgent(args.title, args.defaultTitle) + // Why: remote pane titles can lag their runtime, so keep the last completed + // hook identity instead of flashing unknown when the title reads as a shell. const completedHookAgent = - !args.isRemote && titleLooksShell && args.hasCompletedHook ? null : args.completedHookAgent + !args.isRemote && (noAgentTitle || processProvesShell) && hasCompletedHook + ? null + : (args.focusedCompletedHookAgent ?? args.siblingCompletedHookAgent ?? null) const focusedHookAgent = args.hookAgent ?? null const fallbackHookAgent = args.siblingHookAgent ?? completedHookAgent ?? null - const localShellForegroundClearedLaunch = - !args.isRemote && args.foreground === null && args.shellForegroundAfterAgentSignal - const remoteCompletedHookAtShellTitle = args.isRemote && titleLooksShell && args.hasCompletedHook - const activeLaunchAgent = - localShellForegroundClearedLaunch || remoteCompletedHookAtShellTitle ? null : launchAgent - // Why: titleAgent now ranks ahead of launch/fallback hooks because, once the + const launchedAgentExited = resolveLaunchedAgentExitEvidence({ + title: args.title, + defaultTitle: args.defaultTitle, + isRemote: args.isRemote, + hasObservedAgentSignal: args.hasObservedAgentSignal, + hookAgent: focusedHookAgent, + siblingHookAgent: args.siblingHookAgent, + hasCompletedHook, + processAgent: args.processAgent, + processShellForeground: args.processShellForeground + }) + const activeLaunchAgent = launchedAgentExited ? null : launchAgent + const processAgent = args.processAgent ?? null + // Why: titleAgent ranks ahead of launch/fallback hooks because, once the // pane has shown activity, a live explicit title is the freshest identity // signal — it beats a launchAgent gone stale through pane reuse. Before any // activity, titleAgent is null while launchAgent exists, so launch bootstrap - // still wins the startup window. - if (args.isRemote || args.foreground === undefined) { - return focusedHookAgent ?? titleAgent ?? activeLaunchAgent ?? fallbackHookAgent - } - if (args.foreground) { - return args.foreground - } - // Why: once a local pane has returned to a shell, a stale hook should not keep - // painting it as an agent tab. - if (args.shellForegroundAfterAgentSignal) { - return null - } - return focusedHookAgent ?? titleAgent ?? activeLaunchAgent ?? fallbackHookAgent + // still wins the startup window. Process identity (the recognized foreground + // process) is ground truth below hooks — it covers agents that emit neither + // hooks nor titles. + return focusedHookAgent ?? processAgent ?? titleAgent ?? activeLaunchAgent ?? fallbackHookAgent } /** * Resolve which coding-harness agent a terminal tab is running, for its tab-bar - * icon. Layered signals, most-authoritative first: + * icon. Identity flows through the same already-computed state as the sidebar + * agent rows — no foreground probing. Layered signals, most-authoritative + * first: * - * 1. Live foreground process — the ground truth for what's running *now*: the - * only signal that reverts to the terminal glyph when the agent exits to a - * shell, or flips when a different agent starts in the same pane. Checked - * event-driven (only when the tab's title changes — exactly when an agent - * starts/exits/takes a turn), never on an interval, and only for local panes - * (SSH foreground inspection is a 15s-timeout RPC). A recognized agent wins; - * a recognized shell authoritatively means "no agent". - * 2. Hook status — accurate provider identity from native integrations, and - * available for SSH/remote panes where foreground polling is too costly. - * 3. launchAgent — what Orca launched here; instant bootstrap before hooks or - * foreground polling arrive, and the owned identity for startup windows. + * 1. Hook status — provider identity from native integrations; the live entry + * for the pane, dropped by the same OSC 133 command-finished machinery that + * clears the sidebar row when the process exits. + * 2. Process identity — the recognized foreground process, read at OSC 133 + * command boundaries (local panes only); covers agents that emit neither + * hooks nor titles, and its shell-foreground mark is title-independent + * exit evidence. + * 3. launchAgent — what Orca launched here; instant bootstrap before hooks + * arrive, cleared once hook/process/title evidence shows the launched + * agent exited. * 4. Title — legacy/unknown-session fallback, and the live override when a pane * is reused: once the pane has shown activity, a title that explicitly names * a different agent than launchAgent wins over that stale launch identity. @@ -138,11 +167,23 @@ export function useTabAgent(tab: TerminalTab): TuiAgent | null { tab.id ) ) - const completedHookAgent = focusedCompletedHookAgent ?? siblingCompletedHookAgent const hasCompletedHook = focusedCompletedHookAgent !== null const clearTabLaunchAgent = useAppStore((s) => s.clearTabLaunchAgent) + const focusedPaneKey = useAppStore((s) => { + const activeLeafId = s.terminalLayoutsByTabId[tab.id]?.activeLeafId + return activeLeafId && isTerminalLeafId(activeLeafId) ? makePaneKey(tab.id, activeLeafId) : null + }) + const processAgent = useAppStore((s) => + focusedPaneKey ? (s.paneForegroundAgentByPaneKey[focusedPaneKey]?.agent ?? null) : null + ) + const processShellForeground = useAppStore((s) => + focusedPaneKey + ? Boolean(s.paneForegroundAgentByPaneKey[focusedPaneKey]?.shellForeground) + : false + ) - // The focused pane's PTY (single-pane tabs have exactly one leaf). + // The focused pane's PTY (single-pane tabs have exactly one leaf). Only used + // to reset per-process-generation signals when the pane is respawned. const ptyId = useAppStore((s) => { const layout = s.terminalLayoutsByTabId[tab.id] const activeLeafId = layout?.activeLeafId @@ -151,10 +192,18 @@ export function useTabAgent(tab: TerminalTab): TuiAgent | null { return leafPty } const ptyIds = s.ptyIdsByTabId[tab.id] ?? [] - // Why: without a focused leaf, a split tab's first PTY can be a sibling - // shell. Only single-PTY fallback foreground is authoritative. return ptyIds.length === 1 ? ptyIds[0]! : null }) + // Why: with no layout to prove which pane a completed row belongs to, only a + // single-pane tab may treat it as focused-pane exit evidence — a sibling's + // done row must not clear another pane's launch identity. + const completedHookScopeKnown = useAppStore((s) => { + const layout = s.terminalLayoutsByTabId[tab.id] + if (layout?.activeLeafId && isTerminalLeafId(layout.activeLeafId)) { + return true + } + return (s.ptyIdsByTabId[tab.id] ?? []).length <= 1 + }) const hasRemoteRuntimePty = useAppStore((s) => { const layout = s.terminalLayoutsByTabId[tab.id] const ptyIds = new Set(s.ptyIdsByTabId[tab.id] ?? []) @@ -166,134 +215,92 @@ export function useTabAgent(tab: TerminalTab): TuiAgent | null { const isRemoteWorktree = useAppStore((s) => worktreeUsesRemoteConnection(s, tab.worktreeId)) const isRemoteLike = isRemoteWorktree || hasRemoteRuntimePty - // undefined = no conclusive local reading (defer to title/hook/launchAgent); - // null = foreground is a shell; TuiAgent = recognized agent process. - const [foreground, setForeground] = useState(undefined) const [hasObservedAgentSignal, setHasObservedAgentSignal] = useState(false) - const [shellForegroundAfterAgentSignal, setShellForegroundAfterAgentSignal] = useState(false) const hasObservedAgentSignalRef = useRef(false) - const titleForegroundKey = getTitleForegroundKey(tab.title, tab.launchAgent) + const signalGenerationRef = useRef(null) + const completedHookEvidence = hasCompletedHook && completedHookScopeKnown useEffect(() => { - setForeground(undefined) - setHasObservedAgentSignal(false) - hasObservedAgentSignalRef.current = false - setShellForegroundAfterAgentSignal(false) - }, [ptyId, isRemoteLike]) - - useEffect(() => { - const fallbackAgentSignal = - !tab.launchAgent && (resolveExplicitTerminalTitleAgentType(tab.title) || siblingHookAgent) - // Why: a completed structured hook proves a launched agent existed, but - // local launch cleanup still waits for current foreground-shell evidence. - if (focusedHookAgent || hasCompletedHook || fallbackAgentSignal) { + // Why: reset and re-seed in one effect so a pane respawn both invalidates + // the previous generation's signal and immediately re-observes a still-live + // hook row instead of leaving the signal stuck false. + const generation = `${ptyId ?? ''}|${String(isRemoteLike)}` + if (signalGenerationRef.current !== generation) { + signalGenerationRef.current = generation + hasObservedAgentSignalRef.current = false + setHasObservedAgentSignal(false) + } + const explicitTitleAgent = resolveExplicitTerminalTitleAgentType(tab.title) + // Why: for launched panes, only a title naming the launched agent counts as + // its activity — other-agent or sibling evidence must not arm exit clearing + // for an agent that never produced evidence of its own. + const fallbackAgentSignal = tab.launchAgent + ? explicitTitleAgent === tab.launchAgent + : Boolean(explicitTitleAgent || siblingHookAgent) + // Why: a recognized foreground process is focused-pane ground truth, so it + // arms exit clearing even for agents with no hook or title integration. + if (focusedHookAgent || completedHookEvidence || processAgent || fallbackAgentSignal) { hasObservedAgentSignalRef.current = true setHasObservedAgentSignal(true) } - }, [focusedHookAgent, hasCompletedHook, siblingHookAgent, tab.launchAgent, tab.title]) - - useEffect(() => { - if (!ptyId || isRemoteLike) { - return - } - const localPtyId = ptyId - let cancelled = false - const helperForegroundRetryTimers: number[] = [] - // Why: re-runs when ptyId or tab.title changes — a title change is the event - // signalling a possible foreground transition (agent start, exit, or turn). - // One RPC per transition, not a timer; cancellation coalesces rapid churn. - function readForeground(retryIndex = 0): void { - window.api.pty - .getForegroundProcess(localPtyId) - .then((process) => { - applyForegroundProcess(process, retryIndex) - }) - .catch(() => { - if (!cancelled) { - setForeground(undefined) - } - }) - } - function scheduleHelperForegroundRetry(retryIndex: number): void { - const delay = HELPER_FOREGROUND_RETRY_DELAYS_MS[retryIndex] - if (delay === undefined) { - return - } - // Why: the daemon resolves shell/helper -> agent ancestry asynchronously - // after the first foreground read, so give its short cache a bounded re-read. - const timer = window.setTimeout(() => { - readForeground(retryIndex + 1) - }, delay) - helperForegroundRetryTimers.push(timer) - } - function applyForegroundProcess(process: string | null, retryIndex: number): void { - if (cancelled) { - return - } - const recognized = recognizeAgentProcess(process) - if (recognized) { - hasObservedAgentSignalRef.current = true - setHasObservedAgentSignal(true) - setForeground(recognized.agent) - } else if (process && isShellProcess(process)) { - setShellForegroundAfterAgentSignal(hasObservedAgentSignalRef.current) - setForeground(null) - if (tab.launchAgent && !hasObservedAgentSignalRef.current) { - scheduleHelperForegroundRetry(retryIndex) - } - } else { - if (process && tab.launchAgent) { - // Why: for Orca-owned launches, an unrecognized non-shell process - // is enough lifecycle evidence to clear launch intent when the pane - // later returns to a shell, without using title text as identity. - hasObservedAgentSignalRef.current = true - setHasObservedAgentSignal(true) - } - setForeground(undefined) - if (process && tab.launchAgent) { - scheduleHelperForegroundRetry(retryIndex) - } - } - } - readForeground() - return () => { - cancelled = true - helperForegroundRetryTimers.forEach((timer) => window.clearTimeout(timer)) - } - }, [ptyId, isRemoteLike, tab.launchAgent, titleForegroundKey]) + }, [ + ptyId, + isRemoteLike, + focusedHookAgent, + completedHookEvidence, + processAgent, + siblingHookAgent, + tab.launchAgent, + tab.title + ]) useEffect(() => { if (!tab.launchAgent) { return } - const titleLooksShell = isShellProcess(tab.title) - const foregroundSawExitedAgent = - !isRemoteLike && foreground === null && shellForegroundAfterAgentSignal - const remoteHookCompletedAtShellTitle = isRemoteLike && hasCompletedHook && titleLooksShell - if (foregroundSawExitedAgent || remoteHookCompletedAtShellTitle) { + // Why: AND the state with the ref — the ref is generation-safe within this + // commit (the observe effect above already reset it), while the state can + // lag one render behind a pane focus/respawn switch. + const launchedAgentExited = resolveLaunchedAgentExitEvidence({ + title: tab.title, + defaultTitle: tab.defaultTitle, + isRemote: isRemoteLike, + hasObservedAgentSignal: hasObservedAgentSignal && hasObservedAgentSignalRef.current, + hookAgent: focusedHookAgent, + siblingHookAgent, + hasCompletedHook: completedHookEvidence, + processAgent, + processShellForeground + }) + if (launchedAgentExited) { clearTabLaunchAgent(tab.id) } }, [ clearTabLaunchAgent, - foreground, - hasCompletedHook, + completedHookEvidence, + focusedHookAgent, + siblingHookAgent, + hasObservedAgentSignal, isRemoteLike, - shellForegroundAfterAgentSignal, + processAgent, + processShellForeground, + tab.defaultTitle, tab.id, tab.launchAgent, tab.title ]) return resolveTabAgentFromSignals({ - foreground, hasObservedAgentSignal, - shellForegroundAfterAgentSignal, isRemote: isRemoteLike, title: tab.title, + defaultTitle: tab.defaultTitle, hookAgent: focusedHookAgent, siblingHookAgent, - hasCompletedHook, - completedHookAgent, + focusedCompletedHookAgent, + siblingCompletedHookAgent, + processAgent, + processShellForeground, launchAgent: tab.launchAgent }) } diff --git a/src/renderer/src/lib/window-label-formatter.test.ts b/src/renderer/src/lib/window-label-formatter.test.ts index 730afbbd5ad..01050c93cc7 100644 --- a/src/renderer/src/lib/window-label-formatter.test.ts +++ b/src/renderer/src/lib/window-label-formatter.test.ts @@ -37,4 +37,21 @@ describe('formatWindowLabel', () => { it('returns "3d" for 4320 minutes (3 days)', () => { expect(formatWindowLabel(4320)).toBe('3d') }) + + it('documents the formatter contract: 295 minutes stays raw ("295m")', () => { + // Why: the formatter does NOT snap — the snap lives in the MiniMax + // fetcher (snapMiniMaxWindowMinutes), where raw drift of a few minutes + // rounds to the canonical 300-minute bucket before reaching this label. + // This test pins the contract so a future change to the formatter + // does not silently regress the snap's "5h" output. + expect(formatWindowLabel(295)).toBe('295m') + expect(formatWindowLabel(300)).toBe('5h') + }) + + it('falls back to per-minute labels outside canonical buckets', () => { + // Why: when the window length lands between buckets (e.g. 2h30m), we + // render the raw minute count rather than guess at a half-bucket label. + expect(formatWindowLabel(75)).toBe('75m') + expect(formatWindowLabel(150)).toBe('150m') + }) }) diff --git a/src/renderer/src/lib/work-item-lookup-text.test.ts b/src/renderer/src/lib/work-item-lookup-text.test.ts index 46693afce43..3ced0ea2372 100644 --- a/src/renderer/src/lib/work-item-lookup-text.test.ts +++ b/src/renderer/src/lib/work-item-lookup-text.test.ts @@ -19,6 +19,13 @@ describe('isWorkItemLookupText', () => { ) }) + it('detects modern /-/work_items/ GitLab URLs', () => { + expect(isWorkItemLookupText('https://gitlab.com/group/project/-/work_items/42')).toBe(true) + expect( + isWorkItemLookupText('https://gitlab.example.com:8443/group/sub/project/-/work_items/9') + ).toBe(true) + }) + it('detects Linear issue URLs', () => { expect(isWorkItemLookupText('https://linear.app/acme/issue/STA-123/fix-the-bug')).toBe(true) }) diff --git a/src/renderer/src/lib/worktree-activation.test.ts b/src/renderer/src/lib/worktree-activation.test.ts index 1fbe25359bb..107a573eeeb 100644 --- a/src/renderer/src/lib/worktree-activation.test.ts +++ b/src/renderer/src/lib/worktree-activation.test.ts @@ -2,8 +2,16 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { SetupScriptLaunchMode } from '../../../shared/types' import { activateAndRevealWorktree, ensureWorktreeHasInitialTerminal } from './worktree-activation' +import { resetHookCommandDelayedDeliveryForTests } from './hook-command-delayed-delivery' import { useAppStore } from '@/store' +type AppStoreState = ReturnType + +const initialTabsByWorktree = useAppStore.getState().tabsByWorktree +const initialGetKnownWorktreeById = useAppStore.getState().getKnownWorktreeById +const initialPendingIssueCommandSplitByTabId = + useAppStore.getState().pendingIssueCommandSplitByTabId + function setSetupScriptLaunchMode(mode: SetupScriptLaunchMode | null): void { useAppStore.setState((state) => ({ settings: state.settings @@ -22,6 +30,12 @@ afterEach(() => { : ({ activeRuntimeEnvironmentId: null } as unknown as typeof state.settings) })) setSetupScriptLaunchMode('new-tab') + resetHookCommandDelayedDeliveryForTests() + useAppStore.setState({ + tabsByWorktree: initialTabsByWorktree, + getKnownWorktreeById: initialGetKnownWorktreeById, + pendingIssueCommandSplitByTabId: initialPendingIssueCommandSplitByTabId + } as Partial) }) function createMockStore(overrides: Record = {}) { @@ -266,6 +280,40 @@ describe('ensureWorktreeHasInitialTerminal', () => { ) }) + it('holds the issue command for the first mirrored web runtime tab when none exists yet', () => { + ;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true + useAppStore.setState((state) => ({ + settings: state.settings + ? { ...state.settings, activeRuntimeEnvironmentId: 'web-runtime-1' } + : ({ activeRuntimeEnvironmentId: 'web-runtime-1' } as unknown as typeof state.settings) + })) + useAppStore.setState({ + tabsByWorktree: {}, + getKnownWorktreeById: ((id: string) => + id === 'wt-1' + ? { id: 'wt-1' } + : undefined) as unknown as AppStoreState['getKnownWorktreeById'] + } as Partial) + const store = createMockStore() + + const result = ensureWorktreeHasInitialTerminal(store, 'wt-1', undefined, undefined, { + command: 'gh issue view 42' + }) + + // Why: runtime session tabs mirror in asynchronously — the command must be + // held for the first mirrored tab rather than silently dropped. + expect(result).toBeNull() + expect(useAppStore.getState().pendingIssueCommandSplitByTabId).toEqual({}) + + useAppStore.setState({ + tabsByWorktree: { 'wt-1': [{ id: 'mirror-tab-1' }] } + } as unknown as Partial) + + expect(useAppStore.getState().pendingIssueCommandSplitByTabId['mirror-tab-1']).toEqual({ + command: 'gh issue view 42' + }) + }) + it('creates a local initial terminal for explicitly local worktrees while a runtime is focused', () => { useAppStore.setState((state) => ({ settings: state.settings diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index 924c52dd640..1d94786a345 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -49,6 +49,7 @@ import { import { isTuiAgent } from '../../../shared/tui-agent-config' import { repoIsRemote } from '../../../shared/agent-launch-remote' import { resumeSleepingAgentSessionsForWorktree } from '@/lib/resume-sleeping-agent-session' +import { queueHookCommandsForFirstWorktreeTab } from '@/lib/hook-command-delayed-delivery' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import { getRuntimeEnvironmentIdForWorktree, @@ -506,6 +507,24 @@ export function ensureWorktreeHasInitialTerminal( ) return existingTerminalTabId } + if (setup || issueCommand) { + // Why: runtime-owned worktrees mirror their session tabs asynchronously, + // so right after create there is usually no tab yet. Hold the commands + // for the first mirrored tab instead of silently dropping them. + queueHookCommandsForFirstWorktreeTab({ + worktreeId, + deliver: (state, firstTerminalTabId) => + queueSetupAndIssueCommands( + state, + worktreeId, + firstTerminalTabId, + setup, + issueCommand, + wrappedSetupCommandStr, + opts + ) + }) + } return null } diff --git a/src/renderer/src/lib/worktree-creation-flow.test.ts b/src/renderer/src/lib/worktree-creation-flow.test.ts index f03cf282d25..1ceebd2093f 100644 --- a/src/renderer/src/lib/worktree-creation-flow.test.ts +++ b/src/renderer/src/lib/worktree-creation-flow.test.ts @@ -504,6 +504,70 @@ describe('staged background worktree creation', () => { }) }) + // Why: one-click "Start workspace from issue" commonly backgrounds, so the + // user-moved-on path is the common delivery for the repo's issue command; it + // must thread through as the 5th positional arg, not be dropped to undefined. + it('threads the request issue command into the background terminal seed', async () => { + store.activeView = 'tasks' + store.createWorktree.mockResolvedValueOnce({ + worktree: { + id: 'wt-1', + repoId: 'repo-1' + } + }) + + const started = continueBackgroundWorktreeCreation( + 'creation-1', + makeRequest({ issueCommand: { command: 'gh issue view 42' } }), + { revealCreationSurface: false } + ) + + expect(started).toBe(true) + // Why: vi.waitFor instead of a fixed microtask flush — the await count in + // executeWorktreeCreation grows over time (e.g. VM preflight), and a fixed + // flush silently starves this assertion in merged builds. + await vi.waitFor(() => + expect(ensureWorktreeHasInitialTerminal).toHaveBeenCalledWith( + store, + 'wt-1', + undefined, + undefined, + { command: 'gh issue view 42' }, + undefined, + { activateCreatedTabs: false } + ) + ) + }) + + // Why: the still-watching path activates the worktree directly, so the issue + // command must reach activateAndRevealWorktree too — both branches carry it. + it('threads the request issue command into the active reveal', async () => { + store.activeView = 'terminal' + store.activePendingCreationId = 'creation-1' + store.createWorktree.mockResolvedValueOnce({ + worktree: { + id: 'wt-1', + repoId: 'repo-1', + path: '/repo/wt-1' + } + }) + vi.mocked(activateAndRevealWorktree).mockReturnValueOnce({ primaryTabId: 'tab-1' }) + + const started = continueBackgroundWorktreeCreation( + 'creation-1', + makeRequest({ issueCommand: { command: 'gh issue view 42' } }) + ) + + expect(started).toBe(true) + await vi.waitFor(() => + expect(activateAndRevealWorktree).toHaveBeenCalledWith( + 'wt-1', + expect.objectContaining({ issueCommand: { command: 'gh issue view 42' } }) + ) + ) + expect(ensureWorktreeHasInitialTerminal).not.toHaveBeenCalled() + }) + it('toasts a staged create error after the user leaves the creation surface', async () => { store.activeView = 'tasks' store.createWorktree.mockRejectedValueOnce(new Error('create failed')) diff --git a/src/renderer/src/lib/worktree-creation-flow.ts b/src/renderer/src/lib/worktree-creation-flow.ts index f4938e44013..9af1dc61ebe 100644 --- a/src/renderer/src/lib/worktree-creation-flow.ts +++ b/src/renderer/src/lib/worktree-creation-flow.ts @@ -225,7 +225,8 @@ async function executeWorktreeCreation( sidebarRevealBehavior: 'auto', ...(result.setup ? { setup: result.setup } : {}), ...(result.defaultTabs ? { defaultTabs: result.defaultTabs } : {}), - ...(startupOpt ? { startup: startupOpt } : {}) + ...(startupOpt ? { startup: startupOpt } : {}), + ...(preparedRequest.issueCommand ? { issueCommand: preparedRequest.issueCommand } : {}) }) primaryTabId = activation === false ? null : activation.primaryTabId } else { @@ -237,7 +238,7 @@ async function executeWorktreeCreation( worktree.id, startupOpt, result.setup, - undefined, + preparedRequest.issueCommand, result.defaultTabs, { activateCreatedTabs: false } ) diff --git a/src/renderer/src/runtime/runtime-file-client.test.ts b/src/renderer/src/runtime/runtime-file-client.test.ts index b5650435107..f1ce3f69e9c 100644 --- a/src/renderer/src/runtime/runtime-file-client.test.ts +++ b/src/renderer/src/runtime/runtime-file-client.test.ts @@ -235,6 +235,141 @@ describe('runtime file client', () => { ).rejects.toThrow('Remote file is too large to open in the editor') }) + it('falls back to files.readPreview when a remote binary file is opened', async () => { + runtimeEnvironmentCall.mockImplementation((args: { method: string }) => { + if (args.method === 'files.read') { + return Promise.resolve({ + id: 'rpc-read', + ok: false, + error: { code: 'runtime_error', message: 'binary_file' }, + _meta: { runtimeId: 'remote-runtime' } + }) + } + return Promise.resolve({ + id: 'rpc-preview', + ok: true, + result: { + content: 'JVBERi0=', + isBinary: true, + isImage: true, + mimeType: 'application/pdf' + }, + _meta: { runtimeId: 'remote-runtime' } + }) + }) + + await expect( + readRuntimeFileContent({ + settings: { activeRuntimeEnvironmentId: 'env-1' }, + filePath: '/remote/repo/doc.pdf', + relativePath: 'doc.pdf', + worktreeId: 'wt-1' + }) + ).resolves.toEqual({ + content: 'JVBERi0=', + isBinary: true, + isImage: true, + mimeType: 'application/pdf' + }) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'files.read', + params: { worktree: 'id:wt-1', relativePath: 'doc.pdf' }, + timeoutMs: 15_000 + }) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'files.readPreview', + params: { worktree: 'id:wt-1', relativePath: 'doc.pdf' }, + timeoutMs: 15_000 + }) + }) + + it('does not fall back to files.readPreview for non-binary remote read errors', async () => { + runtimeEnvironmentCall.mockImplementation((args: { method: string }) => { + if (args.method === 'files.read') { + return Promise.resolve({ + id: 'rpc-read', + ok: false, + error: { code: 'runtime_error', message: 'permission_denied' }, + _meta: { runtimeId: 'remote-runtime' } + }) + } + throw new Error('files.readPreview should not be called') + }) + + await expect( + readRuntimeFileContent({ + settings: { activeRuntimeEnvironmentId: 'env-1' }, + filePath: '/remote/repo/secret.txt', + relativePath: 'secret.txt', + worktreeId: 'wt-1' + }) + ).rejects.toThrow('permission_denied') + + expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( + expect.objectContaining({ method: 'files.readPreview' }) + ) + }) + + it('propagates a files.readPreview failure during the binary fallback', async () => { + runtimeEnvironmentCall.mockImplementation((args: { method: string }) => { + if (args.method === 'files.read') { + return Promise.resolve({ + id: 'rpc-read', + ok: false, + error: { code: 'runtime_error', message: 'binary_file' }, + _meta: { runtimeId: 'remote-runtime' } + }) + } + return Promise.resolve({ + id: 'rpc-preview', + ok: false, + error: { code: 'runtime_error', message: 'file_too_large' }, + _meta: { runtimeId: 'remote-runtime' } + }) + }) + + await expect( + readRuntimeFileContent({ + settings: { activeRuntimeEnvironmentId: 'env-1' }, + filePath: '/remote/repo/huge.pdf', + relativePath: 'huge.pdf', + worktreeId: 'wt-1' + }) + ).rejects.toThrow('file_too_large') + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ method: 'files.readPreview' }) + ) + }) + + it('does not fall back when a non-RPC error merely shares the binary_file message', async () => { + // Why: only a typed RuntimeRpcCallError('binary_file') means the server + // classified the file as binary. A transport-level failure that happens to + // carry the same message text must propagate, not trigger a preview read. + runtimeEnvironmentCall.mockImplementation((args: { method: string }) => { + if (args.method === 'files.read') { + return Promise.reject(new Error('binary_file')) + } + throw new Error('files.readPreview should not be called') + }) + + await expect( + readRuntimeFileContent({ + settings: { activeRuntimeEnvironmentId: 'env-1' }, + filePath: '/remote/repo/doc.pdf', + relativePath: 'doc.pdf', + worktreeId: 'wt-1' + }) + ).rejects.toThrow('binary_file') + + expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( + expect.objectContaining({ method: 'files.readPreview' }) + ) + }) + it('uses the active runtime id as the dedupe scope', () => { expect(getRuntimeFileReadScope({ activeRuntimeEnvironmentId: 'env-1' }, 'ssh-1')).toBe( 'runtime:env-1' diff --git a/src/renderer/src/runtime/runtime-file-client.ts b/src/renderer/src/runtime/runtime-file-client.ts index b5bceb45141..e9161f754c1 100644 --- a/src/renderer/src/runtime/runtime-file-client.ts +++ b/src/renderer/src/runtime/runtime-file-client.ts @@ -17,6 +17,7 @@ import type { import { callRuntimeRpc, getActiveRuntimeTarget, + RuntimeRpcCallError, unwrapRuntimeRpcResult } from './runtime-rpc-client' import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' @@ -155,12 +156,29 @@ export async function readRuntimeFileContent({ throw new Error('Remote file is outside the owning runtime worktree') } - const result = await callRuntimeRpc( - target, - 'files.read', - { worktree: toRuntimeWorktreeSelector(worktreeId), relativePath }, - { timeoutMs: 15_000 } - ) + const worktree = toRuntimeWorktreeSelector(worktreeId) + let result: RuntimeFileReadResult + try { + result = await callRuntimeRpc( + target, + 'files.read', + { worktree, relativePath }, + { timeoutMs: 15_000 } + ) + } catch (err) { + // Why: files.read rejects binary paths with the typed 'binary_file' error; fall + // back to the base64 preview RPC so PDFs/images render like local/SSH paths. + // Match the exact typed error so an unrelated failure can't spoof the fallback. + if (err instanceof RuntimeRpcCallError && err.message === 'binary_file') { + return callRuntimeRpc( + target, + 'files.readPreview', + { worktree, relativePath }, + { timeoutMs: 15_000 } + ) + } + throw err + } if (result.truncated) { // Why: the runtime file RPC is preview-sized today; treating a truncated // payload as editable content would make saves overwrite the rest of the file. diff --git a/src/renderer/src/runtime/web-runtime-session.test.ts b/src/renderer/src/runtime/web-runtime-session.test.ts index 2a03f55483a..52fbcf4ffb5 100644 --- a/src/renderer/src/runtime/web-runtime-session.test.ts +++ b/src/renderer/src/runtime/web-runtime-session.test.ts @@ -480,6 +480,7 @@ describe('createWebRuntimeSessionTerminal', () => { afterTabId: 'web-terminal-host-tab-1%3A%3Aleaf-1', targetGroupId: 'group-left', command: "codex 'linked issue context'", + cwd: '/repo/packages/app', env: { CODEX_PROFILE: 'captured' }, startupCommandDelivery: 'shell-ready', launchConfig: { @@ -499,6 +500,7 @@ describe('createWebRuntimeSessionTerminal', () => { afterTabId: 'host-tab-1::leaf-1', targetGroupId: 'group-left', command: "codex 'linked issue context'", + cwd: '/repo/packages/app', env: { CODEX_PROFILE: 'captured' }, startupCommandDelivery: 'shell-ready', launchConfig: { diff --git a/src/renderer/src/runtime/web-runtime-session.ts b/src/renderer/src/runtime/web-runtime-session.ts index 14408a9626a..b2e14dd161a 100644 --- a/src/renderer/src/runtime/web-runtime-session.ts +++ b/src/renderer/src/runtime/web-runtime-session.ts @@ -50,6 +50,7 @@ export async function createWebRuntimeSessionTerminal(args: { afterTabId?: string targetGroupId?: string command?: string + cwd?: string env?: Record startupCommandDelivery?: StartupCommandDelivery launchConfig?: SleepingAgentLaunchConfig @@ -78,6 +79,7 @@ export async function createWebRuntimeSessionTerminal(args: { afterTabId: args.afterTabId ? toHostSessionTabId(args.afterTabId) : undefined, targetGroupId: args.targetGroupId, command: args.command, + cwd: args.cwd, ...(args.env ? { env: args.env } : {}), startupCommandDelivery: args.startupCommandDelivery, ...(args.launchConfig ? { launchConfig: args.launchConfig } : {}), diff --git a/src/renderer/src/runtime/web-session-tabs-sync.test.ts b/src/renderer/src/runtime/web-session-tabs-sync.test.ts index b567654eae4..be97ef98b87 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.test.ts @@ -748,6 +748,7 @@ describe('applyWebSessionTabsSnapshot', () => { leafId: LEAF_ID, isActive: true, launchAgent: 'codex', + startupCwd: '/worktree/packages/web', status: 'ready', terminal: 'terminal-1' } @@ -765,6 +766,7 @@ describe('applyWebSessionTabsSnapshot', () => { id: mirroredId, ptyId: 'remote:web-env-1@@terminal-1', launchAgent: 'codex', + startupCwd: '/worktree/packages/web', title: 'host shell', worktreeId: WT } @@ -827,6 +829,48 @@ describe('applyWebSessionTabsSnapshot', () => { expect(patch.tabsByWorktree?.[WT]?.[0]?.launchAgent).toBeUndefined() }) + it('drops mirrored startup cwd when a later host snapshot omits it', () => { + const existingTab: TerminalTab = { + id: toWebTerminalSurfaceTabId('host-tab-1'), + ptyId: 'remote:web-env-1@@terminal-1', + worktreeId: WT, + title: 'zsh', + defaultTitle: 'zsh', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: NOW, + startupCwd: '/worktree/packages/web' + } + + const patch = applyWebSessionTabsSnapshot( + makeState({ + tabsByWorktree: { [WT]: [existingTab] }, + ptyIdsByTabId: { [existingTab.id]: ['remote:web-env-1@@terminal-1'] } + }), + makeSnapshot([ + { + type: 'terminal', + id: HOST_SURFACE_ID, + title: 'zsh', + parentTabId: 'host-tab-1', + leafId: LEAF_ID, + isActive: true, + status: 'ready', + terminal: 'terminal-1' + } + ]), + ENV, + NOW + 1 + ) as Partial + + expect(patch.tabsByWorktree?.[WT]?.[0]).toMatchObject({ + id: existingTab.id, + title: 'zsh' + }) + expect(patch.tabsByWorktree?.[WT]?.[0]?.startupCwd).toBeUndefined() + }) + it('adopts host viewMode when this client has no prior tab', () => { const patch = applyWebSessionTabsSnapshot( makeState(), diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 5c6b4677ce1..8d0eba1361d 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -547,6 +547,10 @@ function buildMirroredTerminalTabs( activeSurface.quickCommandLabel?.trim() || surfaces.find((surface) => surface.quickCommandLabel?.trim())?.quickCommandLabel?.trim() || existing?.quickCommandLabel?.trim() + // Why: startup cwd is host-owned launch metadata; once the host omits it, + // mirrored clients must not resurrect stale subdirectory intent. + const startupCwd = + activeSurface.startupCwd || surfaces.find((surface) => surface.startupCwd)?.startupCwd // Why: tab color/pin echo back through host snapshots, so prefer the client's // own record (kept authoritative in tabsByWorktree by the pin/color setters) // and fall back to the host value only when this client has no prior tab — @@ -570,6 +574,7 @@ function buildMirroredTerminalTabs( title, defaultTitle: existing?.defaultTitle ?? title, ...(quickCommandLabel ? { quickCommandLabel } : {}), + ...(startupCwd ? { startupCwd } : {}), customTitle: existing?.customTitle ?? null, color, isPinned, @@ -1386,6 +1391,7 @@ function terminalTabEqual(a: TerminalTab, b: TerminalTab): boolean { a.title === b.title && a.defaultTitle === b.defaultTitle && a.quickCommandLabel === b.quickCommandLabel && + a.startupCwd === b.startupCwd && a.generatedTitle === b.generatedTitle && a.customTitle === b.customTitle && a.color === b.color && diff --git a/src/renderer/src/runtime/worktree-create-base.test.ts b/src/renderer/src/runtime/worktree-create-base.test.ts index 67f74558b3b..2ece667beef 100644 --- a/src/renderer/src/runtime/worktree-create-base.test.ts +++ b/src/renderer/src/runtime/worktree-create-base.test.ts @@ -1,42 +1,28 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { resolveWorktreeCreateBaseBranch } from './worktree-create-base' describe('resolveWorktreeCreateBaseBranch', () => { - it('uses an explicit Start-from selection before repo defaults', async () => { - const loadDefaultBaseRef = vi.fn().mockResolvedValue('origin/main') - + it('uses an explicit Start-from selection', async () => { await expect( resolveWorktreeCreateBaseBranch({ - explicitBaseBranch: 'origin/feature', - repoWorktreeBaseRef: 'dev', - loadDefaultBaseRef + explicitBaseBranch: ' origin/feature ' }) ).resolves.toBe('origin/feature') - - expect(loadDefaultBaseRef).not.toHaveBeenCalled() }) - it('uses the pinned repo worktree base before resolving the git primary', async () => { - const loadDefaultBaseRef = vi.fn().mockResolvedValue('origin/main') - + it('omits repo defaults so backend create owns base selection', async () => { await expect( resolveWorktreeCreateBaseBranch({ - explicitBaseBranch: undefined, - repoWorktreeBaseRef: ' dev ', - loadDefaultBaseRef + explicitBaseBranch: undefined }) - ).resolves.toBe('dev') - - expect(loadDefaultBaseRef).not.toHaveBeenCalled() + ).resolves.toBeUndefined() }) - it('falls back to the git primary when no explicit or pinned base exists', async () => { + it('omits blank explicit selections', async () => { await expect( resolveWorktreeCreateBaseBranch({ - explicitBaseBranch: undefined, - repoWorktreeBaseRef: undefined, - loadDefaultBaseRef: vi.fn().mockResolvedValue('origin/main') + explicitBaseBranch: ' ' }) - ).resolves.toBe('origin/main') + ).resolves.toBeUndefined() }) }) diff --git a/src/renderer/src/runtime/worktree-create-base.ts b/src/renderer/src/runtime/worktree-create-base.ts index cb3f2fc62f8..d182e1c90a3 100644 --- a/src/renderer/src/runtime/worktree-create-base.ts +++ b/src/renderer/src/runtime/worktree-create-base.ts @@ -1,15 +1,6 @@ +// Backend base resolution owns stale-ref fallback, so the renderer only forwards explicit selections. export async function resolveWorktreeCreateBaseBranch(args: { explicitBaseBranch: string | undefined - repoWorktreeBaseRef: string | undefined - loadDefaultBaseRef: () => Promise }): Promise { - if (args.explicitBaseBranch) { - return args.explicitBaseBranch - } - const pinnedBaseRef = args.repoWorktreeBaseRef?.trim() - if (pinnedBaseRef) { - return pinnedBaseRef - } - const defaultBaseRef = (await args.loadDefaultBaseRef())?.trim() - return defaultBaseRef || undefined + return args.explicitBaseBranch?.trim() || undefined } diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index df81ab4f36e..e75bbffa5c9 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -24,6 +24,7 @@ import { createBrowserSlice } from './slices/browser' import { createRateLimitSlice } from './slices/rate-limits' import { createSshSlice } from './slices/ssh' import { createAgentStatusSlice } from './slices/agent-status' +import { createPaneForegroundAgentSlice } from './slices/pane-foreground-agent' import { createDiffCommentsSlice } from './slices/diffComments' import { createDetectedAgentsSlice } from './slices/detected-agents' import { createWorktreeNavHistorySlice } from './slices/worktree-nav-history' @@ -61,6 +62,7 @@ export const useAppStore = create()((...a) => ({ ...createRateLimitSlice(...a), ...createSshSlice(...a), ...createAgentStatusSlice(...a), + ...createPaneForegroundAgentSlice(...a), ...createDiffCommentsSlice(...a), ...createDetectedAgentsSlice(...a), ...createWorktreeNavHistorySlice(...a), diff --git a/src/renderer/src/store/slices/agent-status-worktree-purge-leak.test.ts b/src/renderer/src/store/slices/agent-status-worktree-purge-leak.test.ts new file mode 100644 index 00000000000..681c26f81c4 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-worktree-purge-leak.test.ts @@ -0,0 +1,139 @@ +/** + * Memory-leak regression: pane-scoped agent-status, unread, and terminal-input + * maps must be purged when a worktree is removed via the BULK path. + * + * These maps are keyed by `${tabId}:${leafId}` (or by tabId for + * `unreadTerminalTabs`). The single `removeWorktree` path clears them via + * `shutdownWorktreeTerminals` / `dropAgentStatusByWorktree` / + * `clearPaneForegroundAgentByWorktree`, but the bulk `purgeWorktreeTerminalState` + * reducer (used by the authoritative-scan reconcile, remove-project, and the + * hydration stale-purge) never ran terminal teardown — so an externally-removed + * worktree (CLI `git worktree remove`, another Orca window, SSH, or a removed + * project) orphaned one entry per agent pane for the lifetime of the renderer + * session, plus a phantom unread dock badge. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { AppState } from '../types' +import type * as AgentStatusModule from '@/lib/agent-status' + +vi.mock('sonner', () => ({ + toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() } +})) + +vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({ + restorePtyDataHandlersAfterFailedShutdown: vi.fn(), + unregisterPtyDataHandlers: vi.fn() +})) + +vi.mock('@/lib/agent-status', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } +}) + +const mockApi = { + worktrees: { + list: vi.fn().mockResolvedValue([]), + remove: vi.fn().mockResolvedValue(undefined), + forceDeletePreservedBranch: vi.fn().mockResolvedValue({ deleted: true }), + updateMeta: vi.fn().mockResolvedValue({}) + }, + pty: { kill: vi.fn().mockResolvedValue(undefined) }, + runtimeEnvironments: { call: vi.fn().mockResolvedValue({ ok: true, result: {} }) } +} + +// @ts-expect-error -- minimal window.api stub for the store under test +globalThis.window = { api: mockApi } + +import { createTestStore, seedStore, makeWorktree, makeTab } from './store-test-helpers' + +const WT = 'repo1::/path/wt1' +const TAB = 'tab-1' +const PANE = `${TAB}:leaf-a` + +function liveEntry(paneKey: string): AgentStatusEntry { + return { + state: 'working', + prompt: 'p', + updatedAt: 0, + stateStartedAt: 0, + paneKey, + stateHistory: [] + } +} + +function seedPaneState(store: ReturnType): void { + seedStore(store, { + worktreesByRepo: { + repo1: [makeWorktree({ id: WT, repoId: 'repo1', path: '/path/wt1' })] + }, + tabsByWorktree: { [WT]: [makeTab({ id: TAB, worktreeId: WT })] }, + agentStatusByPaneKey: { [PANE]: liveEntry(PANE) }, + agentLaunchConfigByPaneKey: { + [PANE]: { launchConfig: {}, registeredAt: 0, identity: {} } + } as unknown as AppState['agentLaunchConfigByPaneKey'], + acknowledgedAgentsByPaneKey: { [PANE]: 1 }, + paneForegroundAgentByPaneKey: { [PANE]: { agent: null, shellForeground: true } }, + sleepingAgentSessionsByPaneKey: { + [PANE]: {} + } as unknown as AppState['sleepingAgentSessionsByPaneKey'], + unreadTerminalTabs: { [TAB]: true }, + unreadTerminalPanes: { [PANE]: true }, + unreadAgentCompletionPanes: { [PANE]: true }, + lastTerminalInputAtByPaneKey: { [PANE]: 123 } + }) +} + +describe('bulk worktree purge evicts pane-scoped agent/unread/input maps (leak regression)', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('purgeWorktreeTerminalState drops every pane-scoped map for the removed worktree', () => { + const store = createTestStore() + seedPaneState(store) + + store.getState().purgeWorktreeTerminalState([WT]) + + const s = store.getState() + expect(s.agentStatusByPaneKey[PANE]).toBeUndefined() + expect(s.agentLaunchConfigByPaneKey[PANE]).toBeUndefined() + expect(s.acknowledgedAgentsByPaneKey[PANE]).toBeUndefined() + expect(s.paneForegroundAgentByPaneKey[PANE]).toBeUndefined() + expect(s.sleepingAgentSessionsByPaneKey[PANE]).toBeUndefined() + expect(s.unreadTerminalTabs[TAB]).toBeUndefined() + expect(s.unreadTerminalPanes[PANE]).toBeUndefined() + expect(s.unreadAgentCompletionPanes[PANE]).toBeUndefined() + expect(s.lastTerminalInputAtByPaneKey[PANE]).toBeUndefined() + }) + + it('keeps pane-scoped state for worktrees that are NOT removed', () => { + const store = createTestStore() + const OTHER = 'repo1::/path/wt2' + const OTHER_TAB = 'tab-2' + const OTHER_PANE = `${OTHER_TAB}:leaf-b` + seedStore(store, { + worktreesByRepo: { + repo1: [ + makeWorktree({ id: WT, repoId: 'repo1', path: '/path/wt1' }), + makeWorktree({ id: OTHER, repoId: 'repo1', path: '/path/wt2' }) + ] + }, + tabsByWorktree: { + [WT]: [makeTab({ id: TAB, worktreeId: WT })], + [OTHER]: [makeTab({ id: OTHER_TAB, worktreeId: OTHER })] + }, + agentStatusByPaneKey: { [PANE]: liveEntry(PANE), [OTHER_PANE]: liveEntry(OTHER_PANE) }, + unreadTerminalTabs: { [TAB]: true, [OTHER_TAB]: true }, + lastTerminalInputAtByPaneKey: { [PANE]: 1, [OTHER_PANE]: 2 } + }) + + store.getState().purgeWorktreeTerminalState([WT]) + + const s = store.getState() + expect(s.agentStatusByPaneKey[PANE]).toBeUndefined() + expect(s.agentStatusByPaneKey[OTHER_PANE]).toBeDefined() + expect(s.unreadTerminalTabs[OTHER_TAB]).toBe(true) + expect(s.lastTerminalInputAtByPaneKey[OTHER_PANE]).toBe(2) + }) +}) diff --git a/src/renderer/src/store/slices/browser.test.ts b/src/renderer/src/store/slices/browser.test.ts index 99a42253b4e..61a534c6194 100644 --- a/src/renderer/src/store/slices/browser.test.ts +++ b/src/renderer/src/store/slices/browser.test.ts @@ -619,6 +619,45 @@ describe('createBrowserSlice runtime guard', () => { expect(tab.sessionProfileId).toBe('remote-default') }) + it('stores a runtime-resolved browser partition without a renderer profile mirror', () => { + const store = createTestStore() + store.setState({ browserSessionProfiles: [] }) + + const tab = store.getState().createBrowserTab('wt-1', 'https://example.com', { + sessionProfileId: 'profile-isolated', + sessionPartition: 'persist:orca-browser-session-profile-isolated' + }) + + expect(tab.sessionProfileId).toBe('profile-isolated') + expect(tab.sessionPartition).toBe('persist:orca-browser-session-profile-isolated') + expect(store.getState().browserTabsByWorktree['wt-1']?.[0]?.sessionPartition).toBe( + 'persist:orca-browser-session-profile-isolated' + ) + }) + + it('stores a runtime-resolved partition when switching browser tab profiles', () => { + const store = createTestStore() + const tab = store.getState().createBrowserTab('wt-1', 'https://example.com', { + sessionProfileId: null, + sessionPartition: 'persist:orca-browser' + }) + + store + .getState() + .switchBrowserTabProfile( + tab.id, + 'profile-isolated', + 'persist:orca-browser-session-profile-isolated' + ) + + expect(store.getState().browserTabsByWorktree['wt-1']?.[0]).toEqual( + expect.objectContaining({ + sessionProfileId: 'profile-isolated', + sessionPartition: 'persist:orca-browser-session-profile-isolated' + }) + ) + }) + it('creates new browser tabs through the owning runtime for desktop remote worktrees', async () => { const store = createTestStore() store.setState({ diff --git a/src/renderer/src/store/slices/browser.ts b/src/renderer/src/store/slices/browser.ts index 160851e784e..53d07f58127 100644 --- a/src/renderer/src/store/slices/browser.ts +++ b/src/renderer/src/store/slices/browser.ts @@ -54,6 +54,7 @@ type CreateBrowserTabOptions = { activate?: boolean title?: string sessionProfileId?: string | null + sessionPartition?: string | null // Why: callers like "Open Preview to the Side" need to place the new browser // tab in a specific (sibling or newly-split) group rather than the ambient // active group. Defaults to the worktree's current active group. @@ -168,7 +169,11 @@ export type BrowserSlice = { deleteBrowserPageAnnotation: (pageId: string, annotationId: string) => void clearBrowserPageAnnotations: (pageId: string) => void hydrateBrowserSession: (session: WorkspaceSessionState) => void - switchBrowserTabProfile: (workspaceId: string, profileId: string | null) => void + switchBrowserTabProfile: ( + workspaceId: string, + profileId: string | null, + sessionPartition?: string | null + ) => void browserSessionProfiles: BrowserSessionProfile[] browserSessionProfilesByHostId: Partial> browserSessionImportState: { @@ -323,12 +328,14 @@ function buildWorkspaceFromPage( worktreeId: string, page: BrowserPage, pageIds: string[], - sessionProfileId?: string | null + sessionProfileId?: string | null, + sessionPartition?: string | null ): BrowserWorkspace { return { id, worktreeId, sessionProfileId: sessionProfileId ?? null, + sessionPartition: sessionPartition ?? null, activePageId: page.id, pageIds, url: page.url, @@ -506,7 +513,8 @@ export const createBrowserSlice: StateCreator = worktreeId, page, [page.id], - sessionProfileId + sessionProfileId, + options?.sessionPartition ) set((s) => { @@ -819,12 +827,14 @@ export const createBrowserSlice: StateCreator = const snap = entryToRestore.workspace const pages = entryToRestore.pages const sessionProfileId = snap.sessionProfileId ?? null + const sessionPartition = snap.sessionPartition ?? null if (pages.length === 0) { const restored = get().createBrowserTab(worktreeId, snap.url, { title: snap.title, activate: true, - sessionProfileId + sessionProfileId, + sessionPartition }) return get().browserTabsByWorktree[worktreeId]?.find((tab) => tab.id === restored.id) ?? null } @@ -836,6 +846,7 @@ export const createBrowserSlice: StateCreator = title: firstPage.title, activate: true, sessionProfileId, + sessionPartition, browserRuntimeEnvironmentId: firstPage.browserRuntimeEnvironmentId }) @@ -1683,13 +1694,17 @@ export const createBrowserSlice: StateCreator = } }, - switchBrowserTabProfile: (workspaceId, profileId) => { + switchBrowserTabProfile: (workspaceId, profileId, sessionPartition) => { set((s) => { for (const [worktreeId, tabs] of Object.entries(s.browserTabsByWorktree)) { const tabIndex = tabs.findIndex((t) => t.id === workspaceId) if (tabIndex !== -1) { const updatedTabs = [...tabs] - updatedTabs[tabIndex] = { ...updatedTabs[tabIndex], sessionProfileId: profileId } + updatedTabs[tabIndex] = { + ...updatedTabs[tabIndex], + sessionProfileId: profileId, + sessionPartition: sessionPartition ?? null + } return { browserTabsByWorktree: { ...s.browserTabsByWorktree, diff --git a/src/renderer/src/store/slices/diffComments.test.ts b/src/renderer/src/store/slices/diffComments.test.ts index dbb200d89a0..3726098e85d 100644 --- a/src/renderer/src/store/slices/diffComments.test.ts +++ b/src/renderer/src/store/slices/diffComments.test.ts @@ -131,6 +131,7 @@ import { createBrowserSlice } from './browser' import { createRateLimitSlice } from './rate-limits' import { createSshSlice } from './ssh' import { createAgentStatusSlice } from './agent-status' +import { createPaneForegroundAgentSlice } from './pane-foreground-agent' import { createDiffCommentsSlice } from './diffComments' import { createDetectedAgentsSlice } from './detected-agents' import { createWorktreeNavHistorySlice } from './worktree-nav-history' @@ -167,6 +168,7 @@ function createTestStore() { ...createRateLimitSlice(...a), ...createSshSlice(...a), ...createAgentStatusSlice(...a), + ...createPaneForegroundAgentSlice(...a), ...createDiffCommentsSlice(...a), ...createDetectedAgentsSlice(...a), ...createWorktreeNavHistorySlice(...a), diff --git a/src/renderer/src/store/slices/editor.test.ts b/src/renderer/src/store/slices/editor.test.ts index 0b570c41ff3..4656c765881 100644 --- a/src/renderer/src/store/slices/editor.test.ts +++ b/src/renderer/src/store/slices/editor.test.ts @@ -1459,6 +1459,64 @@ describe('createEditorSlice markdown view state', () => { }) ]) }) + + it('drops markdown visibility for a preview replaced by a diff', () => { + const store = createEditorStore() + + store.getState().openFile( + { + filePath: '/repo/docs/README.md', + relativePath: 'docs/README.md', + worktreeId: 'wt-1', + language: 'markdown', + mode: 'edit' + }, + { preview: true } + ) + store.getState().setMarkdownFrontmatterVisible('/repo/docs/README.md', true) + store.getState().setMarkdownTableOfContentsVisible('/repo/docs/README.md', true) + + store.getState().openDiff('wt-1', '/repo/docs/guide.md', 'docs/guide.md', 'markdown', false, { + preview: true + }) + + expect(store.getState().markdownFrontmatterVisible).toEqual({}) + expect(store.getState().markdownTableOfContentsVisible).toEqual({}) + }) + + it('keeps markdown visibility when another preview still references a replaced source', () => { + const store = createEditorStore() + + store.getState().openFile( + { + filePath: '/repo/docs/README.md', + relativePath: 'docs/README.md', + worktreeId: 'wt-1', + language: 'markdown', + mode: 'edit' + }, + { preview: true } + ) + store.getState().openMarkdownPreview({ + filePath: '/repo/docs/README.md', + relativePath: 'docs/README.md', + worktreeId: 'wt-1', + language: 'markdown' + }) + store.getState().setMarkdownFrontmatterVisible('/repo/docs/README.md', true) + store.getState().setMarkdownTableOfContentsVisible('/repo/docs/README.md', true) + + store.getState().openDiff('wt-1', '/repo/docs/guide.md', 'docs/guide.md', 'markdown', false, { + preview: true + }) + + expect(store.getState().markdownFrontmatterVisible).toEqual({ + '/repo/docs/README.md': true + }) + expect(store.getState().markdownTableOfContentsVisible).toEqual({ + '/repo/docs/README.md': true + }) + }) }) describe('createEditorSlice editor view mode', () => { @@ -1628,6 +1686,79 @@ describe('createEditorSlice markdown frontmatter visibility (#4468)', () => { }) }) +describe('createEditorSlice markdown table of contents visibility', () => { + it('stores visible=true as an explicit entry keyed by fileId', () => { + const store = createEditorStore() + + store.getState().setMarkdownTableOfContentsVisible('/repo/notes.md', true) + + expect(store.getState().markdownTableOfContentsVisible).toEqual({ '/repo/notes.md': true }) + }) + + it('deletes the entry when visibility resets to hidden', () => { + const store = createEditorStore() + store.getState().setMarkdownTableOfContentsVisible('/repo/notes.md', true) + + store.getState().setMarkdownTableOfContentsVisible('/repo/notes.md', false) + + expect(store.getState().markdownTableOfContentsVisible).toEqual({}) + }) + + it('drops the visibility flag when replacing a preview tab', () => { + const store = createEditorStore() + store.getState().openFile( + { + filePath: '/repo/notes.md', + relativePath: 'notes.md', + worktreeId: 'wt-1', + language: 'markdown', + mode: 'edit' + }, + { preview: true } + ) + store.getState().setMarkdownTableOfContentsVisible('/repo/notes.md', true) + + store.getState().openFile( + { + filePath: '/repo/guide.md', + relativePath: 'guide.md', + worktreeId: 'wt-1', + language: 'markdown', + mode: 'edit' + }, + { preview: true } + ) + + expect(store.getState().markdownTableOfContentsVisible).toEqual({}) + }) + + it('keeps the visibility flag while a preview tab still references the source file', () => { + const store = createEditorStore() + store.getState().openFile({ + filePath: '/repo/notes.md', + relativePath: 'notes.md', + worktreeId: 'wt-1', + language: 'markdown', + mode: 'edit' + }) + store.getState().openMarkdownPreview({ + filePath: '/repo/notes.md', + relativePath: 'notes.md', + worktreeId: 'wt-1', + language: 'markdown' + }) + store.getState().setMarkdownTableOfContentsVisible('/repo/notes.md', true) + + store.getState().closeFile('/repo/notes.md') + + expect(store.getState().markdownTableOfContentsVisible).toEqual({ '/repo/notes.md': true }) + + store.getState().closeFile('markdown-preview::/repo/notes.md') + + expect(store.getState().markdownTableOfContentsVisible).toEqual({}) + }) +}) + describe('createEditorSlice openMarkdownPreview', () => { it('opens markdown preview as a separate read-only tab', () => { const store = createEditorStore() diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index 54013b30a1f..805a2f801f4 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -401,7 +401,12 @@ export type EditorSlice = { markdownFrontmatterVisible: Record setMarkdownFrontmatterVisible: (fileId: string, visible: boolean) => void - // Markdown table of contents + // Per-file opt-in to keep the markdown table of contents open. Default is + // hidden; absent entry means hidden. + markdownTableOfContentsVisible: Record + setMarkdownTableOfContentsVisible: (fileId: string, visible: boolean) => void + + // Markdown table of contents panel sizing markdownTocPanelWidth: number setMarkdownTocPanelWidth: (width: number) => void @@ -792,35 +797,86 @@ function getReplaceablePreviewFileId( function removeEditorStateForReplacedPreview( state: Pick< EditorSlice, - 'editorDrafts' | 'editorCursorLine' | 'markdownViewMode' | 'editorViewMode' + | 'editorDrafts' + | 'editorCursorLine' + | 'markdownViewMode' + | 'editorViewMode' + | 'markdownFrontmatterVisible' + | 'markdownTableOfContentsVisible' + | 'openFiles' >, - replacedFileId: string, + replacedFile: Pick, nextFileId: string -): Pick { - if (replacedFileId === nextFileId) { +): Pick< + EditorSlice, + | 'editorDrafts' + | 'editorCursorLine' + | 'markdownViewMode' + | 'editorViewMode' + | 'markdownFrontmatterVisible' + | 'markdownTableOfContentsVisible' +> { + const visibilityKeys = [ + replacedFile.id, + ...(replacedFile.markdownPreviewSourceFileId ? [replacedFile.markdownPreviewSourceFileId] : []) + ].filter( + (key) => + key !== nextFileId && + !state.openFiles.some( + (file) => + file.id !== replacedFile.id && + (file.id === key || file.markdownPreviewSourceFileId === key) + ) + ) + if (replacedFile.id === nextFileId) { return { editorDrafts: state.editorDrafts, editorCursorLine: state.editorCursorLine, markdownViewMode: state.markdownViewMode, - editorViewMode: state.editorViewMode + editorViewMode: state.editorViewMode, + markdownFrontmatterVisible: state.markdownFrontmatterVisible, + markdownTableOfContentsVisible: state.markdownTableOfContentsVisible } } return { editorDrafts: Object.fromEntries( - Object.entries(state.editorDrafts).filter(([fileId]) => fileId !== replacedFileId) + Object.entries(state.editorDrafts).filter(([fileId]) => fileId !== replacedFile.id) ), editorCursorLine: Object.fromEntries( - Object.entries(state.editorCursorLine).filter(([fileId]) => fileId !== replacedFileId) + Object.entries(state.editorCursorLine).filter(([fileId]) => fileId !== replacedFile.id) ), markdownViewMode: Object.fromEntries( - Object.entries(state.markdownViewMode).filter(([fileId]) => fileId !== replacedFileId) + Object.entries(state.markdownViewMode).filter(([fileId]) => fileId !== replacedFile.id) ), editorViewMode: Object.fromEntries( - Object.entries(state.editorViewMode).filter(([fileId]) => fileId !== replacedFileId) + Object.entries(state.editorViewMode).filter(([fileId]) => fileId !== replacedFile.id) + ), + markdownFrontmatterVisible: removeMarkdownVisibilityKeys( + state.markdownFrontmatterVisible, + visibilityKeys + ), + markdownTableOfContentsVisible: removeMarkdownVisibilityKeys( + state.markdownTableOfContentsVisible, + visibilityKeys ) } } +function removeMarkdownVisibilityKeys( + visibility: Record, + keysToRemove: readonly string[] +): Record { + let next: Record | null = null + for (const key of keysToRemove) { + if (!(key in visibility)) { + continue + } + next ??= { ...visibility } + delete next[key] + } + return next ?? visibility +} + function getGroupActiveTab(group: TabGroup, tabsById: Map): Tab | null { return group.activeTabId ? (tabsById.get(group.activeTabId) ?? null) : null } @@ -1331,7 +1387,27 @@ export const createEditorSlice: StateCreator = (s return { markdownFrontmatterVisible: { ...s.markdownFrontmatterVisible, [fileId]: true } } }), - // Markdown table of contents + // Markdown table of contents visibility + markdownTableOfContentsVisible: {}, + setMarkdownTableOfContentsVisible: (fileId, visible) => + set((s) => { + if (!visible) { + if (!(fileId in s.markdownTableOfContentsVisible)) { + return s + } + const next = { ...s.markdownTableOfContentsVisible } + delete next[fileId] + return { markdownTableOfContentsVisible: next } + } + return { + markdownTableOfContentsVisible: { + ...s.markdownTableOfContentsVisible, + [fileId]: true + } + } + }), + + // Markdown table of contents panel sizing markdownTocPanelWidth: 240, setMarkdownTocPanelWidth: (width) => set((s) => ({ @@ -1623,59 +1699,17 @@ export const createEditorSlice: StateCreator = (s const existingPreviewIdx = s.openFiles.findIndex((f) => f.id === replaceablePreviewId) if (existingPreviewIdx !== -1) { const replacedPreview = s.openFiles[existingPreviewIdx] - const nextEditorDrafts = - replacedPreview.id === id - ? s.editorDrafts - : Object.fromEntries( - Object.entries(s.editorDrafts).filter(([fileId]) => fileId !== replacedPreview.id) - ) - const nextMarkdownViewMode = - replacedPreview.id === id - ? s.markdownViewMode - : Object.fromEntries( - Object.entries(s.markdownViewMode).filter( - ([fileId]) => fileId !== replacedPreview.id - ) - ) - const nextEditorViewMode = - replacedPreview.id === id - ? s.editorViewMode - : Object.fromEntries( - Object.entries(s.editorViewMode).filter( - ([fileId]) => fileId !== replacedPreview.id - ) - ) - const frontmatterVisibilityKeys = new Set([replacedPreview.id]) - if (replacedPreview.markdownPreviewSourceFileId) { - frontmatterVisibilityKeys.add(replacedPreview.markdownPreviewSourceFileId) - } - const frontmatterKeysToRemove = [...frontmatterVisibilityKeys].filter( - (key) => - key in s.markdownFrontmatterVisible && - !s.openFiles.some( - (file, index) => - index !== existingPreviewIdx && - (file.id === key || file.markdownPreviewSourceFileId === key) - ) - ) - const nextMarkdownFrontmatterVisible = - replacedPreview.id === id || frontmatterKeysToRemove.length === 0 - ? s.markdownFrontmatterVisible - : Object.fromEntries( - Object.entries(s.markdownFrontmatterVisible).filter( - ([fileId]) => !frontmatterKeysToRemove.includes(fileId) - ) - ) - // Why: editorCursorLine entries accumulate per file; clean up the - // evicted preview's entry so it does not leak across tab replacements. - const nextEditorCursorLine = - replacedPreview.id === id - ? s.editorCursorLine - : Object.fromEntries( - Object.entries(s.editorCursorLine).filter( - ([fileId]) => fileId !== replacedPreview.id - ) - ) + // Why: reuse the shared eviction helper (as the four other preview- + // replacement paths do) so per-file cursor/draft/visibility cleanup stays + // defined in one place instead of a hand-rolled copy that drifts. + const { + editorDrafts: nextEditorDrafts, + editorCursorLine: nextEditorCursorLine, + markdownViewMode: nextMarkdownViewMode, + editorViewMode: nextEditorViewMode, + markdownFrontmatterVisible: nextMarkdownFrontmatterVisible, + markdownTableOfContentsVisible: nextMarkdownTableOfContentsVisible + } = removeEditorStateForReplacedPreview(s, replacedPreview, id) // Replace in-place to preserve tab position newFiles = s.openFiles.map((f, i) => i === existingPreviewIdx @@ -1721,6 +1755,7 @@ export const createEditorSlice: StateCreator = (s markdownViewMode: nextMarkdownViewMode, editorViewMode: nextEditorViewMode, markdownFrontmatterVisible: nextMarkdownFrontmatterVisible, + markdownTableOfContentsVisible: nextMarkdownTableOfContentsVisible, recentlyClosedEditorTabsByWorktree: nextRecentlyClosed, ...previewTabBarUpdate, ...activeResult @@ -1962,25 +1997,22 @@ export const createEditorSlice: StateCreator = (s delete newMarkdownViewMode[fileId] const newEditorViewMode = { ...s.editorViewMode } delete newEditorViewMode[fileId] - const frontmatterVisibilityKeys = new Set([fileId]) + const markdownVisibilityKeys = new Set([fileId]) if (closedFile?.markdownPreviewSourceFileId) { - frontmatterVisibilityKeys.add(closedFile.markdownPreviewSourceFileId) + markdownVisibilityKeys.add(closedFile.markdownPreviewSourceFileId) } - const keysToRemove = [...frontmatterVisibilityKeys].filter( + const visibilityKeysToRemove = [...markdownVisibilityKeys].filter( (key) => - key in s.markdownFrontmatterVisible && !newFiles.some((file) => file.id === key || file.markdownPreviewSourceFileId === key) ) const newMarkdownFrontmatterVisible = - keysToRemove.length > 0 - ? (() => { - const next = { ...s.markdownFrontmatterVisible } - for (const key of keysToRemove) { - delete next[key] - } - return next - })() + visibilityKeysToRemove.length > 0 + ? removeMarkdownVisibilityKeys(s.markdownFrontmatterVisible, visibilityKeysToRemove) : s.markdownFrontmatterVisible + const newMarkdownTableOfContentsVisible = + visibilityKeysToRemove.length > 0 + ? removeMarkdownVisibilityKeys(s.markdownTableOfContentsVisible, visibilityKeysToRemove) + : s.markdownTableOfContentsVisible // Why: editorCursorLine entries are keyed by fileId and accumulate on // every cursor move. Without cleanup they grow without bound across a // long session as files are opened and closed. @@ -2112,6 +2144,7 @@ export const createEditorSlice: StateCreator = (s markdownViewMode: newMarkdownViewMode, editorViewMode: newEditorViewMode, markdownFrontmatterVisible: newMarkdownFrontmatterVisible, + markdownTableOfContentsVisible: newMarkdownTableOfContentsVisible, tabBarOrderByWorktree: nextTabBarOrderByWorktree, pendingEditorReveal: null, recentlyClosedEditorTabsByWorktree: nextRecentlyClosed @@ -2208,6 +2241,7 @@ export const createEditorSlice: StateCreator = (s markdownViewMode: {}, editorViewMode: {}, markdownFrontmatterVisible: {}, + markdownTableOfContentsVisible: {}, pendingEditorReveal: null } } @@ -2228,6 +2262,11 @@ export const createEditorSlice: StateCreator = (s remainingFileIds.has(fileId) ) ) + const newMarkdownTableOfContentsVisible = Object.fromEntries( + Object.entries(s.markdownTableOfContentsVisible).filter(([fileId]) => + remainingFileIds.has(fileId) + ) + ) const newEditorCursorLine = Object.fromEntries( Object.entries(s.editorCursorLine).filter(([fileId]) => remainingFileIds.has(fileId)) ) @@ -2295,6 +2334,7 @@ export const createEditorSlice: StateCreator = (s markdownViewMode: newMarkdownViewMode, editorViewMode: newEditorViewMode, markdownFrontmatterVisible: newMarkdownFrontmatterVisible, + markdownTableOfContentsVisible: newMarkdownTableOfContentsVisible, activeFileIdByWorktree: newActiveFileIdByWorktree, activeTabTypeByWorktree: newActiveTabTypeByWorktree, tabBarOrderByWorktree: nextTabBarOrderByWorktree, @@ -2489,7 +2529,7 @@ export const createEditorSlice: StateCreator = (s openFiles: s.openFiles.map((file, index) => index === replaceablePreviewIndex ? newFile : file ), - ...removeEditorStateForReplacedPreview(s, s.openFiles[replaceablePreviewIndex].id, id), + ...removeEditorStateForReplacedPreview(s, s.openFiles[replaceablePreviewIndex], id), activeFileId: id, activeTabType: 'editor', activeFileIdByWorktree: { ...s.activeFileIdByWorktree, [worktreeId]: id }, @@ -2580,7 +2620,7 @@ export const createEditorSlice: StateCreator = (s openFiles: s.openFiles.map((file, index) => index === replaceablePreviewIndex ? newFile : file ), - ...removeEditorStateForReplacedPreview(s, s.openFiles[replaceablePreviewIndex].id, id), + ...removeEditorStateForReplacedPreview(s, s.openFiles[replaceablePreviewIndex], id), activeFileId: id, activeTabType: 'editor', activeFileIdByWorktree: { ...s.activeFileIdByWorktree, [worktreeId]: id }, @@ -2671,7 +2711,7 @@ export const createEditorSlice: StateCreator = (s openFiles: s.openFiles.map((file, index) => index === replaceablePreviewIndex ? newFile : file ), - ...removeEditorStateForReplacedPreview(s, s.openFiles[replaceablePreviewIndex].id, id), + ...removeEditorStateForReplacedPreview(s, s.openFiles[replaceablePreviewIndex], id), activeFileId: id, activeTabType: 'editor', activeFileIdByWorktree: { ...s.activeFileIdByWorktree, [worktreeId]: id }, @@ -2872,7 +2912,7 @@ export const createEditorSlice: StateCreator = (s openFiles: s.openFiles.map((file, index) => index === replaceablePreviewIndex ? newFile : file ), - ...removeEditorStateForReplacedPreview(s, s.openFiles[replaceablePreviewIndex].id, id), + ...removeEditorStateForReplacedPreview(s, s.openFiles[replaceablePreviewIndex], id), activeFileId: id, activeTabType: 'editor', activeFileIdByWorktree: { ...s.activeFileIdByWorktree, [worktreeId]: id }, diff --git a/src/renderer/src/store/slices/github-pr-refresh-owner-routing.test.ts b/src/renderer/src/store/slices/github-pr-refresh-owner-routing.test.ts index d53beb70488..f7e49e211d1 100644 --- a/src/renderer/src/store/slices/github-pr-refresh-owner-routing.test.ts +++ b/src/renderer/src/store/slices/github-pr-refresh-owner-routing.test.ts @@ -155,7 +155,7 @@ describe('GitHub PR refresh owner-host routing', () => { expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ selector: 'env-1', method: 'github.prForBranch', - params: { repo: 'repo-runtime', branch, linkedPRNumber: null }, + params: { repo: 'repo-runtime', branch, linkedPRNumber: null, currentHeadOid: 'head-oid' }, timeoutMs: 30_000 }) }) @@ -228,7 +228,7 @@ describe('GitHub PR refresh owner-host routing', () => { expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ selector: 'env-1', method: 'github.prForBranch', - params: { repo: 'repo-runtime', branch, linkedPRNumber: null }, + params: { repo: 'repo-runtime', branch, linkedPRNumber: null, currentHeadOid: 'head-oid' }, timeoutMs: 30_000 }) }) @@ -272,7 +272,12 @@ describe('GitHub PR refresh owner-host routing', () => { expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ selector: 'env-1', method: 'github.prForBranch', - params: { repo: 'repo-runtime', branch: 'feature/runtime', linkedPRNumber: null }, + params: { + repo: 'repo-runtime', + branch: 'feature/runtime', + linkedPRNumber: null, + currentHeadOid: 'head-oid' + }, timeoutMs: 30_000 }) }) diff --git a/src/renderer/src/store/slices/github.test.ts b/src/renderer/src/store/slices/github.test.ts index aed7dcad6b1..e17be50508a 100644 --- a/src/renderer/src/store/slices/github.test.ts +++ b/src/renderer/src/store/slices/github.test.ts @@ -2264,6 +2264,55 @@ describe('createGitHubSlice.fetchPRForBranch', () => { expect(mockApi.cache.setGitHub).not.toHaveBeenCalled() }) + it('preserves cached merged PR data when the worktree head is a confirmed PR commit', async () => { + vi.useFakeTimers() + const store = createTestStore() + const repoPath = '/repo' + const repoId = 'repo-1' + const branch = 'feature/merged-pr-behind-head' + const worktreeId = 'wt-merged-behind-head' + const cachedPR = makePR({ + number: 13, + title: 'Merged PR with unpulled final head', + state: 'merged', + headSha: 'merged-final-head', + confirmedContainedHeadOid: 'behind-head' + }) + + store.setState({ + repos: [{ id: repoId, path: repoPath, name: 'repo', kind: 'git' }], + worktreesByRepo: { + [repoId]: [ + makePRRefreshWorktree({ + id: worktreeId, + repoId, + branch, + head: 'behind-head' + }) + ] + }, + prCache: { + [`${repoId}::${branch}`]: { + data: cachedPR, + fetchedAt: 1 + } + } + } as unknown as Partial) + mockApi.gh.refreshPRNow.mockResolvedValueOnce({ kind: 'no-pr', fetchedAt: 2 }) + + await expect( + store.getState().fetchPRForBranch(repoPath, branch, { + force: true, + repoId, + worktreeId + }) + ).resolves.toEqual(cachedPR) + expect(store.getState().prCache[`${repoId}::${branch}`]).toEqual({ + data: cachedPR, + fetchedAt: 1 + }) + }) + it.each([ { name: 'worktree id is missing', @@ -3482,6 +3531,58 @@ describe('createGitHubSlice.refreshGitHubForWorktreeIfStale', () => { expect(mockApi.gh.prForBranch).not.toHaveBeenCalled() }) + it('keeps a confirmed behind-head merged PR as the refresh fallback number', async () => { + const store = createTestStore() + const repoPath = '/repo' + const repoId = 'repo-1' + const branch = 'feature/merged-pr-behind-head' + const worktreeId = 'wt-merged-behind-fallback' + mockApi.gh.enqueuePRRefresh.mockResolvedValueOnce({ kind: 'queued' }) + + store.setState({ + repos: [{ id: repoId, path: repoPath, name: 'repo', kind: 'git' }], + worktreesByRepo: { + [repoId]: [ + makePRRefreshWorktree({ + id: worktreeId, + repoId, + branch, + head: 'behind-head' + }) + ] + }, + prCache: { + [`${repoId}::${branch}`]: { + data: makePR({ + number: 13, + title: 'Merged PR with unpulled final head', + state: 'merged', + headSha: 'merged-final-head', + confirmedContainedHeadOid: 'behind-head' + }), + fetchedAt: 1 + } + } + } as unknown as Partial) + + store.getState().enqueueGitHubPRRefresh(worktreeId, 'active', 80) + await Promise.resolve() + + // Why 13: a merged PR confirmed to contain this worktree head is still the + // branch's PR; losing the fallback number would blank the panel whenever + // GitHub stops reporting the deleted head by branch name. + expect(mockApi.gh.enqueuePRRefresh).toHaveBeenCalledWith( + expect.objectContaining({ + candidate: expect.objectContaining({ + fallbackPRNumber: 13, + // Why: main can only head-gate fallback preservation when the + // candidate carries the worktree head it was built for. + currentHeadOid: 'behind-head' + }) + }) + ) + }) + it('direct-fetches when enqueue returns an explicit fallback result', async () => { const store = createTestStore() const repoPath = '/repo' @@ -3864,7 +3965,7 @@ describe('createGitHubSlice.refreshGitHubForWorktreeIfStale', () => { expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ selector: 'env-1', method: 'github.prForBranch', - params: { repo: 'repo-1', branch, linkedPRNumber: 12 }, + params: { repo: 'repo-1', branch, linkedPRNumber: 12, currentHeadOid: null }, timeoutMs: 30_000 }) expect(store.getState().hostedReviewCache[hostedReviewCacheKey]).toMatchObject({ @@ -3914,7 +4015,7 @@ describe('createGitHubSlice.refreshGitHubForWorktreeIfStale', () => { expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ selector: 'env-1', method: 'github.prForBranch', - params: { repo: 'repo-runtime', branch, linkedPRNumber: null }, + params: { repo: 'repo-runtime', branch, linkedPRNumber: null, currentHeadOid: null }, timeoutMs: 30_000 }) expect(store.getState().prCache[`runtime:env-1::repo-runtime::${branch}`]?.data).toMatchObject({ @@ -4151,7 +4252,7 @@ describe('createGitHubSlice.refreshAllGitHub', () => { expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ selector: 'env-1', method: 'github.prForBranch', - params: { repo: 'repo-1', branch, linkedPRNumber: null }, + params: { repo: 'repo-1', branch, linkedPRNumber: null, currentHeadOid: null }, timeoutMs: 30_000 }) }) @@ -4281,7 +4382,7 @@ describe('createGitHubSlice.refreshGitHubForWorktree', () => { expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ selector: 'env-1', method: 'github.prForBranch', - params: { repo: 'repo-1', branch, linkedPRNumber: null }, + params: { repo: 'repo-1', branch, linkedPRNumber: null, currentHeadOid: null }, timeoutMs: 30_000 }) }) diff --git a/src/renderer/src/store/slices/github.ts b/src/renderer/src/store/slices/github.ts index c0f73e621d6..8478c519877 100644 --- a/src/renderer/src/store/slices/github.ts +++ b/src/renderer/src/store/slices/github.ts @@ -1038,8 +1038,14 @@ function buildPRRefreshCandidate( true ) const cachedFallbackPRNumber = cachedPR?.number ?? null + // Why: a merged PR stays a valid fallback when the worktree sits on its head or + // on a commit confirmed to be part of the PR; anything else means the branch + // moved on and the number must not resurrect the old merged PR. const cachedMergedPRMovedPastHead = - worktree.linkedPR == null && cachedPR?.state === 'merged' && cachedPR.headSha !== worktree.head + worktree.linkedPR == null && + cachedPR?.state === 'merged' && + cachedPR.headSha !== worktree.head && + cachedPR.confirmedContainedHeadOid !== worktree.head const fallbackPRNumber = worktree.linkedPR == null && !cachedMergedPRMovedPastHead ? (cachedFallbackPRNumber ?? hostedReviewFallbackPRNumber) @@ -1060,6 +1066,7 @@ function buildPRRefreshCandidate( branch, cacheKey, worktreeId: worktree.id, + currentHeadOid: worktree.head ?? null, // Why: persisted linked PR metadata is exact, while PR cache numbers are // only fallback hints after branch lookup misses. linkedPRNumber: worktree.linkedPR ?? null, @@ -1264,7 +1271,8 @@ function shouldPreserveExistingPRForFallbackMiss(args: { const worktree = args.worktreeId ? findWorktreeById(args.state, args.worktreeId) : null const worktreeHead = worktree?.head // Why: merged branch PRs are only safe to keep when cached PR metadata still - // matches the commit this stored worktree is actually on. + // matches the commit this stored worktree is actually on — exactly, or via a + // head confirmed to be part of the merged PR. const preservesMergedPRForCurrentHead = args.nextPR === null && args.linkedPRNumber == null && @@ -1273,7 +1281,8 @@ function shouldPreserveExistingPRForFallbackMiss(args: { args.currentPR.headSha.length > 0 && typeof worktreeHead === 'string' && worktreeHead.length > 0 && - args.currentPR.headSha === worktreeHead + (args.currentPR.headSha === worktreeHead || + args.currentPR.confirmedContainedHeadOid === worktreeHead) // Why: fallback PR numbers come from already-visible cache, not durable // worktree metadata. A branch/fallback miss is weaker than the current exact @@ -2941,6 +2950,9 @@ export const createGitHubSlice: StateCreator = (s const request = (async () => { try { const runtimeRepo = getRuntimeRepoTarget(get(), repoPath, requestSettings) + const candidateWorktree = options?.worktreeId + ? findWorktreeById(get(), options.worktreeId) + : null const outcome = runtimeRepo ? await callRuntimeRpc( runtimeRepo.target, @@ -2949,6 +2961,7 @@ export const createGitHubSlice: StateCreator = (s repo: runtimeRepo.repo.id, branch, linkedPRNumber, + currentHeadOid: candidateWorktree?.head ?? null, ...(fallbackPRNumber !== null ? { fallbackPRNumber, acceptMergedFallbackPR: fallbackPRSource !== null } : {}) @@ -2967,6 +2980,7 @@ export const createGitHubSlice: StateCreator = (s branch, cacheKey, worktreeId: options?.worktreeId, + currentHeadOid: candidateWorktree?.head ?? null, linkedPRNumber, fallbackPRNumber, fallbackPRSource, @@ -2988,7 +3002,9 @@ export const createGitHubSlice: StateCreator = (s branch, linkedPRNumber, fallbackPRNumber, - acceptMergedFallbackPR: fallbackPRNumber !== null && fallbackPRSource !== null + acceptMergedFallbackPR: + fallbackPRNumber !== null && fallbackPRSource !== null, + currentHeadOid: candidateWorktree?.head ?? null }) .then((pr) => pr diff --git a/src/renderer/src/store/slices/hosted-review.test.ts b/src/renderer/src/store/slices/hosted-review.test.ts index 8a07ede5595..7bd6c4f3dda 100644 --- a/src/renderer/src/store/slices/hosted-review.test.ts +++ b/src/renderer/src/store/slices/hosted-review.test.ts @@ -547,6 +547,39 @@ describe('hosted review slice', () => { expect(mockApi.hostedReview.forBranch).toHaveBeenCalledTimes(2) }) + it('serves a cached merged review whose confirmed contained head matches the worktree', async () => { + const mergedBehindHead: HostedReviewInfo = { + provider: 'github', + number: 7, + title: 'Merged with unpulled final head', + state: 'merged', + url: 'https://github.com/acme/orca/pull/7', + status: 'success', + updatedAt: '2026-05-10T00:00:00.000Z', + mergeable: 'MERGEABLE', + headSha: 'aaaaaaa', + confirmedContainedHeadOid: 'bbbbbbb' + } + mockApi.hostedReview.forBranch.mockResolvedValueOnce(mergedBehindHead) + const store = makeStore() + + await expect( + store.getState().fetchHostedReviewForBranch('/repo', 'feature/merged', { + currentHeadOid: 'bbbbbbb' + }) + ).resolves.toEqual(mergedBehindHead) + + // Why one call: a merged review confirmed for this worktree head is not + // stale, so the second read must reuse the branch-scoped cache instead of + // refetching every poll. + await expect( + store.getState().fetchHostedReviewForBranch('/repo', 'feature/merged', { + currentHeadOid: 'bbbbbbb' + }) + ).resolves.toEqual(mergedBehindHead) + expect(mockApi.hostedReview.forBranch).toHaveBeenCalledTimes(1) + }) + it('does not preserve a merged GitHub review after the worktree moves off its head', async () => { const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) const cacheKey = getHostedReviewCacheKey( diff --git a/src/renderer/src/store/slices/hosted-review.ts b/src/renderer/src/store/slices/hosted-review.ts index 710bdcc2233..a638d964c57 100644 --- a/src/renderer/src/store/slices/hosted-review.ts +++ b/src/renderer/src/store/slices/hosted-review.ts @@ -100,9 +100,10 @@ function isStaleMergedGitHubReviewForHead( cached: CacheEntry | undefined, currentHeadOid: string | null | undefined ): boolean { - // Why: a merged GitHub PR is only shown when the worktree sits on its head. - // The cache key is branch-scoped, so a worktree that advanced off the merged - // head must not reuse (or, on failure, preserve) the now-stale merged review. + // Why: a merged GitHub PR is only shown when the worktree sits on its head + // or on a commit confirmed to be part of the PR. The cache key is + // branch-scoped, so a worktree that advanced off the merged line of work + // must not reuse (or, on failure, preserve) the now-stale merged review. const head = typeof currentHeadOid === 'string' ? currentHeadOid.trim() : '' if (head.length === 0) { return false @@ -113,7 +114,8 @@ function isStaleMergedGitHubReviewForHead( data.state === 'merged' && typeof data.headSha === 'string' && data.headSha.length > 0 && - data.headSha !== head + data.headSha !== head && + data.confirmedContainedHeadOid !== head ) } diff --git a/src/renderer/src/store/slices/pane-foreground-agent.test.ts b/src/renderer/src/store/slices/pane-foreground-agent.test.ts new file mode 100644 index 00000000000..b20239a8e34 --- /dev/null +++ b/src/renderer/src/store/slices/pane-foreground-agent.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest' +import { createTestStore } from './store-test-helpers' +import type { TerminalTab } from '../../../../shared/types' + +function terminalTab(id: string, worktreeId: string): TerminalTab { + return { + id, + ptyId: null, + worktreeId, + title: 'Terminal 1', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +describe('pane foreground agent slice', () => { + it('sets, value-bails, and clears entries per pane key', () => { + const store = createTestStore() + store + .getState() + .setPaneForegroundAgent('tab-1:leaf-1', { agent: 'aider', shellForeground: false }) + const first = store.getState().paneForegroundAgentByPaneKey + + store + .getState() + .setPaneForegroundAgent('tab-1:leaf-1', { agent: 'aider', shellForeground: false }) + expect(store.getState().paneForegroundAgentByPaneKey).toBe(first) + + store.getState().clearPaneForegroundAgent('tab-1:leaf-1') + expect(store.getState().paneForegroundAgentByPaneKey).toEqual({}) + }) + + it('sweeps only the closed tab prefix, not sibling tabs or prefix-share ids', () => { + const store = createTestStore() + store + .getState() + .setPaneForegroundAgent('tab-1:leaf-1', { agent: 'aider', shellForeground: false }) + store + .getState() + .setPaneForegroundAgent('tab-10:leaf-1', { agent: 'codex', shellForeground: false }) + + store.getState().clearPaneForegroundAgentByTabPrefix('tab-1') + + expect(Object.keys(store.getState().paneForegroundAgentByPaneKey)).toEqual(['tab-10:leaf-1']) + }) + + it('sweeps every tab of a worktree on wholesale teardown', () => { + const store = createTestStore() + store.setState({ + tabsByWorktree: { + 'wt-1': [terminalTab('tab-1', 'wt-1'), terminalTab('tab-2', 'wt-1')], + 'wt-2': [terminalTab('tab-3', 'wt-2')] + } + }) + store + .getState() + .setPaneForegroundAgent('tab-1:leaf-1', { agent: 'aider', shellForeground: false }) + store.getState().setPaneForegroundAgent('tab-2:leaf-1', { agent: null, shellForeground: true }) + store + .getState() + .setPaneForegroundAgent('tab-3:leaf-1', { agent: 'codex', shellForeground: false }) + + const before = store.getState().paneForegroundAgentByPaneKey + store.getState().clearPaneForegroundAgentByWorktree('wt-missing') + expect(store.getState().paneForegroundAgentByPaneKey).toBe(before) + + store.getState().clearPaneForegroundAgentByWorktree('wt-1') + + expect(Object.keys(store.getState().paneForegroundAgentByPaneKey)).toEqual(['tab-3:leaf-1']) + }) +}) diff --git a/src/renderer/src/store/slices/pane-foreground-agent.ts b/src/renderer/src/store/slices/pane-foreground-agent.ts new file mode 100644 index 00000000000..c779c05b018 --- /dev/null +++ b/src/renderer/src/store/slices/pane-foreground-agent.ts @@ -0,0 +1,92 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { TuiAgent } from '../../../../shared/types' + +export type PaneForegroundAgentEntry = { + /** Recognized agent process in the pane's foreground; null when unknown. */ + agent: TuiAgent | null + /** True once the foreground is proven back at the shell (OSC 133;D) — + * process-grade launched-agent exit evidence, independent of titles. */ + shellForeground: boolean +} + +/** + * Process-table identity for local panes, read at OSC 133 command boundaries + * (see pane-foreground-agent-tracker). Sits below hook rows in the tab-icon + * resolution; covers agents that emit neither hooks nor titles. + */ +export type PaneForegroundAgentSlice = { + paneForegroundAgentByPaneKey: Record + setPaneForegroundAgent: (paneKey: string, entry: PaneForegroundAgentEntry) => void + clearPaneForegroundAgent: (paneKey: string) => void + /** Wholesale teardown sweeps (tab close, worktree sleep/remove) retire pane + * keys without per-pane close events — clear their entries too. */ + clearPaneForegroundAgentByTabPrefix: (tabIdPrefix: string) => void + clearPaneForegroundAgentByWorktree: (worktreeId: string) => void +} + +export const createPaneForegroundAgentSlice: StateCreator< + AppState, + [], + [], + PaneForegroundAgentSlice +> = (set) => ({ + paneForegroundAgentByPaneKey: {}, + setPaneForegroundAgent: (paneKey, entry) => { + set((s) => { + const current = s.paneForegroundAgentByPaneKey[paneKey] + if ( + current && + current.agent === entry.agent && + current.shellForeground === entry.shellForeground + ) { + return s + } + return { + paneForegroundAgentByPaneKey: { ...s.paneForegroundAgentByPaneKey, [paneKey]: entry } + } + }) + }, + clearPaneForegroundAgent: (paneKey) => { + set((s) => { + if (!(paneKey in s.paneForegroundAgentByPaneKey)) { + return s + } + const next = { ...s.paneForegroundAgentByPaneKey } + delete next[paneKey] + return { paneForegroundAgentByPaneKey: next } + }) + }, + clearPaneForegroundAgentByTabPrefix: (tabIdPrefix) => { + set((s) => clearEntriesByTabPrefixes(s.paneForegroundAgentByPaneKey, [`${tabIdPrefix}:`]) ?? s) + }, + clearPaneForegroundAgentByWorktree: (worktreeId) => { + // Why: entries carry no worktreeId, so this must run while the worktree's + // tabs are still in tabsByWorktree (removeWorktree prunes them only after + // awaiting terminal teardown). + set((s) => { + const prefixes = (s.tabsByWorktree[worktreeId] ?? []).map((tab) => `${tab.id}:`) + return clearEntriesByTabPrefixes(s.paneForegroundAgentByPaneKey, prefixes) ?? s + }) + } +}) + +function clearEntriesByTabPrefixes( + entries: Record, + tabPrefixes: string[] +): Pick | null { + if (tabPrefixes.length === 0) { + return null + } + const staleKeys = Object.keys(entries).filter((paneKey) => + tabPrefixes.some((prefix) => paneKey.startsWith(prefix)) + ) + if (staleKeys.length === 0) { + return null + } + const next = { ...entries } + for (const paneKey of staleKeys) { + delete next[paneKey] + } + return { paneForegroundAgentByPaneKey: next } +} diff --git a/src/renderer/src/store/slices/rate-limits.ts b/src/renderer/src/store/slices/rate-limits.ts index fa4d41b9b03..4513119ab69 100644 --- a/src/renderer/src/store/slices/rate-limits.ts +++ b/src/renderer/src/store/slices/rate-limits.ts @@ -21,6 +21,8 @@ export const createRateLimitSlice: StateCreator { + clearRuntimeCompatibilityCacheForTests() + reposList.mockReset() + // Only repos.list is exercised here — the missing projects API makes + // fetchProjectHostSetupCompatibility fall back to deriving from repos. + vi.stubGlobal('window', { api: { repos: { list: reposList } } }) +}) + +// A repos:changed burst (deleting a project group with contained projects) starts +// overlapping fetchRepos calls; the slice must keep the latest and drop superseded +// results so removed projects don't reappear until restart (#7020). +describe('repos slice stale-fetch race (#7020)', () => { + it('drops a stale repos fetch that resolves after a newer one', async () => { + const store = createTestStore() + let resolveStale!: (repos: Repo[]) => void + const stalePromise = new Promise((resolve) => { + resolveStale = resolve + }) + // Why: mirrors the delete-project-group burst — the first fetch reads + // pre-removal state (both repos) but resolves LAST; the second reads + // post-removal state (remoteRepo gone) and resolves first. + reposList.mockReturnValueOnce(stalePromise).mockResolvedValueOnce([localRepo]) + + const stale = store.getState().fetchRepos() + const fresh = store.getState().fetchRepos() + await fresh + expect(store.getState().repos.map((repo) => repo.id)).toEqual(['local-repo']) + + resolveStale([localRepo, remoteRepo]) + await stale + // The superseded fetch must not resurrect the removed repo. + expect(store.getState().repos.map((repo) => repo.id)).toEqual(['local-repo']) + }) + + it('a superseding fetch that later rejects still blocks the older stale fetch', async () => { + const store = createTestStore() + let resolveStale!: (repos: Repo[]) => void + const stalePromise = new Promise((resolve) => { + resolveStale = resolve + }) + // The stale fetch reads pre-removal state and resolves LAST; the superseding + // fetch reads post-removal state but REJECTS. Because the generation is + // claimed synchronously before the await, the failed fetch still supersedes + // the stale one, which must be dropped rather than resurrect remoteRepo. + reposList.mockReturnValueOnce(stalePromise).mockRejectedValueOnce(new Error('boom')) + + const stale = store.getState().fetchRepos() + await store.getState().fetchRepos() + + resolveStale([localRepo, remoteRepo]) + await stale + expect(store.getState().repos).toEqual([]) + }) +}) diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index 3b382cb1bef..b44f4e177d7 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -1152,6 +1152,8 @@ export type RepoSlice = { folderWorkspaces: FolderWorkspace[] folderWorkspacePathStatuses: Record activeRepoId: string | null + // Monotonic sequence so an overlapping fetchRepos can drop its own stale result (#7020). + reposFetchGeneration: number fetchRepos: () => Promise fetchReposForAllHosts: () => Promise fetchRuntimeEnvironmentRepos: (environmentId: string) => Promise @@ -1270,8 +1272,16 @@ export const createRepoSlice: StateCreator = (set, folderWorkspaces: [], folderWorkspacePathStatuses: {}, activeRepoId: null, + reposFetchGeneration: 0, fetchRepos: async () => { + // Why: overlapping repos:changed fetches can resolve out of order; an earlier + // one must not overwrite a newer result and resurrect deleted projects (#7020). + let generation = 0 + set((s) => { + generation = s.reposFetchGeneration + 1 + return { reposFetchGeneration: generation } + }) try { const target = getActiveRuntimeTarget(get().settings) const { @@ -1279,6 +1289,10 @@ export const createRepoSlice: StateCreator = (set, projectCompatibility, hostId } = await fetchReposForTarget(target, get().repos) + // A newer fetchRepos superseded us while we awaited — drop this stale result. + if (get().reposFetchGeneration !== generation) { + return + } set((s) => { const validRepoIds = new Set(reconciledRepos.map((repo) => repo.id)) const mergedProjectCompatibility = mergeFetchedProjectCompatibilityForHost({ diff --git a/src/renderer/src/store/slices/store-cascades.test.ts b/src/renderer/src/store/slices/store-cascades.test.ts index 6412e2c1935..dd4b531fbd8 100644 --- a/src/renderer/src/store/slices/store-cascades.test.ts +++ b/src/renderer/src/store/slices/store-cascades.test.ts @@ -1117,6 +1117,57 @@ describe('setActiveWorktree', () => { expect(groups[0].tabOrder).toEqual([terminal.id]) }) + it('moves live PTY ownership when detaching a primary pane to a tab', () => { + const store = createTestStore() + const wt = 'repo1::/path/wt1' + const sourceTabId = 'tab-source' + const targetTabId = 'tab-target' + + seedStore(store, { + worktreesByRepo: { + repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })] + }, + tabsByWorktree: { + [wt]: [ + makeTab({ id: sourceTabId, worktreeId: wt, ptyId: 'pty-detached' }), + makeTab({ id: targetTabId, worktreeId: wt, ptyId: null }) + ] + }, + ptyIdsByTabId: { + [sourceTabId]: ['pty-detached', 'pty-survivor'], + [targetTabId]: ['pty-detached'] + }, + lastKnownRelayPtyIdByTabId: { + [sourceTabId]: 'pty-detached', + [targetTabId]: 'pty-detached' + } + }) + + store.getState().syncPaneDetachPtyOwnership({ + detachedPtyId: 'pty-detached', + sourceLayout: { + root: { type: 'leaf', leafId: 'survivor-leaf' }, + activeLeafId: 'survivor-leaf', + expandedLeafId: null, + ptyIdsByLeafId: { 'survivor-leaf': 'pty-survivor' } + }, + sourceTabId, + targetTabId + }) + + const state = store.getState() + expect(state.ptyIdsByTabId[sourceTabId]).toEqual(['pty-survivor']) + expect(state.ptyIdsByTabId[targetTabId]).toEqual(['pty-detached']) + expect(state.lastKnownRelayPtyIdByTabId[sourceTabId]).toBe('pty-survivor') + expect(state.lastKnownRelayPtyIdByTabId[targetTabId]).toBe('pty-detached') + expect(state.tabsByWorktree[wt].find((tab) => tab.id === sourceTabId)?.ptyId).toBe( + 'pty-survivor' + ) + expect(state.tabsByWorktree[wt].find((tab) => tab.id === targetTabId)?.ptyId).toBe( + 'pty-detached' + ) + }) + it('stores trimmed quick command labels on terminal and unified tabs', () => { const store = createTestStore() const wt = 'repo1::/path/wt1' @@ -1146,6 +1197,30 @@ describe('setActiveWorktree', () => { ) }) + it('stores terminal startup cwd exactly and omits empty values', () => { + const store = createTestStore() + const wt = 'repo1::/path/wt1' + + seedStore(store, { + worktreesByRepo: { + repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })] + } + }) + + const nested = store + .getState() + .createTab(wt, undefined, undefined, { startupCwd: '/path/wt1/packages/app ' }) + const empty = store.getState().createTab(wt, undefined, undefined, { startupCwd: '' }) + const state = store.getState() + + expect(state.tabsByWorktree[wt].find((tab) => tab.id === nested.id)?.startupCwd).toBe( + '/path/wt1/packages/app ' + ) + expect(state.tabsByWorktree[wt].find((tab) => tab.id === empty.id)).not.toHaveProperty( + 'startupCwd' + ) + }) + it('stamps the Windows default shell onto new terminal tabs', () => { const originalNavigator = globalThis.navigator Object.defineProperty(globalThis, 'navigator', { @@ -2840,6 +2915,188 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => { expect(dropByWorktree).not.toHaveBeenCalled() }) + it('aborts pane hibernation without side effects when no resume record can be captured', async () => { + // The prod ghost pane was killed with NO sleeping record captured, so + // nothing could ever wake it. Planner eligibility can go stale between + // ticks, so the shutdown must throw before any suppression or kill when the + // capture comes back empty (a done agent with no resumable provider + // session), leaving the pane fully intact for a later retry. + const store = createTestStore() + const wt = 'repo1::/path/wt1' + const targetLeaf = '11111111-1111-4111-8111-111111111111' + const siblingLeaf = '22222222-2222-4222-8222-222222222222' + const targetPaneKey = `tab-1:${targetLeaf}` + + seedStore(store, { + worktreesByRepo: { + repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })] + }, + tabsByWorktree: { + [wt]: [makeTab({ id: 'tab-1', worktreeId: wt, title: 'Codex', ptyId: 'pty-agent' })] + }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: targetLeaf }, + second: { type: 'leaf', leafId: siblingLeaf } + }, + activeLeafId: siblingLeaf, + expandedLeafId: null, + ptyIdsByLeafId: { [targetLeaf]: 'pty-agent', [siblingLeaf]: 'pty-shell' } + } + }, + ptyIdsByTabId: { 'tab-1': ['pty-agent', 'pty-shell'] } + }) + // A done agent with no provider session yields no resumable sleeping record. + store + .getState() + .setAgentStatus( + targetPaneKey, + { state: 'done', prompt: 'resume target', agentType: 'codex' }, + 'Codex', + { updatedAt: 2000, stateStartedAt: 1000 }, + { tabId: 'tab-1', worktreeId: wt } + ) + + await expect( + store.getState().shutdownCompletedAgentPaneForHibernation(wt, { + paneKey: targetPaneKey, + tabId: 'tab-1', + leafId: targetLeaf, + ptyId: 'pty-agent' + }) + ).rejects.toThrow('agent_hibernation_capture_missing') + + const state = store.getState() + // Nothing was suppressed, killed, or persisted — the pane is untouched. + expect(mockApi.pty.kill).not.toHaveBeenCalled() + expect(state.suppressedPtyExitIds['pty-agent']).toBeUndefined() + expect(state.sleepingAgentSessionsByPaneKey[targetPaneKey]).toBeUndefined() + expect(state.ptyIdsByTabId['tab-1']).toEqual(['pty-agent', 'pty-shell']) + expect(state.agentStatusByPaneKey[targetPaneKey]).toBeDefined() + }) + + it('rolls back the sleeping record and suppression when the hibernation kill fails', async () => { + // The sleeping record must be visible to the pane's exit handler BEFORE the + // kill (pty:exit can beat the kill promise back to the renderer), so it is + // written alongside the suppression — and both must roll back if the kill + // fails, or a live pane would carry a stale wake record. + const store = createTestStore() + const wt = 'repo1::/path/wt1' + const targetLeaf = '11111111-1111-4111-8111-111111111111' + const targetPaneKey = `tab-1:${targetLeaf}` + + seedStore(store, { + worktreesByRepo: { + repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })] + }, + tabsByWorktree: { + [wt]: [makeTab({ id: 'tab-1', worktreeId: wt, title: 'Claude', ptyId: 'pty-agent' })] + }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: targetLeaf }, + activeLeafId: targetLeaf, + expandedLeafId: null, + ptyIdsByLeafId: { [targetLeaf]: 'pty-agent' } + } + }, + ptyIdsByTabId: { 'tab-1': ['pty-agent'] } + }) + store + .getState() + .setAgentStatus( + targetPaneKey, + { state: 'done', prompt: 'resume target', agentType: 'claude' }, + 'Claude', + { updatedAt: 2000, stateStartedAt: 1000 }, + { tabId: 'tab-1', worktreeId: wt }, + { providerSession: { key: 'session_id', id: 'sess-rollback-1' } } + ) + mockApi.pty.kill.mockRejectedValueOnce(new Error('kill_failed')) + + await expect( + store.getState().shutdownCompletedAgentPaneForHibernation(wt, { + paneKey: targetPaneKey, + tabId: 'tab-1', + leafId: targetLeaf, + ptyId: 'pty-agent' + }) + ).rejects.toThrow('kill_failed') + + const state = store.getState() + expect(state.suppressedPtyExitIds['pty-agent']).toBeUndefined() + expect(state.sleepingAgentSessionsByPaneKey[targetPaneKey]).toBeUndefined() + expect(state.agentStatusByPaneKey[targetPaneKey]).toBeDefined() + }) + + it('persists the sleeping record and suppression before issuing the hibernation kill', async () => { + // pty:exit can beat the kill promise back to the renderer, and the pane's + // exit handler arms the hibernation wake only if the sleeping record is + // already in the store. A record written after the kill resolves passes + // every end-state assertion while still losing that race, so this test + // observes the store at the moment the kill is issued. + const store = createTestStore() + const wt = 'repo1::/path/wt1' + const targetLeaf = '11111111-1111-4111-8111-111111111111' + const targetPaneKey = `tab-1:${targetLeaf}` + + seedStore(store, { + worktreesByRepo: { + repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })] + }, + tabsByWorktree: { + [wt]: [makeTab({ id: 'tab-1', worktreeId: wt, title: 'Claude', ptyId: 'pty-agent' })] + }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: targetLeaf }, + activeLeafId: targetLeaf, + expandedLeafId: null, + ptyIdsByLeafId: { [targetLeaf]: 'pty-agent' } + } + }, + ptyIdsByTabId: { 'tab-1': ['pty-agent'] } + }) + store + .getState() + .setAgentStatus( + targetPaneKey, + { state: 'done', prompt: 'resume target', agentType: 'claude' }, + 'Claude', + { updatedAt: 2000, stateStartedAt: 1000 }, + { tabId: 'tab-1', worktreeId: wt }, + { providerSession: { key: 'session_id', id: 'sess-ordering-1' } } + ) + let recordAtKillTime: unknown = null + let suppressionAtKillTime: boolean | undefined + mockApi.pty.kill.mockImplementationOnce(async () => { + const atKill = store.getState() + recordAtKillTime = atKill.sleepingAgentSessionsByPaneKey[targetPaneKey] + suppressionAtKillTime = atKill.suppressedPtyExitIds['pty-agent'] + }) + + await store.getState().shutdownCompletedAgentPaneForHibernation(wt, { + paneKey: targetPaneKey, + tabId: 'tab-1', + leafId: targetLeaf, + ptyId: 'pty-agent' + }) + + expect(mockApi.pty.kill).toHaveBeenCalledWith('pty-agent', { keepHistory: true }) + expect(recordAtKillTime).toMatchObject({ + paneKey: targetPaneKey, + providerSession: { key: 'session_id', id: 'sess-ordering-1' } + }) + expect(suppressionAtKillTime).toBe(true) + // The record must survive the successful kill so the reveal-time wake can + // consume it. + const state = store.getState() + expect(state.sleepingAgentSessionsByPaneKey[targetPaneKey]).toBeDefined() + }) + it('keeps manual sleep worktree-wide', async () => { const store = createTestStore() const wt = 'repo1::/path/wt1' diff --git a/src/renderer/src/store/slices/store-test-helpers.ts b/src/renderer/src/store/slices/store-test-helpers.ts index 9045837ae3f..b1e09bf4ea6 100644 --- a/src/renderer/src/store/slices/store-test-helpers.ts +++ b/src/renderer/src/store/slices/store-test-helpers.ts @@ -32,6 +32,7 @@ import { createBrowserSlice } from './browser' import { createRateLimitSlice } from './rate-limits' import { createSshSlice } from './ssh' import { createAgentStatusSlice } from './agent-status' +import { createPaneForegroundAgentSlice } from './pane-foreground-agent' import { createDiffCommentsSlice } from './diffComments' import { createDetectedAgentsSlice } from './detected-agents' import { createWorktreeNavHistorySlice } from './worktree-nav-history' @@ -77,6 +78,7 @@ export function createTestStore() { ...createRateLimitSlice(...a), ...createSshSlice(...a), ...createAgentStatusSlice(...a), + ...createPaneForegroundAgentSlice(...a), ...createDiffCommentsSlice(...a), ...createDetectedAgentsSlice(...a), ...createWorktreeNavHistorySlice(...a), diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 9af11f13a46..3b99e38374a 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -294,6 +294,36 @@ function equalStringSets(a: readonly string[], b: readonly string[]): boolean { return a.every((value) => bSet.has(value)) } +function uniquePtyIds(ptyIds: readonly (string | null | undefined)[]): string[] { + return [...new Set(ptyIds.filter((ptyId): ptyId is string => Boolean(ptyId)))] +} + +function resolvePrimaryLayoutPtyId(layout: TerminalLayoutSnapshot): string | null { + const ptyIdsByLeafId = layout.ptyIdsByLeafId ?? {} + const activePtyId = layout.activeLeafId ? ptyIdsByLeafId[layout.activeLeafId] : undefined + return activePtyId ?? Object.values(ptyIdsByLeafId)[0] ?? null +} + +function withTerminalTabPtyId( + tabsByWorktree: Record, + tabId: string, + ptyId: string | null +): Record { + for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { + const index = tabs.findIndex((tab) => tab.id === tabId) + if (index === -1) { + continue + } + if (tabs[index]?.ptyId === ptyId) { + return tabsByWorktree + } + const nextTabs = [...tabs] + nextTabs[index] = { ...nextTabs[index]!, ptyId } + return { ...tabsByWorktree, [worktreeId]: nextTabs } + } + return tabsByWorktree +} + export type AutomaticAgentResumeClaim = { worktreeId: string launchAgent: TuiAgent @@ -446,6 +476,7 @@ export type TerminalSlice = { * `'chat'` so the tab opens in the native chat view; omitted otherwise * so the tab keeps the implicit `'terminal'` default. */ viewMode?: Tab['viewMode'] + startupCwd?: string } ) => TerminalTab openNewTerminalTabInActiveWorkspace: (groupId: string) => Promise @@ -511,6 +542,12 @@ export type TerminalSlice = { setTabPaneExpanded: (tabId: string, expanded: boolean) => void setTabCanExpandPane: (tabId: string, canExpand: boolean) => void setTabLayout: (tabId: string, layout: TerminalLayoutSnapshot | null) => void + syncPaneDetachPtyOwnership: (args: { + detachedPtyId: string | null + sourceLayout: TerminalLayoutSnapshot + sourceTabId: string + targetTabId: string + }) => void queueTabStartupCommand: ( tabId: string, startup: { @@ -806,6 +843,7 @@ export const createTerminalSlice: StateCreator const nextOrdinal = getNextTerminalOrdinal(existing) const defaultTitle = `Terminal ${nextOrdinal}` const quickCommandLabel = options?.quickCommandLabel?.trim() + const startupCwd = options?.startupCwd const remoteConnectionId = getRemoteConnectionIdForWorktree(s, worktreeId) const isRemoteWorktree = Boolean(remoteConnectionId) const isWslWorktree = worktreeUsesWslPath(s, worktreeId) @@ -842,6 +880,7 @@ export const createTerminalSlice: StateCreator sortOrder: existing.length, createdAt: Date.now(), ...(createdShellOverride !== undefined ? { shellOverride: createdShellOverride } : {}), + ...(startupCwd && startupCwd.length > 0 ? { startupCwd } : {}), ...(options?.launchAgent ? { launchAgent: options.launchAgent } : {}), // Why: when Terminal.tsx's activation fallback auto-creates a tab for a // first-visit worktree, the resulting PTY spawn is caused by the user @@ -1190,6 +1229,9 @@ export const createTerminalSlice: StateCreator // so retention suppressors are planted: a live→gone transition inside the // same frame as the tab close cannot re-snapshot a row we just dropped. get().dropAgentStatusByTabPrefix(tabId) + // Why: retired pane keys never recur, so stranded foreground entries would + // accumulate for the renderer's whole lifetime. + get().clearPaneForegroundAgentByTabPrefix(tabId) // Why: closing a tab permanently retires every pane under it (a reopen mints // a fresh leafId at epoch 0), so drop the panes' hibernation output epochs to // keep that module-level map from growing for the renderer's whole lifetime. @@ -1878,6 +1920,12 @@ export const createTerminalSlice: StateCreator worktreeId, paneKeys ) + if (!sleepingAgentSessionRecords[opts.paneKey]) { + // Why: killing the PTY without a persisted resume record strands the + // pane — nothing can ever wake it. Planner eligibility can go stale + // between ticks; abort this round instead of hibernating unrecoverably. + throw new Error('agent_hibernation_capture_missing') + } const capture = shutdownBufferCaptures.get(opts.tabId) if (capture) { @@ -1888,13 +1936,35 @@ export const createTerminalSlice: StateCreator } } - const clearTargetSuppressions = (): void => { + // Why: the pane's exit handler consults sleepingAgentSessionsByPaneKey to + // tell a hibernation kill from other suppressed exits. pty:exit can reach + // the renderer before the kill promise resolves, so the record must be in + // the store BEFORE the kill is issued — and rolled back if the kill fails. + const sleepingRecordKeys = Object.keys(sleepingAgentSessionRecords) + const replacedSleepingRecords: Record = {} + for (const key of sleepingRecordKeys) { + const existing = state.sleepingAgentSessionsByPaneKey[key] + if (existing) { + replacedSleepingRecords[key] = existing + } + } + + const rollbackTargetShutdownState = (): void => { set((s) => { const next = { ...s.suppressedPtyExitIds } for (const ptyId of shutdownPtyIds) { delete next[ptyId] } - return { suppressedPtyExitIds: next } + const nextSleeping = { ...s.sleepingAgentSessionsByPaneKey } + for (const key of sleepingRecordKeys) { + const replaced = replacedSleepingRecords[key] + if (replaced) { + nextSleeping[key] = replaced + } else { + delete nextSleeping[key] + } + } + return { suppressedPtyExitIds: next, sleepingAgentSessionsByPaneKey: nextSleeping } }) } @@ -1902,13 +1972,17 @@ export const createTerminalSlice: StateCreator suppressedPtyExitIds: { ...s.suppressedPtyExitIds, ...Object.fromEntries(shutdownPtyIds.map((ptyId) => [ptyId, true] as const)) + }, + sleepingAgentSessionsByPaneKey: { + ...s.sleepingAgentSessionsByPaneKey, + ...sleepingAgentSessionRecords } })) if (expectedRuntimePtyIds.length > 0) { const runtimeEnvironmentId = resolveTerminalStopRuntimeEnvironmentId(get(), worktreeId) if (!runtimeEnvironmentId) { - clearTargetSuppressions() + rollbackTargetShutdownState() throw new Error('missing_runtime_for_exact_terminal_stop') } let stopResult: { @@ -1935,18 +2009,18 @@ export const createTerminalSlice: StateCreator { timeoutMs: 15_000 } ) } catch (err) { - clearTargetSuppressions() + rollbackTargetShutdownState() throw err } const stoppedPtyIds = sortedUniquePtyIds(stopResult.stoppedPtyIds) const livePtyIds = sortedUniquePtyIds(stopResult.livePtyIds) const targetWasLive = expectedRuntimePtyIds.every((ptyId) => livePtyIds.includes(ptyId)) if (!equalStringSets(stoppedPtyIds, expectedRuntimePtyIds) || !targetWasLive) { - clearTargetSuppressions() + rollbackTargetShutdownState() throw new Error('exact_terminal_stop_mismatch') } if (stopResult.postStopVerified !== true) { - clearTargetSuppressions() + rollbackTargetShutdownState() throw new Error(stopResult.postStopFailure ?? 'exact_terminal_stop_unverified') } unregisterPtyDataHandlers(shutdownPtyIds) @@ -1958,7 +2032,7 @@ export const createTerminalSlice: StateCreator await window.api.pty.kill(opts.ptyId, { keepHistory: true }) } catch (err) { restorePtyDataHandlersAfterFailedShutdown(handlerSnapshots) - clearTargetSuppressions() + rollbackTargetShutdownState() throw err } } @@ -2035,13 +2109,6 @@ export const createTerminalSlice: StateCreator } }) - set((s) => ({ - sleepingAgentSessionsByPaneKey: { - ...s.sleepingAgentSessionsByPaneKey, - ...sleepingAgentSessionRecords - } - })) - get().dropHibernatedAgentStatusPane(worktreeId, opts.paneKey, { retainedCompletionEvidence }) @@ -2355,6 +2422,7 @@ export const createTerminalSlice: StateCreator sleepingPaneKeys: opts?.sleepingPaneKeys, retainedCompletionEvidence }) + get().clearPaneForegroundAgentByWorktree(worktreeId) if (ptyIds.length === 0 && expectedRuntimePtyIds.length === 0) { return @@ -2497,6 +2565,54 @@ export const createTerminalSlice: StateCreator }) }, + syncPaneDetachPtyOwnership: ({ detachedPtyId, sourceLayout, sourceTabId, targetTabId }) => { + set((s) => { + const layoutSourcePtyIds = uniquePtyIds(Object.values(sourceLayout.ptyIdsByLeafId ?? {})) + const existingSourcePtyIds = (s.ptyIdsByTabId[sourceTabId] ?? []).filter( + (ptyId) => ptyId !== detachedPtyId + ) + const sourcePtyIds = layoutSourcePtyIds.length > 0 ? layoutSourcePtyIds : existingSourcePtyIds + const sourcePrimaryPtyId = resolvePrimaryLayoutPtyId(sourceLayout) ?? sourcePtyIds[0] ?? null + const nextPtyIdsByTabId = { + ...s.ptyIdsByTabId, + [sourceTabId]: sourcePtyIds + } + if (detachedPtyId) { + nextPtyIdsByTabId[targetTabId] = uniquePtyIds([ + ...(nextPtyIdsByTabId[targetTabId] ?? []), + detachedPtyId + ]) + } + + const nextLastKnownRelayPtyIdByTabId = { ...s.lastKnownRelayPtyIdByTabId } + if (sourcePrimaryPtyId) { + nextLastKnownRelayPtyIdByTabId[sourceTabId] = sourcePrimaryPtyId + } else { + delete nextLastKnownRelayPtyIdByTabId[sourceTabId] + } + if (detachedPtyId) { + nextLastKnownRelayPtyIdByTabId[targetTabId] = detachedPtyId + } + + // Why: pane-to-tab detach moves live PTY ownership without spawning or + // exiting processes, so sync identity maps directly without activity bumps. + const sourceTabsByWorktree = withTerminalTabPtyId( + s.tabsByWorktree, + sourceTabId, + sourcePrimaryPtyId + ) + const nextTabsByWorktree = detachedPtyId + ? withTerminalTabPtyId(sourceTabsByWorktree, targetTabId, detachedPtyId) + : sourceTabsByWorktree + + return { + ptyIdsByTabId: nextPtyIdsByTabId, + lastKnownRelayPtyIdByTabId: nextLastKnownRelayPtyIdByTabId, + ...(nextTabsByWorktree !== s.tabsByWorktree ? { tabsByWorktree: nextTabsByWorktree } : {}) + } + }) + }, + queueTabStartupCommand: (tabId, startup) => { // Why: launchToken is only meaningful for tracked launch-config reuse; // plain startup commands must not mint or carry a synthetic token. diff --git a/src/renderer/src/store/slices/ui.test.ts b/src/renderer/src/store/slices/ui.test.ts index 4b64524c786..64899d1a169 100644 --- a/src/renderer/src/store/slices/ui.test.ts +++ b/src/renderer/src/store/slices/ui.test.ts @@ -1162,11 +1162,18 @@ describe('createUISlice hydratePersistedUI', () => { }) ) - expect(store.getState().statusBarItems).toEqual(['claude', 'resource-usage', 'ports', 'kimi']) + expect(store.getState().statusBarItems).toEqual([ + 'claude', + 'resource-usage', + 'ports', + 'kimi', + 'minimax' + ]) expect(setUI).toHaveBeenCalledWith({ - statusBarItems: ['claude', 'resource-usage', 'ports', 'kimi'], + statusBarItems: ['claude', 'resource-usage', 'ports', 'kimi', 'minimax'], _portsStatusBarDefaultAdded: true, - _kimiStatusBarDefaultAdded: true + _kimiStatusBarDefaultAdded: true, + _minimaxStatusBarDefaultAdded: true }) }) @@ -1179,7 +1186,8 @@ describe('createUISlice hydratePersistedUI', () => { makePersistedUI({ statusBarItems: ['claude', 'resource-usage'], _portsStatusBarDefaultAdded: true, - _kimiStatusBarDefaultAdded: true + _kimiStatusBarDefaultAdded: true, + _minimaxStatusBarDefaultAdded: true }) ) diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 5e0c78c2945..c8e5f112e39 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -263,6 +263,7 @@ function migrateStatusBarItems(items: readonly string[] | undefined): StatusBarI const DEFAULT_ON_PORTS_STATUS_BAR_ITEM: StatusBarItem = 'ports' const DEFAULT_ON_KIMI_STATUS_BAR_ITEM: StatusBarItem = 'kimi' +const DEFAULT_ON_MINIMAX_STATUS_BAR_ITEM: StatusBarItem = 'minimax' function normalizeHydratedVisibleWorkspaceHostIds(ui: PersistedUIState): VisibleWorkspaceHostIds { const visibleHostIds = normalizeVisibleExecutionHostIds(ui.visibleWorkspaceHostIds) @@ -2218,15 +2219,22 @@ export const createUISlice: StateCreator = (set, get) ui._kimiStatusBarDefaultAdded || statusBarItemsWithPorts.includes('kimi') ? statusBarItemsWithPorts : [...statusBarItemsWithPorts, DEFAULT_ON_KIMI_STATUS_BAR_ITEM] + const statusBarItemsWithMiniMax = + ui._minimaxStatusBarDefaultAdded || statusBarItems.includes('minimax') + ? statusBarItems + : [...statusBarItems, DEFAULT_ON_MINIMAX_STATUS_BAR_ITEM] if ( - (!ui._portsStatusBarDefaultAdded || !ui._kimiStatusBarDefaultAdded) && + (!ui._portsStatusBarDefaultAdded || + !ui._kimiStatusBarDefaultAdded || + !ui._minimaxStatusBarDefaultAdded) && typeof window !== 'undefined' ) { window.api.ui .set({ - statusBarItems, + statusBarItems: statusBarItemsWithMiniMax, _portsStatusBarDefaultAdded: true, - _kimiStatusBarDefaultAdded: true + _kimiStatusBarDefaultAdded: true, + _minimaxStatusBarDefaultAdded: true }) .catch(console.error) } @@ -2286,7 +2294,7 @@ export const createUISlice: StateCreator = (set, get) workspaceBoardOpacity: clampWorkspaceBoardOpacity(ui.workspaceBoardOpacity), workspaceBoardColumnWidth: clampWorkspaceBoardColumnWidth(ui.workspaceBoardColumnWidth), syncTaskStatusFromWorkspaceBoard: ui.syncTaskStatusFromWorkspaceBoard === true, - statusBarItems, + statusBarItems: statusBarItemsWithMiniMax, statusBarVisible: ui.statusBarVisible ?? true, // Why: absent → true so existing users see the pet the first time // they enable the experimental flag. Only an explicit Hide pet diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index ce3ba3176b5..58dfa5cb7e2 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -91,6 +91,7 @@ const mockApi = { globalThis.window = { api: mockApi } import { + WORKTREE_REFRESH_CONCURRENCY, createWorktreeSlice, getHostedReviewLinkMutationGenerationForTests, getHostedReviewLinkWorktreeAliasCountForTests, @@ -5648,7 +5649,7 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => { it('bounds concurrent repo scans during hydration-time refresh', async () => { const store = createTestStore() - const repos = Array.from({ length: 7 }, (_, index) => ({ + const repos = Array.from({ length: WORKTREE_REFRESH_CONCURRENCY + 2 }, (_, index) => ({ id: `repo-${index}`, path: `/repos/${index}`, displayName: `repo-${index}`, @@ -5670,14 +5671,14 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => { await store.getState().fetchAllWorktrees() - expect(maxActiveScans).toBeLessThanOrEqual(5) + expect(maxActiveScans).toBeLessThanOrEqual(WORKTREE_REFRESH_CONCURRENCY) expect(mockApi.worktrees.list).toHaveBeenCalledTimes(repos.length) expect(store.getState().hasHydratedWorktreePurge).toBe(true) }) it('bounds concurrent repo scans after the hydration purge has run', async () => { const store = createTestStore() - const repos = Array.from({ length: 7 }, (_, index) => ({ + const repos = Array.from({ length: WORKTREE_REFRESH_CONCURRENCY + 2 }, (_, index) => ({ id: `repo-${index}`, path: `/repos/${index}`, displayName: `repo-${index}`, @@ -5702,7 +5703,7 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => { await store.getState().fetchAllWorktrees() - expect(maxActiveScans).toBeLessThanOrEqual(5) + expect(maxActiveScans).toBeLessThanOrEqual(WORKTREE_REFRESH_CONCURRENCY) expect(mockApi.worktrees.list).toHaveBeenCalledTimes(repos.length) expect(store.getState().hasHydratedWorktreePurge).toBe(true) }) diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 0eb99b91b55..5966530cc2d 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -72,7 +72,10 @@ const REMOTE_WORKTREE_LIST_PARITY_LIMIT = 10_000 const ACTIVE_WORKTREE_TERMINAL_PREP_DELAY_MS = 300 const ACTIVE_WORKTREE_TERMINAL_PREP_INPUT_QUIET_MS = 450 const ACTIVE_WORKTREE_TERMINAL_PREP_IDLE_TIMEOUT_MS = 180 -const WORKTREE_REFRESH_CONCURRENCY = 5 +// Why: each repo's `git worktree list` is an independent main-process child, so +// a higher ceiling cuts startup scan batches (#7225) while staying bounded so +// one UI moment can't launch every git probe at once. +export const WORKTREE_REFRESH_CONCURRENCY = 8 const pendingActivationTerminalPrepCancels = new Map void>() const detachedHeadAutoDerivedDisplayNames = new Map() const folderWorkspaceWorktreeCache = new WeakMap() @@ -1900,6 +1903,25 @@ function buildWorktreePurgeState(s: AppState, worktreeIds: string[]): Partial(obj: Record): Record => { + // Null-tolerant like omitByTabId: some worktree-isolation callers omit these + // slices entirely, and the production store always initializes them to {}. + if (!obj) { + return obj + } + let changed = false + const out = { ...obj } + for (const paneKey of Object.keys(obj)) { + const sep = paneKey.indexOf(':') + if (sep > 0 && doomedTabIds.has(paneKey.slice(0, sep))) { + delete out[paneKey] + changed = true + } + } + return changed ? out : obj + } const omitByBrowserWorkspaceId = (obj: Record): Record => { let changed = false const out = { ...obj } @@ -1970,6 +1992,25 @@ function buildWorktreePurgeState(s: AppState, worktreeIds: string[]): Partial { expect(runtimeCalls).toEqual([{ method: 'settings.get', params: undefined }]) }) + it('hydrates MiniMax usage settings from a paired runtime', async () => { + const runtimeCalls: { method: string; params: unknown }[] = [] + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(method: string, params?: unknown): Promise> { + runtimeCalls.push({ method, params }) + return Promise.resolve({ + id: `call-${runtimeCalls.length}`, + ok: true, + result: { + settings: { + minimaxGroupId: 'group-42', + minimaxUsageModels: 'general,abab6.5' + } + }, + _meta: { runtimeId: 'runtime-1' } + }) + } + + close(): void {} + } + })) + + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage) + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + const settings = await globals.window.api.settings.get() + const stored = JSON.parse(globals.storage.getItem('orca.web.settings.v1') ?? '{}') as { + minimaxGroupId?: string + minimaxUsageModels?: string + } + + expect(settings.minimaxGroupId).toBe('group-42') + expect(settings.minimaxUsageModels).toBe('general,abab6.5') + expect(stored.minimaxGroupId).toBe('group-42') + expect(stored.minimaxUsageModels).toBe('general,abab6.5') + expect(runtimeCalls).toEqual([{ method: 'settings.get', params: undefined }]) + }) + it('forwards compact worktree card updates to a paired runtime', async () => { const runtimeCalls: { method: string; params: unknown }[] = [] vi.doMock('./web-runtime-client', () => ({ @@ -478,6 +519,77 @@ describe('web settings preload API', () => { { method: 'settings.update', params: { experimentalNewWorktreeCardStyle: true } } ]) }) + + it('forwards MiniMax usage setting updates to a paired runtime', async () => { + const runtimeCalls: { method: string; params: unknown }[] = [] + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(method: string, params?: unknown): Promise> { + runtimeCalls.push({ method, params }) + return Promise.resolve({ + id: `call-${runtimeCalls.length}`, + ok: true, + result: { + settings: { + minimaxGroupId: 'group-42', + minimaxUsageModels: 'general,abab6.5' + } + }, + _meta: { runtimeId: 'runtime-1' } + }) + } + + close(): void {} + } + })) + + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage) + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + const settings = await globals.window.api.settings.set({ + minimaxGroupId: 'group-42', + minimaxUsageModels: 'general,abab6.5' + }) + + const stored = JSON.parse(globals.storage.getItem('orca.web.settings.v1') ?? '{}') as { + minimaxGroupId?: string + minimaxUsageModels?: string + } + + expect(settings.minimaxGroupId).toBe('group-42') + expect(settings.minimaxUsageModels).toBe('general,abab6.5') + expect(stored.minimaxGroupId).toBe('group-42') + expect(stored.minimaxUsageModels).toBe('general,abab6.5') + expect(runtimeCalls).toEqual([ + { + method: 'settings.update', + params: { + minimaxGroupId: 'group-42', + minimaxUsageModels: 'general,abab6.5' + } + } + ]) + }) +}) + +describe('web MiniMax preload API', () => { + beforeEach(() => { + vi.resetModules() + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('exposes desktop-only MiniMax credential reads as unconfigured and rejects saves', async () => { + const { api } = await installApi('Linux') + + await expect(api.minimaxCredentials.getStatus()).resolves.toEqual({ configured: false }) + await expect(api.minimaxCredentials.saveCookie('_token=abc')).rejects.toThrow(/desktop app/i) + await expect(api.minimaxCredentials.clearCookie()).resolves.toEqual({ configured: false }) + }) }) describe('web UI preload API', () => { diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index ca0fcb2806d..8647c9b0d86 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -632,11 +632,13 @@ function createWebPreloadApi(): Partial { sessions: [], issues: [], scannedAt: new Date().toISOString() - }) + }), + onWindowFocused: () => () => {} }, preflight: createPreflightApi(), notifications: createNotificationsApi(), rateLimits: createRateLimitsApi(), + minimaxCredentials: createMiniMaxCredentialsApi(), codexAccounts: createAccountsApi(), claudeAccounts: createAccountsApi(), cli: createCliApi(), @@ -2452,6 +2454,8 @@ function createRateLimitsApi(): NonNullable['rateLimits']> { gemini: null, opencodeGo: null, kimi: null, + minimax: null, + minimaxCookieConfigured: false, claudeTarget: { runtime: 'host', wslDistro: null }, codexTarget: { runtime: 'host', wslDistro: null }, inactiveClaudeAccounts: [], @@ -2468,10 +2472,21 @@ function createRateLimitsApi(): NonNullable['rateLimits']> { setPollingInterval: () => Promise.resolve(), fetchInactiveClaudeAccounts: () => Promise.resolve(), fetchInactiveCodexAccounts: () => Promise.resolve(), + refreshMiniMax: () => Promise.resolve(empty), onUpdate: () => noopUnsubscribe } } +function createMiniMaxCredentialsApi(): NonNullable['minimaxCredentials']> { + const notConfigured = { configured: false } + const unsupportedError = new Error('MiniMax cookie storage is only available in the desktop app.') + return { + getStatus: () => Promise.resolve(notConfigured), + saveCookie: () => Promise.reject(unsupportedError), + clearCookie: () => Promise.resolve(notConfigured) + } +} + function createAccountsApi(): never { const empty = { accounts: [], @@ -2537,6 +2552,8 @@ function createPtyApi(): NonNullable['pty']> { resize: () => {}, reportGeometry: () => {}, signal: () => {}, + // Web panes clear the host buffer via the terminal.clearBuffer runtime RPC. + clearBuffer: () => {}, kill: () => Promise.resolve(), ackColdRestore: () => {}, ackData: () => {}, @@ -2547,6 +2564,7 @@ function createPtyApi(): NonNullable['pty']> { getCwd: () => Promise.resolve('~'), getSize: () => Promise.resolve(null), listSessions: () => Promise.resolve([]), + hasPty: () => Promise.resolve(null), getMainBufferSnapshot: () => Promise.resolve(null), getRendererDeliveryDebugSnapshot: () => Promise.resolve({ @@ -2845,6 +2863,12 @@ async function getRuntimeBackedStoredSettings(): Promise { if (typeof result.settings.compactWorktreeCards === 'boolean') { runtimeSettings.compactWorktreeCards = result.settings.compactWorktreeCards } + if (typeof result.settings.minimaxGroupId === 'string') { + runtimeSettings.minimaxGroupId = result.settings.minimaxGroupId + } + if (typeof result.settings.minimaxUsageModels === 'string') { + runtimeSettings.minimaxUsageModels = result.settings.minimaxUsageModels + } const next = mergeSettings(local, runtimeSettings) writeJson(SETTINGS_STORAGE_KEY, next) return next @@ -2868,6 +2892,12 @@ async function syncRuntimeBackedSettings( if (typeof updates.compactWorktreeCards === 'boolean') { runtimeUpdates.compactWorktreeCards = updates.compactWorktreeCards } + if (typeof updates.minimaxGroupId === 'string') { + runtimeUpdates.minimaxGroupId = updates.minimaxGroupId + } + if (typeof updates.minimaxUsageModels === 'string') { + runtimeUpdates.minimaxUsageModels = updates.minimaxUsageModels + } if (Object.keys(runtimeUpdates).length === 0) { return localNext } diff --git a/src/shared/agent-detection.test.ts b/src/shared/agent-detection.test.ts index b4f8495548a..a1bee91aef3 100644 --- a/src/shared/agent-detection.test.ts +++ b/src/shared/agent-detection.test.ts @@ -8,6 +8,7 @@ import { MAX_OSC_TITLE_CHARS } from './agent-detection' import { + hasCompatibleAgentTitleIdentity, normalizeCompatibleAgentStatusEntryForOwner, normalizeCompatibleAgentTitleForOwner, resolveCompatibleAgentTypeForOwner @@ -114,6 +115,14 @@ describe('Pi-compatible title detection', () => { expect(normalizeCompatibleAgentTitleForOwner('\u280b Pi', 'codex')).toBe('\u280b Pi') }) + it('identifies titles whose compatible identity can be re-owned', () => { + expect(hasCompatibleAgentTitleIdentity('Pi ready')).toBe(true) + expect(hasCompatibleAgentTitleIdentity('π - tmp')).toBe(true) + expect(hasCompatibleAgentTitleIdentity('\u280b OMP')).toBe(true) + expect(hasCompatibleAgentTitleIdentity('Fix pi bugs')).toBe(false) + expect(hasCompatibleAgentTitleIdentity('\u280b Codex')).toBe(false) + }) + it('normalizes Pi-compatible status identity and terminal title to the owner', () => { const status = normalizeCompatibleAgentStatusEntryForOwner( { diff --git a/src/shared/agent-hook-listener.test.ts b/src/shared/agent-hook-listener.test.ts index a28cfe0bc19..cedc0efda32 100644 --- a/src/shared/agent-hook-listener.test.ts +++ b/src/shared/agent-hook-listener.test.ts @@ -808,6 +808,51 @@ describe('shared agent-hook-listener', () => { expect(event).toBeNull() }) + it('keeps the cached prompt when a harness-injected turn fires UserPromptSubmit', () => { + normalizeHookPayload( + state, + 'claude', + { paneKey: PANE_KEY, payload: { hook_event_name: 'UserPromptSubmit', prompt: 'fix login' } }, + 'production' + ) + // Why: the harness injects background task notifications as user turns; + // they must not replace the user's real prompt in status labels. + const event = normalizeHookPayload( + state, + 'claude', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'UserPromptSubmit', + prompt: ' bzthj2b8r t1' + } + }, + 'production' + ) + expect(event).not.toBeNull() + expect(event!.payload.state).toBe('working') + expect(event!.payload.prompt).toBe('fix login') + expect(event!.hasExplicitPrompt).toBe(false) + }) + + it('resolves an empty prompt for a harness-injected turn with nothing cached', () => { + const event = normalizeHookPayload( + state, + 'claude', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'UserPromptSubmit', + prompt: 'background context' + } + }, + 'production' + ) + expect(event).not.toBeNull() + expect(event!.payload.prompt).toBe('') + expect(event!.hasExplicitPrompt).toBe(false) + }) + it('isolates caches between listener instances', () => { const a = createHookListenerState() const b = createHookListenerState() diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 23f1e9d4c0f..e73d2ae9dc9 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -35,6 +35,7 @@ import { type AgentProviderSessionMetadata } from './agent-session-resume' import { parsePaneKey } from './stable-pane-id' +import { isHarnessInjectedUserTurnText } from './harness-injected-user-turns' /** Maximum request body size accepted by the listener (1 MB). */ export const HOOK_REQUEST_MAX_BYTES = 1_000_000 @@ -392,6 +393,12 @@ function resolvePrompt( promptText: string, options?: { resetOnNewTurn?: boolean } ): string { + // Why: harness-injected turns (task notifications, system reminders) fire + // UserPromptSubmit but are not the user's ask — keep the cached real prompt + // instead of surfacing raw machinery tags in status labels. + if (isHarnessInjectedUserTurnText(promptText)) { + return state.lastPromptByPaneKey.get(paneKey) ?? '' + } if (options?.resetOnNewTurn) { state.lastPromptByPaneKey.delete(paneKey) } @@ -2085,6 +2092,11 @@ function hasExplicitUserPrompt( if (extractedPrompt.text.length === 0) { return false } + // Why: harness-injected machinery turns are not proof of a user submit — + // they must not count for prompt-sent telemetry or permission stickiness. + if (isHarnessInjectedUserTurnText(extractedPrompt.text)) { + return false + } // Why: bare `message` fields often contain permission or status copy. They // may update visible status prompts, but they are not proof of user submit. if (extractedPrompt.source === 'message') { diff --git a/src/shared/agent-title-owner.ts b/src/shared/agent-title-owner.ts index a72a46b1cb8..4e1dfda55af 100644 --- a/src/shared/agent-title-owner.ts +++ b/src/shared/agent-title-owner.ts @@ -43,6 +43,14 @@ function getProfileForTitle(title: string): TitleProfileMatch | null { return null } +/** + * Why: unknown-launch remote sessions need to wait for foreground ownership + * before publishing title frames whose identity can be re-owned. + */ +export function hasCompatibleAgentTitleIdentity(title: string): boolean { + return Boolean(getProfileForTitle(title)?.profile.titleIdentityGroup) +} + /** * Detects the agent status (working, permission, idle) from a terminal title, * accounting for legacy Pi titles. diff --git a/src/shared/ai-vault-types.ts b/src/shared/ai-vault-types.ts index 70d2054d623..58bf82597ab 100644 --- a/src/shared/ai-vault-types.ts +++ b/src/shared/ai-vault-types.ts @@ -5,6 +5,7 @@ import { type AgentStartupShell } from './tui-agent-startup-shell' import type { TuiAgent } from './types' +import type { ExecutionHostId, ExecutionHostScope } from './execution-host' export const AI_VAULT_AGENTS = [ 'claude', @@ -53,6 +54,8 @@ export type AiVaultSessionPreviewMessage = { export type AiVaultSession = { id: string + executionHostId: ExecutionHostId + executionHostPlatform?: NodeJS.Platform | null agent: AiVaultAgent sessionId: string title: string @@ -71,6 +74,7 @@ export type AiVaultSession = { } export type AiVaultScanIssue = { + executionHostId?: ExecutionHostId agent: AiVaultAgent path: string message: string @@ -82,6 +86,7 @@ export type AiVaultListArgs = { // Active workspace/project paths. The global result is recency-capped, so these // guarantee a scoped view still surfaces its own (possibly older) sessions. scopePaths?: readonly string[] + executionHostScope?: ExecutionHostScope } export type AiVaultListResult = { diff --git a/src/shared/browser-guest-events.ts b/src/shared/browser-guest-events.ts index be28f439af6..7466016b124 100644 --- a/src/shared/browser-guest-events.ts +++ b/src/shared/browser-guest-events.ts @@ -51,6 +51,7 @@ export type BrowserContextMenuRequestedEvent = { screenY: number pageUrl: string linkUrl: string | null + selectionText: string canGoBack: boolean canGoForward: boolean } diff --git a/src/shared/composer-branch-selection.ts b/src/shared/composer-branch-selection.ts index 4b8a0dca98d..0ee66f0a28e 100644 --- a/src/shared/composer-branch-selection.ts +++ b/src/shared/composer-branch-selection.ts @@ -1,3 +1,5 @@ +import type { GitPushTarget } from './types' + export type ComposerBranchSelection = { baseBranch: string branchNameOverride: string | undefined @@ -97,17 +99,54 @@ export function resolveComposerReuseOverride(args: { return args.branchNameOverride } +/** + * The branch-name override to apply when creating a worktree from the composer. + * + * With no resolver-provided override, branch mode (#6721) keeps a + * slash-containing typed name as the git branch — validated downstream by + * `git check-ref-format` — while the worktree folder name is sanitized + * separately; every other mode leaves the branch to be derived from the + * sanitized name. With an override, keep it verbatim when the workspace name is + * user-edited (`preserveWorkspaceNameEdits`) or still matches the auto-name. + */ export function resolveComposerBranchNameOverrideForCreate(args: { branchNameOverride: string | undefined branchAutoName: string workspaceName: string preserveWorkspaceNameEdits: boolean + createBranchFromWorkspaceName?: boolean }): string | undefined { if (!args.branchNameOverride) { - return undefined + return args.createBranchFromWorkspaceName && args.workspaceName.includes('/') + ? args.workspaceName + : undefined } if (args.preserveWorkspaceNameEdits) { return args.branchNameOverride } return args.workspaceName === args.branchAutoName ? args.branchNameOverride : undefined } + +export function resolveComposerManualBranchNameChange(args: { + value: string | undefined + pushTarget: GitPushTarget | undefined + forkPushWarning: string | null +}): { + branchNameOverride: string | undefined + pushTarget: GitPushTarget | undefined + forkPushWarning: string | null +} { + const branchNameOverride = args.value?.trim() || undefined + if (args.pushTarget && args.pushTarget.branchName !== branchNameOverride) { + return { + branchNameOverride, + pushTarget: undefined, + forkPushWarning: null + } + } + return { + branchNameOverride, + pushTarget: args.pushTarget, + forkPushWarning: args.forkPushWarning + } +} diff --git a/src/shared/constants.test.ts b/src/shared/constants.test.ts index fdead8aab38..76afe5caab4 100644 --- a/src/shared/constants.test.ts +++ b/src/shared/constants.test.ts @@ -51,6 +51,10 @@ describe('getDefaultSettings', () => { expect(getDefaultSettings('/tmp').confirmClosePinnedTab).toBe(true) }) + it('keeps rich Markdown spellcheck enabled by default', () => { + expect(getDefaultSettings('/tmp').richMarkdownSpellcheckEnabled).toBe(true) + }) + it('enables Source Control AI by default without pinning a separate agent', () => { expect(getDefaultSettings('/tmp').commitMessageAi).toMatchObject({ enabled: true, @@ -121,3 +125,15 @@ describe('getDefaultPrimarySelectionMiddleClickPaste', () => { expect(getDefaultPrimarySelectionMiddleClickPaste('win32')).toBe(false) }) }) + +describe('MiniMax defaults', () => { + it('starts MiniMax with empty group id and the canonical default model', () => { + const settings = getDefaultSettings('/tmp') + // Why: the fetcher reads these defaults on first launch. An empty + // group id is the signal that the fetcher must pull the value from + // the cookie itself, and "general" matches the model name the + // MiniMax usage endpoint exposes by default. + expect(settings.minimaxGroupId).toBe('') + expect(settings.minimaxUsageModels).toBe('general') + }) +}) diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 7c8e60b3eb4..cc9f8ce9603 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -203,6 +203,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings { editorAutoSave: false, editorAutoSaveDelayMs: DEFAULT_EDITOR_AUTO_SAVE_DELAY_MS, editorMinimapEnabled: false, + richMarkdownSpellcheckEnabled: true, markdownReviewToolsEnabled: true, primarySelectionMiddleClickPaste: getDefaultPrimarySelectionMiddleClickPaste(), primarySelectionMiddleClickPasteDefaultedForLinux: @@ -215,7 +216,8 @@ export function getDefaultSettings(homedir: string): GlobalSettings { terminalLineHeight: 1, terminalScrollSensitivity: 1.15, terminalFastScrollSensitivity: 5, - terminalTuiScrollSensitivity: 3, + terminalTuiScrollSensitivity: 1, + terminalTuiScrollSensitivityDefaultedToOne: true, // Why: "auto" should use WebGL when supported while keeping DOM fallback // for renderer failures and Linux software/unknown GPU renderers. terminalGpuAcceleration: 'auto', @@ -318,6 +320,8 @@ export function getDefaultSettings(homedir: string): GlobalSettings { defaultLinearTeamSelection: null, opencodeSessionCookie: '', opencodeWorkspaceId: '', + minimaxGroupId: '', + minimaxUsageModels: 'general', geminiCliOAuthEnabled: false, agentCmdOverrides: {}, agentDefaultArgs: { ...DEFAULT_TUI_AGENT_ARGS }, @@ -476,6 +480,7 @@ export function getDefaultUIState(): PersistedUIState { workspaceBoardColumnWidth: 308, syncTaskStatusFromWorkspaceBoard: false, _workspaceStatusesDefaultOrderMigrated: true, + _workspaceStatusesReorderedDefaultRepaired: true, _workspaceStatusesDefaultWorkflowMigrated: true, _workspaceStatusesDefaultVisualsMigrated: true, statusBarItems: [...DEFAULT_STATUS_BAR_ITEMS], diff --git a/src/shared/external-worktree-inbox.test.ts b/src/shared/external-worktree-inbox.test.ts index 029e04c3959..1e16c6d5134 100644 --- a/src/shared/external-worktree-inbox.test.ts +++ b/src/shared/external-worktree-inbox.test.ts @@ -1,13 +1,23 @@ import { describe, expect, it } from 'vitest' -import type { DetectedWorktree, DetectedWorktreeListResult, Repo } from './types' +import type { + DetectedWorktree, + DetectedWorktreeListResult, + GlobalSettings, + Repo, + Worktree +} from './types' import { getHiddenExternalWorktrees, getNewExternalWorktreeInboxWorktrees, mergeExternalWorktreeInboxPaths, shouldOfferNewExternalWorktreeInbox } from './external-worktree-inbox' -import { EXTERNAL_WORKTREE_VISIBILITY_ROLLOUT_AT } from './worktree-ownership' +import { + buildKnownOrcaWorkspaceLayouts, + EXTERNAL_WORKTREE_VISIBILITY_ROLLOUT_AT, + toDetectedWorktree +} from './worktree-ownership' const repo: Repo = { id: 'repo-1', @@ -54,6 +64,55 @@ function detectedResult(worktrees: DetectedWorktree[]): DetectedWorktreeListResu } } +function makeSettings(): GlobalSettings { + return { + workspaceDir: '/orca/workspaces', + nestWorkspaces: true, + workspaceDirHistory: [], + refreshLocalBaseRefOnWorktreeCreate: false, + localBaseRefSuggestionDismissed: false, + branchPrefix: 'none', + branchPrefixCustom: '', + enableGitHubAttribution: false, + theme: 'system', + appFontFamily: 'Geist', + editorAutoSave: false, + editorAutoSaveDelayMs: 1000, + editorMinimapEnabled: false, + markdownReviewToolsEnabled: true, + terminalFontSize: 14, + terminalFontFamily: 'monospace', + terminalFontWeight: 400, + terminalLineHeight: 1.2 + } as unknown as GlobalSettings +} + +function makeGitWorktree(overrides: Partial = {}): Worktree { + return { + id: `repo-1::${overrides.path ?? '/repo'}`, + repoId: repo.id, + path: '/repo', + displayName: 'repo', + branch: 'refs/heads/main', + head: 'abc123', + isBare: false, + isMainWorktree: true, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + workspaceStatus: 'todo', + ...overrides + } +} + describe('external worktree inbox', () => { it('merges inbox paths without duplicates when paths match after normalization', () => { expect(mergeExternalWorktreeInboxPaths(['/repo/one/'], ['/repo/one', '/repo/two'])).toEqual([ @@ -103,6 +162,25 @@ describe('external worktree inbox', () => { ).toEqual([hidden]) }) + it('offers metadata-free nested Orca workspace worktrees through the inbox', () => { + const settings = makeSettings() + const manual = toDetectedWorktree({ + repo, + settings, + worktree: makeGitWorktree({ + path: '/orca/workspaces/orca/manual-from-git', + displayName: 'manual-from-git', + branch: 'refs/heads/manual-from-git', + isMainWorktree: false + }), + knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo) + }) + + expect(manual.ownership).toBe('external') + expect(manual.visible).toBe(false) + expect(getNewExternalWorktreeInboxWorktrees(detectedResult([manual]), repo)).toEqual([manual]) + }) + it('suppresses non-authoritative detected results', () => { expect( getNewExternalWorktreeInboxWorktrees(detectedResult([detectedWorktree()]), { diff --git a/src/shared/github-pr-auto-merge-availability.test.ts b/src/shared/github-pr-auto-merge-availability.test.ts index 47989de6b6d..2e22a3bd3f2 100644 --- a/src/shared/github-pr-auto-merge-availability.test.ts +++ b/src/shared/github-pr-auto-merge-availability.test.ts @@ -48,7 +48,7 @@ describe('github PR auto-merge availability', () => { expect(canShowGitHubPRAutoMergeControl(pr({ autoMergeEnabled: true }))).toBe(true) }) - it('suppresses closed, draft, disallowed, and conflicting PRs', () => { + it('suppresses closed, draft, disallowed, conflicting, and unstable PRs', () => { expect( canShowGitHubPRAutoMergeControl(pr({ state: 'draft', mergeStateStatus: 'BLOCKED' })) ).toBe(false) @@ -63,5 +63,11 @@ describe('github PR auto-merge availability', () => { expect( canShowGitHubPRAutoMergeControl(pr({ mergeable: 'CONFLICTING', mergeStateStatus: 'DIRTY' })) ).toBe(false) + expect( + canEnableGitHubPRAutoMerge(pr({ mergeable: 'UNKNOWN', mergeStateStatus: 'UNSTABLE' })) + ).toBe(false) + expect( + canShowGitHubPRAutoMergeControl(pr({ mergeable: 'UNKNOWN', mergeStateStatus: 'UNSTABLE' })) + ).toBe(false) }) }) diff --git a/src/shared/github-pr-auto-merge-availability.ts b/src/shared/github-pr-auto-merge-availability.ts index 5c7417d622f..67b6e25ad72 100644 --- a/src/shared/github-pr-auto-merge-availability.ts +++ b/src/shared/github-pr-auto-merge-availability.ts @@ -18,6 +18,10 @@ function isConflicting(item: GitHubPRAutoMergeAvailabilityInput): boolean { return item.mergeable === 'CONFLICTING' || item.mergeStateStatus === 'DIRTY' } +function isUnstable(item: GitHubPRAutoMergeAvailabilityInput): boolean { + return item.mergeStateStatus === 'UNSTABLE' +} + function hasReviewRequirement(item: GitHubPRAutoMergeAvailabilityInput): boolean { return item.reviewDecision === 'REVIEW_REQUIRED' || item.reviewDecision === 'CHANGES_REQUESTED' } @@ -30,7 +34,9 @@ function canMergeImmediately(item: GitHubPRAutoMergeAvailabilityInput): boolean } function canRequestWhenReady(item: GitHubPRAutoMergeAvailabilityInput): boolean { - if (!isOpenPR(item) || isConflicting(item)) { + // Why: GitHub auto-merge waits on unmet requirements; UNSTABLE is rejected + // by the mutation rather than becoming a waitable auto-merge request. + if (!isOpenPR(item) || isConflicting(item) || isUnstable(item)) { return false } if (item.mergeQueueRequired === true) { diff --git a/src/shared/harness-injected-user-turns.test.ts b/src/shared/harness-injected-user-turns.test.ts new file mode 100644 index 00000000000..1b012b7d4ba --- /dev/null +++ b/src/shared/harness-injected-user-turns.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' + +import { isHarnessInjectedUserTurnText } from './harness-injected-user-turns' + +describe('isHarnessInjectedUserTurnText', () => { + it('matches harness machinery turns by prefix', () => { + expect( + isHarnessInjectedUserTurnText( + ' bzthj2b8r toolu_01abc' + ) + ).toBe(true) + expect(isHarnessInjectedUserTurnText('context')).toBe(true) + expect(isHarnessInjectedUserTurnText('/review')).toBe(true) + expect(isHarnessInjectedUserTurnText('ok')).toBe( + true + ) + expect(isHarnessInjectedUserTurnText('[Request interrupted by user]')).toBe(true) + expect( + isHarnessInjectedUserTurnText('This session is being continued from a previous conversation.') + ).toBe(true) + }) + + it('is case-insensitive and ignores surrounding whitespace', () => { + expect(isHarnessInjectedUserTurnText(' done')).toBe(true) + expect(isHarnessInjectedUserTurnText('\n hi')).toBe(true) + }) + + it('keeps real user prompts, including ones that mention the tags', () => { + expect(isHarnessInjectedUserTurnText('fix the login bug')).toBe(false) + expect(isHarnessInjectedUserTurnText('why does show in the sidebar?')).toBe( + false + ) + expect(isHarnessInjectedUserTurnText('')).toBe(false) + expect(isHarnessInjectedUserTurnText(' ')).toBe(false) + }) +}) diff --git a/src/shared/harness-injected-user-turns.ts b/src/shared/harness-injected-user-turns.ts new file mode 100644 index 00000000000..21d732a0cc7 --- /dev/null +++ b/src/shared/harness-injected-user-turns.ts @@ -0,0 +1,29 @@ +// Why: agent harnesses (Claude Code and its forks) inject machinery into the +// conversation as user-role turns — background task notifications, system +// reminders, slash-command envelopes, local-command output, interruption and +// compaction notices. These fire user-prompt hooks and land in transcripts, +// but they are not something the user typed, so prompt-derived UI must not +// surface them. +const HARNESS_INJECTED_TURN_PREFIXES = [ + '', + '', + '', + '', + '', + '', + '', + ' normalized.startsWith(prefix)) +} diff --git a/src/shared/hosted-review-github.ts b/src/shared/hosted-review-github.ts index 1e376003223..e7db9111f03 100644 --- a/src/shared/hosted-review-github.ts +++ b/src/shared/hosted-review-github.ts @@ -120,6 +120,9 @@ export function hostedReviewInfoFromGitHubPRInfo(pr: PRInfo): HostedReviewInfo { ...(pr.mergeQueueRequired !== undefined ? { mergeQueueRequired: pr.mergeQueueRequired } : {}), ...(pr.mergeStateStatus !== undefined ? { mergeStateStatus: pr.mergeStateStatus } : {}), ...(pr.headSha ? { headSha: pr.headSha } : {}), + ...(pr.confirmedContainedHeadOid + ? { confirmedContainedHeadOid: pr.confirmedContainedHeadOid } + : {}), ...(pr.conflictSummary ? { conflictSummary: pr.conflictSummary } : {}) } } diff --git a/src/shared/hosted-review.ts b/src/shared/hosted-review.ts index 9a403cb80f5..7bf3dd24181 100644 --- a/src/shared/hosted-review.ts +++ b/src/shared/hosted-review.ts @@ -25,6 +25,9 @@ export type HostedReviewInfo = { mergeQueueRequired?: boolean | null mergeStateStatus?: string | null headSha?: string + // Why: mirrors PRInfo.confirmedContainedHeadOid so merged-review staleness + // checks accept a worktree head confirmed to be part of the merged PR. + confirmedContainedHeadOid?: string /** Target branch name for review-created worktree compare-base repair. */ baseRefName?: string conflictSummary?: PRConflictSummary diff --git a/src/shared/nested-repo-telemetry.ts b/src/shared/nested-repo-telemetry.ts index 4f5aca068e8..44c536a7b3b 100644 --- a/src/shared/nested-repo-telemetry.ts +++ b/src/shared/nested-repo-telemetry.ts @@ -20,7 +20,12 @@ export const NESTED_REPO_SCAN_RESULTS = [ ] as const export type NestedRepoScanTelemetryResult = (typeof NESTED_REPO_SCAN_RESULTS)[number] -export const NESTED_REPO_IMPORT_ACTIONS = ['import_group', 'import_separate', 'back'] as const +export const NESTED_REPO_IMPORT_ACTIONS = [ + 'import_group', + 'import_separate', + 'open_as_folder', + 'back' +] as const export type NestedRepoImportTelemetryAction = (typeof NESTED_REPO_IMPORT_ACTIONS)[number] export const NESTED_REPO_IMPORT_OUTCOMES = ['success', 'partial_failure', 'failed'] as const diff --git a/src/shared/network/manual-address.test.ts b/src/shared/network/manual-address.test.ts index 3d1b48b43b2..fe6c1950f68 100644 --- a/src/shared/network/manual-address.test.ts +++ b/src/shared/network/manual-address.test.ts @@ -19,11 +19,18 @@ describe('parseManualNetworkAddress', () => { expect(parseManualNetworkAddress('255.255.255.255').ok).toBe(true) }) + it('accepts an IPv4 address with a port suffix', () => { + expect(parseManualNetworkAddress('192.168.1.24:8080')).toEqual({ + ok: true, + address: '192.168.1.24:8080' + }) + }) + it('rejects malformed IPv4', () => { for (const bad of ['', ' ', '1.2.3', '1.2.3.4.5', '256.0.0.1']) { expect(parseManualNetworkAddress(bad)).toEqual({ ok: false, - error: 'Enter an IPv4 address or Tailscale MagicDNS hostname' + error: 'Enter an IPv4 address or hostname, optionally with a :port suffix' }) } }) @@ -31,7 +38,7 @@ describe('parseManualNetworkAddress', () => { it('rejects leading zeros in octets', () => { expect(parseManualNetworkAddress('01.02.03.04')).toEqual({ ok: false, - error: 'Enter an IPv4 address or Tailscale MagicDNS hostname' + error: 'Enter an IPv4 address or hostname, optionally with a :port suffix' }) expect(parseManualNetworkAddress('0.0.0.0').ok).toBe(true) }) @@ -57,11 +64,107 @@ describe('parseManualNetworkAddress', () => { expect(parseManualNetworkAddress('MY-MAC.TS.NET').ok).toBe(true) }) - it('rejects non-Tailscale hostnames', () => { - for (const bad of ['my-mac', 'my-mac.ts.com', '-foo.ts.net', 'my-mac.com']) { + it('rejects a MagicDNS-shaped hostname with a malformed label', () => { + // Leading/trailing hyphens are invalid in any RFC 1123 label, + // regardless of the `.ts.net` suffix. + expect(parseManualNetworkAddress('-foo.ts.net').ok).toBe(false) + }) + }) + + describe('arbitrary hostnames (DDNS / self-hosted domains)', () => { + it('accepts a bare single-label hostname', () => { + expect(parseManualNetworkAddress('my-nas')).toEqual({ + ok: true, + address: 'my-nas' + }) + }) + + it('accepts a multi-label domain, e.g. a DDNS hostname', () => { + expect(parseManualNetworkAddress('home.example.com')).toEqual({ + ok: true, + address: 'home.example.com' + }) + expect(parseManualNetworkAddress('my-house.ddns.net').ok).toBe(true) + }) + + it('accepts a hostname with a port suffix', () => { + expect(parseManualNetworkAddress('home.example.com:8443')).toEqual({ + ok: true, + address: 'home.example.com:8443' + }) + }) + + it('is case-insensitive', () => { + expect(parseManualNetworkAddress('HOME.EXAMPLE.COM').ok).toBe(true) + }) + + it('rejects labels with leading or trailing hyphens', () => { + for (const bad of ['-foo.example.com', 'foo-.example.com', 'foo..com']) { expect(parseManualNetworkAddress(bad).ok).toBe(false) } }) + + it('rejects hostnames with characters outside the RFC 1123 grammar', () => { + for (const bad of ['my_host.example.com', 'my host.example.com', 'example.com/path']) { + expect(parseManualNetworkAddress(bad).ok).toBe(false) + } + }) + + it('rejects a host whose last label is numeric as an ambiguous IPv4', () => { + // The WHATWG URL parser downstream treats "ends in a number" as an IPv4 + // signal and would reinterpret or fail to resolve these (`123` -> + // `0.0.0.123`; `foo.123` -> no-op fallback), so accepting one here would + // validate an address the main process dials differently. + const bad = [ + '123', + '123:8080', + '1.2.3', + '1.2.3.4.5', + '256.0.0.1', + '999.999.999.999', + 'foo.123', + 'foo.123:8080', + 'foo.0x1' + ] + for (const value of bad) { + expect(parseManualNetworkAddress(value).ok).toBe(false) + } + }) + + it('still accepts a hostname whose last label merely contains digits', () => { + // Only a fully-numeric (or hex) final label is ambiguous; a label like + // `com` or `nas1` is a normal hostname. + expect(parseManualNetworkAddress('host2.example.com').ok).toBe(true) + expect(parseManualNetworkAddress('my-nas1').ok).toBe(true) + }) + }) + + describe('port suffix', () => { + it('accepts boundary port values', () => { + expect(parseManualNetworkAddress('example.com:1').ok).toBe(true) + expect(parseManualNetworkAddress('example.com:65535').ok).toBe(true) + }) + + it('rejects out-of-range ports', () => { + expect(parseManualNetworkAddress('example.com:0').ok).toBe(false) + expect(parseManualNetworkAddress('example.com:65536').ok).toBe(false) + expect(parseManualNetworkAddress('example.com:99999').ok).toBe(false) + }) + + it('rejects a non-numeric or empty port', () => { + expect(parseManualNetworkAddress('example.com:abc').ok).toBe(false) + expect(parseManualNetworkAddress('example.com:').ok).toBe(false) + }) + + it('rejects a leading-zero (non-canonical, unbounded-length) port', () => { + expect(parseManualNetworkAddress('example.com:0080').ok).toBe(false) + expect(parseManualNetworkAddress(`example.com:${'0'.repeat(1000)}8080`).ok).toBe(false) + }) + + it('rejects addresses with more than one colon (e.g. IPv6-shaped input)', () => { + expect(parseManualNetworkAddress('example.com:80:90').ok).toBe(false) + expect(parseManualNetworkAddress('::1').ok).toBe(false) + }) }) describe('length and whitespace', () => { @@ -77,5 +180,9 @@ describe('parseManualNetworkAddress', () => { address: '192.168.1.24' }) }) + + it('rejects whitespace inside the address', () => { + expect(parseManualNetworkAddress('my host.example.com').ok).toBe(false) + }) }) }) diff --git a/src/shared/network/manual-address.ts b/src/shared/network/manual-address.ts index 9f6a8421f78..81a8a28a26b 100644 --- a/src/shared/network/manual-address.ts +++ b/src/shared/network/manual-address.ts @@ -1,36 +1,83 @@ // Why: pure shared helper so the same validation runs in renderer // today and in any future CLI/main-process caller without duplicating -// the IPv4 + Tailscale MagicDNS grammar. +// the IPv4 + hostname + optional-port grammar. const IPV4_OCTET = '(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])' const IPV4 = `(?:${IPV4_OCTET}\\.){3}${IPV4_OCTET}` -// MagicDNS hostname: lowercase letters/digits/hyphens, dot-separated, ending in .ts.net. -// Labels may not start or end with a hyphen; max 63 chars per label (DNS limit). -const MAGICDNS_LABEL = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?' -const MAGICDNS = `(?:${MAGICDNS_LABEL}\\.)+ts\\.net` +const IPV4_REGEX = new RegExp(`^${IPV4}$`) + +// RFC 1123 hostname label: letters/digits/hyphens, 1-63 chars, may not +// start or end with a hyphen. This covers plain LAN hostnames, DDNS domains +// (e.g. `home.example.com`), and Tailscale MagicDNS names (`*.ts.net`) as a +// special case of the same grammar — no separate MagicDNS-only pattern needed. +const HOSTNAME_LABEL = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?' +const HOSTNAME = `(?:${HOSTNAME_LABEL}\\.)*${HOSTNAME_LABEL}` +const HOSTNAME_REGEX = new RegExp(`^${HOSTNAME}$`, 'i') const HOSTNAME_MAX_LENGTH = 253 -const ERROR_MESSAGE = 'Enter an IPv4 address or Tailscale MagicDNS hostname' +const MIN_PORT = 1 +const MAX_PORT = 65535 +const ERROR_MESSAGE = 'Enter an IPv4 address or hostname, optionally with a :port suffix' export type ParseManualAddressResult = { ok: true; address: string } | { ok: false; error: string } export function parseManualNetworkAddress(input: string): ParseManualAddressResult { const trimmed = input.trim() - if (trimmed === '' || trimmed.length > HOSTNAME_MAX_LENGTH) { + if (trimmed === '') { return { ok: false, error: ERROR_MESSAGE } } if (/\s/.test(trimmed)) { return { ok: false, error: ERROR_MESSAGE } } - const ipv4Regex = new RegExp(`^${IPV4}$`) - if (ipv4Regex.test(trimmed)) { - return { ok: true, address: trimmed } + const { host, port } = splitHostPort(trimmed) + if (host === '' || host.length > HOSTNAME_MAX_LENGTH) { + return { ok: false, error: ERROR_MESSAGE } + } + if (port !== null && !isValidPort(port)) { + return { ok: false, error: ERROR_MESSAGE } } - const magicRegex = new RegExp(`^(?:${MAGICDNS})$`, 'i') - if (magicRegex.test(trimmed)) { + if (IPV4_REGEX.test(host)) { + return { ok: true, address: trimmed } + } + // Why: reject a host whose last label is numeric — a bare `123`, a dotted + // `256.0.0.1`, or a hostname with a numeric final label (`foo.123`, `foo.0x1`). + // The WHATWG URL host parser that `resolvePairingEndpoint` feeds this into + // treats "ends in a number" as an IPv4 signal and tries to parse the whole + // host as IPv4. A real IPv4 was already accepted above, so anything reaching + // here would fail that parse and the main process would silently dial a + // different host — so treat these as mistyped IPs, not hostnames. + const lastLabel = host.split('.').at(-1) ?? '' + if (/^[0-9]+$/.test(lastLabel) || /^0x[0-9a-f]*$/i.test(lastLabel)) { + return { ok: false, error: ERROR_MESSAGE } + } + if (HOSTNAME_REGEX.test(host)) { return { ok: true, address: trimmed } } return { ok: false, error: ERROR_MESSAGE } } + +// Why: mirrors `parsePairingAddressOverride` in src/main/runtime/runtime-rpc.ts +// so the UI only accepts what the main process's pairing endpoint resolution +// can already handle. IPv6 stays out of scope (same as that function), so a +// second colon is left in `host` and fails the grammar checks below instead +// of being misparsed as a port. +function splitHostPort(value: string): { host: string; port: string | null } { + const firstColon = value.indexOf(':') + if (firstColon === -1 || value.includes(':', firstColon + 1)) { + return { host: value, port: null } + } + return { host: value.slice(0, firstColon), port: value.slice(firstColon + 1) } +} + +function isValidPort(port: string): boolean { + // Reject leading zeros: a canonical port has none, and allowing them lets an + // arbitrarily long zero-padded string (`00…08080`) slip past the range check + // and inflate the returned address past the hostname length cap. + if (!/^[1-9][0-9]*$/.test(port)) { + return false + } + const value = Number(port) + return value >= MIN_PORT && value <= MAX_PORT +} diff --git a/src/shared/process-table-snapshot.ts b/src/shared/process-table-snapshot.ts index 7234cd84333..358cb39a650 100644 --- a/src/shared/process-table-snapshot.ts +++ b/src/shared/process-table-snapshot.ts @@ -21,31 +21,35 @@ const PS_TIMEOUT_MS = 3000 // identically to a fresh fork. const DEFAULT_SNAPSHOT_TTL_MS = 500 -type Snapshot = { stdout: string; capturedAtMs: number } +type Snapshot = { value: T; capturedAtMs: number } -type ProcessTableSnapshotReaderDeps = { - runPs: () => Promise +type ProcessTableSnapshotReaderDeps = { + runPs: () => Promise now: () => number ttlMs?: number } /** * Build a process-table snapshot reader that deduplicates concurrent and - * near-simultaneous `ps` scans behind a single in-flight promise + short TTL. + * near-simultaneous scans behind a single in-flight promise + short TTL. * Exposed as a factory so tests can inject the scan and clock; production code - * uses the shared `getProcessTableSnapshot` instance below. + * uses the shared `getProcessTableSnapshot` instance below. Generic over the + * scan result so the Windows path can cache parsed rows while POSIX caches the + * raw `ps` stdout string (the default). */ -export function createProcessTableSnapshotReader(deps: ProcessTableSnapshotReaderDeps): { - getSnapshot: () => Promise +export function createProcessTableSnapshotReader( + deps: ProcessTableSnapshotReaderDeps +): { + getSnapshot: () => Promise reset: () => void } { const ttlMs = deps.ttlMs ?? DEFAULT_SNAPSHOT_TTL_MS - let cached: Snapshot | null = null - let inFlight: Promise | null = null + let cached: Snapshot | null = null + let inFlight: Promise | null = null - async function getSnapshot(): Promise { + async function getSnapshot(): Promise { if (cached && deps.now() - cached.capturedAtMs < ttlMs) { - return cached.stdout + return cached.value } if (inFlight) { return inFlight @@ -53,11 +57,11 @@ export function createProcessTableSnapshotReader(deps: ProcessTableSnapshotReade const promise = deps.runPs() inFlight = promise try { - const stdout = await promise - // Why: stamp capture time AFTER the scan returns so a slow `ps` can't + const value = await promise + // Why: stamp capture time AFTER the scan returns so a slow scan can't // hand back a snapshot that is already older than its TTL. - cached = { stdout, capturedAtMs: deps.now() } - return stdout + cached = { value, capturedAtMs: deps.now() } + return value } finally { // Clear in-flight on success and failure so a transient `ps` error // (timeout, nonzero exit) retries on the next call instead of being diff --git a/src/shared/rate-limit-types.test.ts b/src/shared/rate-limit-types.test.ts new file mode 100644 index 00000000000..dfc8c1dad31 --- /dev/null +++ b/src/shared/rate-limit-types.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import type { RateLimitState } from './rate-limit-types' + +describe('RateLimitState', () => { + it('documents the MiniMax surface used by the AccountsPane settings UI', () => { + // Why: the AccountsPane and the status bar both read these fields + // from RateLimitState. The shape must stay stable so that the + // visibility check (status-bar-provider-visibility) keeps working + // across refactors. + const state: RateLimitState = { + claude: null, + codex: null, + gemini: null, + opencodeGo: null, + kimi: null, + minimax: null, + minimaxCookieConfigured: false, + claudeTarget: { runtime: 'host', wslDistro: null }, + codexTarget: { runtime: 'host', wslDistro: null }, + inactiveClaudeAccounts: [], + inactiveCodexAccounts: [] + } + + expect(state.minimax).toBeNull() + expect(state.minimaxCookieConfigured).toBe(false) + }) +}) diff --git a/src/shared/rate-limit-types.ts b/src/shared/rate-limit-types.ts index 1f22a545302..6ed2c696f1b 100644 --- a/src/shared/rate-limit-types.ts +++ b/src/shared/rate-limit-types.ts @@ -44,11 +44,13 @@ export type UsageRateLimitMetadata = { } export type ProviderRateLimits = { - provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi' + provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi' | 'minimax' /** 5-hour session window, null if not available. */ session: RateLimitWindow | null /** 7-day weekly window, null if not available. */ weekly: RateLimitWindow | null + /** Claude Fable 7-day weekly window, null if not available. */ + fableWeekly?: RateLimitWindow | null /** 30-day monthly window (OpenCode Go only), null if not available. */ monthly?: RateLimitWindow | null /** Named per-model buckets (Gemini only). */ @@ -99,6 +101,14 @@ export type RateLimitState = { gemini: ProviderRateLimits | null opencodeGo: ProviderRateLimits | null kimi: ProviderRateLimits | null + minimax: ProviderRateLimits | null + /** + * True when a MiniMax session cookie is persisted on disk. The cookie lives + * outside GlobalSettings, so this flag is the durable signal that the + * status bar uses to keep the MiniMax provider visible across reloads and + * between snapshot refreshes. + */ + minimaxCookieConfigured: boolean claudeTarget: RateLimitRuntimeTarget codexTarget: RateLimitRuntimeTarget inactiveClaudeAccounts: InactiveAccountUsage[] diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index bcd916c01d5..136c58e58ac 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -135,6 +135,7 @@ export type RuntimeMobileSessionTerminalTab = { terminalTheme?: RuntimeMobileTerminalTheme agentStatus?: AgentStatusEntry | null launchAgent?: TuiAgent + startupCwd?: string parentLayout?: TerminalLayoutSnapshot /** Tab-level color/pin (per parentTabId), host-persisted for remote servers. */ color?: string | null @@ -319,6 +320,24 @@ export type RuntimeFileReadResult = { byteLength: number } +export type RuntimeTerminalPathOpenTarget = + | { + kind: 'worktree-file' + provider: 'local' | 'ssh' + relativePath: string + absolutePath: string + } + | { + kind: 'absolute-file' + provider: 'local' | 'ssh' + absolutePath: string + grantId: string + } + | { + kind: 'unsupported' + reason: string + } + /** Result of resolving a file path tapped in the mobile terminal against the * worktree root (+ optional cwd). relativePath is null when the path resolves * outside the worktree (not openable via the worktree-scoped file RPCs). */ @@ -330,6 +349,7 @@ export type RuntimeTerminalPathResolution = { absolutePath: string | null exists: boolean isDirectory: boolean + openTarget?: RuntimeTerminalPathOpenTarget } export type RuntimeFilePreviewResult = { @@ -463,6 +483,7 @@ type RuntimeTerminalCreateBaseRequestPayload = { afterTabId?: string targetGroupId?: string command?: string + cwd?: string env?: Record launchConfig?: SleepingAgentLaunchConfig launchToken?: string diff --git a/src/shared/secure-file-coarse-ctime.test.ts b/src/shared/secure-file-coarse-ctime.test.ts new file mode 100644 index 00000000000..64c33ba36f1 --- /dev/null +++ b/src/shared/secure-file-coarse-ctime.test.ts @@ -0,0 +1,123 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { chmodSyncMock, existsSyncMock, paths, statSyncMock, state } = vi.hoisted(() => ({ + chmodSyncMock: vi.fn(), + existsSyncMock: vi.fn(), + paths: { + dir: '/secure', + file: '/secure/secret.json' + }, + statSyncMock: vi.fn(), + state: { + dirMode: 0o755, + fileMode: 0o600 + } +})) + +vi.mock('fs', () => ({ + chmodSync: chmodSyncMock, + existsSync: existsSyncMock, + mkdirSync: vi.fn(), + renameSync: vi.fn(), + rmSync: vi.fn(), + statSync: statSyncMock, + writeFileSync: vi.fn() +})) + +vi.mock('child_process', () => ({ + execFile: vi.fn(), + execFileSync: vi.fn() +})) + +import { + __resetSecureFileHardenedPathsForTests, + __resetSecureFileWindowsUserSidForTests, + hardenExistingSecureFile +} from './secure-file' + +describe('secure-file coarse-ctime mode drift', () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + + beforeEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + state.dirMode = 0o755 + state.fileMode = 0o600 + existsSyncMock.mockReturnValue(true) + statSyncMock.mockImplementation((targetPath: string) => { + const path = String(targetPath) + if (path === paths.dir) { + return fakeStats(true, state.dirMode) + } + if (path === paths.file) { + return fakeStats(false, state.fileMode) + } + throw new Error(`unexpected stat path ${path}`) + }) + chmodSyncMock.mockImplementation((targetPath: string, mode: number) => { + const path = String(targetPath) + if (path === paths.dir) { + state.dirMode = mode & 0o777 + } else if (path === paths.file) { + state.fileMode = mode & 0o777 + } + }) + __resetSecureFileWindowsUserSidForTests() + __resetSecureFileHardenedPathsForTests() + }) + + afterEach(() => { + chmodSyncMock.mockReset() + existsSyncMock.mockReset() + statSyncMock.mockReset() + __resetSecureFileWindowsUserSidForTests() + __resetSecureFileHardenedPathsForTests() + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + }) + + it('re-hardens a directory when only POSIX mode changes under stable timestamps', () => { + hardenExistingSecureFile(paths.file) + expect(chmodSyncMock.mock.calls).toEqual([ + [paths.dir, 0o700], + [paths.file, 0o600] + ]) + + chmodSyncMock.mockClear() + // Coarse-ctime filesystems can report identical timestamps after chmod; only mode changes. + state.dirMode = 0o755 + + hardenExistingSecureFile(paths.file) + + expect(chmodSyncMock.mock.calls).toEqual([[paths.dir, 0o700]]) + }) + + it('re-hardens a file when only POSIX mode changes under stable timestamps', () => { + hardenExistingSecureFile(paths.file) + expect(chmodSyncMock.mock.calls).toEqual([ + [paths.dir, 0o700], + [paths.file, 0o600] + ]) + + chmodSyncMock.mockClear() + // Coarse-ctime filesystems can report identical timestamps after chmod; only mode changes. + state.fileMode = 0o644 + + hardenExistingSecureFile(paths.file) + + expect(chmodSyncMock.mock.calls).toEqual([[paths.file, 0o600]]) + }) +}) + +function fakeStats(isDirectory: boolean, mode: number) { + return { + birthtimeMs: 10, + ctimeMs: 20, + dev: 30, + ino: isDirectory ? 40 : 41, + isDirectory: () => isDirectory, + mode, + mtimeMs: 50, + size: isDirectory ? 0 : 2 + } +} diff --git a/src/shared/secure-file.ts b/src/shared/secure-file.ts index 146c62b09e0..1ed451b5633 100644 --- a/src/shared/secure-file.ts +++ b/src/shared/secure-file.ts @@ -18,6 +18,7 @@ type HardenedPathCacheEntry = { dev: number ino: number size: number + mode: number ctimeMs: number mtimeMs: number birthtimeMs: number @@ -130,6 +131,7 @@ export function hardenExistingSecureFile(targetPath: string): void { } } +/** Applies the platform-appropriate permission restriction to a path once, bypassing the cache. */ export function hardenSecurePath( targetPath: string, options: { @@ -146,6 +148,7 @@ export function hardenSecurePath( ) } +/** Applies hardening; async Windows calls only report that best-effort ACL work was accepted. */ function applySecurePathRestriction( targetPath: string, isDirectory: boolean, @@ -170,6 +173,7 @@ function applySecurePathRestriction( return true } +/** Caches the current metadata snapshot for a just-hardened path, or clears it if the path is gone. */ function rememberHardenedPath(targetPath: string, isDirectory: boolean): void { const entry = getHardenedPathCacheEntry(targetPath, isDirectory) if (entry) { @@ -179,6 +183,10 @@ function rememberHardenedPath(targetPath: string, isDirectory: boolean): void { } } +/** + * Snapshots a path's identity, mode, and timestamps so later drift is detectable. + * Mode is tracked directly so a chmod is caught even where coarse ctime granularity hides it. + */ function getHardenedPathCacheEntry( targetPath: string, isDirectory: boolean @@ -193,6 +201,7 @@ function getHardenedPathCacheEntry( dev: stats.dev, ino: stats.ino, size: stats.size, + mode: stats.mode & 0o777, ctimeMs: stats.ctimeMs, mtimeMs: stats.mtimeMs, birthtimeMs: stats.birthtimeMs @@ -202,6 +211,7 @@ function getHardenedPathCacheEntry( } } +/** True when two snapshots describe the same unchanged path (identity, mode, timestamps). */ function hardenedPathCacheEntriesMatch( a: HardenedPathCacheEntry, b: HardenedPathCacheEntry @@ -211,6 +221,7 @@ function hardenedPathCacheEntriesMatch( a.dev === b.dev && a.ino === b.ino && a.size === b.size && + a.mode === b.mode && a.ctimeMs === b.ctimeMs && a.mtimeMs === b.mtimeMs && a.birthtimeMs === b.birthtimeMs diff --git a/src/shared/shell-process-detection.ts b/src/shared/shell-process-detection.ts index 381b3bc42a4..0ae76e42673 100644 --- a/src/shared/shell-process-detection.ts +++ b/src/shared/shell-process-detection.ts @@ -21,3 +21,21 @@ export function isShellProcess(processName: string): boolean { SHELL_NAMES.has(basenameWithoutWindowsExtension) ) } + +// Why: a ConPTY-side buffer clear cannot reach PSReadLine's cached cursor +// row, so the first Enter after a terminal clear still repaints the prompt at +// the stale row. Only the PowerShell family binds Ctrl+L (form feed) to a +// full prompt repaint, so the post-clear nudge must stay scoped to it. +const POWERSHELL_NAMES = new Set(['powershell', 'pwsh']) + +export function isPowerShellProcess(processName: string | null | undefined): boolean { + if (!processName) { + return false + } + const normalized = processName + .trim() + .replace(/^["']|["']$/g, '') + .toLowerCase() + const basename = normalized.split(/[\\/]/).pop() ?? normalized + return POWERSHELL_NAMES.has(basename.replace(WINDOWS_PROCESS_EXTENSION_RE, '')) +} diff --git a/src/shared/ssh-types.ts b/src/shared/ssh-types.ts index d10d24ed395..a6b4fa55e8d 100644 --- a/src/shared/ssh-types.ts +++ b/src/shared/ssh-types.ts @@ -46,6 +46,9 @@ export type SshTarget = { /** Port forwards to auto-restore on connect/reconnect. Persisted so * forwards survive app restarts. */ portForwards?: SavedPortForward[] + /** Reuse a system OpenSSH connection across setup commands. Undefined means + * enabled; false is an explicit per-target compatibility opt-out. */ + systemSshConnectionReuse?: boolean } export type SavedPortForward = { diff --git a/src/shared/status-bar-defaults.ts b/src/shared/status-bar-defaults.ts index 7e673849589..5e9939c6461 100644 --- a/src/shared/status-bar-defaults.ts +++ b/src/shared/status-bar-defaults.ts @@ -6,6 +6,7 @@ export const DEFAULT_STATUS_BAR_ITEMS: StatusBarItem[] = [ 'gemini', 'opencode-go', 'kimi', + 'minimax', 'ssh', 'resource-usage', 'ports' diff --git a/src/shared/terminal-file-link-conformance.ts b/src/shared/terminal-file-link-conformance.ts new file mode 100644 index 00000000000..26ee9cd76b3 --- /dev/null +++ b/src/shared/terminal-file-link-conformance.ts @@ -0,0 +1,131 @@ +export type TerminalFileLinkTapExpectation = { + pathText: string + line: number | null + column: number | null +} + +export type TerminalFileLinkTapConformanceCase = { + name: string + lineText: string + tapText: string + expected: TerminalFileLinkTapExpectation | null +} + +export function columnForTerminalFileLinkTap(testCase: TerminalFileLinkTapConformanceCase): number { + const column = testCase.lineText.indexOf(testCase.tapText) + if (column < 0) { + throw new Error(`Tap text "${testCase.tapText}" not found in "${testCase.lineText}"`) + } + return column +} + +export const TERMINAL_FILE_LINK_TAP_CONFORMANCE_CASES: TerminalFileLinkTapConformanceCase[] = [ + { + name: 'absolute path', + lineText: 'created /tmp/out/report.html for you', + tapText: 'report', + expected: { pathText: '/tmp/out/report.html', line: null, column: null } + }, + { + name: 'relative path with line and column', + lineText: 'see src/components/Button.tsx:12:7 here', + tapText: 'Button', + expected: { pathText: 'src/components/Button.tsx', line: 12, column: 7 } + }, + { + name: 'tilde path', + lineText: 'wrote ~/Documents/notes.md', + tapText: 'notes', + expected: { pathText: '~/Documents/notes.md', line: null, column: null } + }, + { + name: 'directory segment containing a space', + lineText: '/Users/me/My Project/readme.md done', + tapText: 'readme', + expected: { pathText: '/Users/me/My Project/readme.md', line: null, column: null } + }, + { + name: 'spaced path with line and column', + lineText: 'wrote /tmp/orca report/result.json:12:3 for you', + tapText: 'result', + expected: { pathText: '/tmp/orca report/result.json', line: 12, column: 3 } + }, + { + name: 'spaced path with dotted directory', + lineText: 'wrote /tmp/v1.2 reports/result.json for you', + tapText: 'v1.2', + expected: { pathText: '/tmp/v1.2 reports/result.json', line: null, column: null } + }, + { + name: 'spaced path with dotted directory at line end', + lineText: 'wrote /tmp/v1.2 reports/result.json', + tapText: 'result', + expected: { pathText: '/tmp/v1.2 reports/result.json', line: null, column: null } + }, + { + name: 'spaced path at line end', + lineText: 'wrote /tmp/final report.json', + tapText: 'report', + expected: { pathText: '/tmp/final report.json', line: null, column: null } + }, + { + name: 'path followed by prose ending in a filename', + lineText: '/var/log/app.log failed to start app.py', + tapText: 'app.log', + expected: { pathText: '/var/log/app.log', line: null, column: null } + }, + { + name: 'trailing prose filename stays its own tap target', + lineText: '/var/log/app.log failed to start app.py', + tapText: 'app.py', + expected: { pathText: 'app.py', line: null, column: null } + }, + { + name: 'relative path followed by prose ending in a filename', + lineText: 'src/main.ts uses config.yaml', + tapText: 'main', + expected: { pathText: 'src/main.ts', line: null, column: null } + }, + { + name: 'spaced filename', + lineText: 'wrote /tmp/final report.json for you', + tapText: 'report', + expected: { pathText: '/tmp/final report.json', line: null, column: null } + }, + { + name: 'spaced path ending in a dotfile', + lineText: 'wrote /tmp/My Project/.env for you', + tapText: '.env', + expected: { pathText: '/tmp/My Project/.env', line: null, column: null } + }, + { + name: 'first of two spaced candidates', + lineText: 'see /tmp/a.txt and /tmp/b.txt done', + tapText: 'a.txt', + expected: { pathText: '/tmp/a.txt', line: null, column: null } + }, + { + name: 'second of two spaced candidates', + lineText: 'see /tmp/a.txt and /tmp/b.txt done', + tapText: 'b.txt', + expected: { pathText: '/tmp/b.txt', line: null, column: null } + }, + { + name: 'surrounding punctuation', + lineText: 'open (src/a.ts) now', + tapText: 'a.ts', + expected: { pathText: 'src/a.ts', line: null, column: null } + }, + { + name: 'bare filename with extension', + lineText: 'Here you go: README.md', + tapText: 'README', + expected: { pathText: 'README.md', line: null, column: null } + }, + { + name: 'plain prose word', + lineText: 'just some prose with no path here', + tapText: 'prose', + expected: null + } +] diff --git a/src/shared/terminal-startup-cwd.test.ts b/src/shared/terminal-startup-cwd.test.ts new file mode 100644 index 00000000000..f87ef7d51ce --- /dev/null +++ b/src/shared/terminal-startup-cwd.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest' +import { FLOATING_TERMINAL_WORKTREE_ID } from './constants' +import { + resolveTerminalStartupCwd, + resolveTerminalStartupCwdForWorkspace +} from './terminal-startup-cwd' +import { folderWorkspaceKey } from './workspace-scope' + +describe('resolveTerminalStartupCwd', () => { + it('accepts absolute child paths inside the worktree', () => { + expect(resolveTerminalStartupCwd('/repo/app', '/repo/app/packages/web')).toBe( + '/repo/app/packages/web' + ) + }) + + it('resolves relative paths against the worktree', () => { + expect(resolveTerminalStartupCwd('/repo/app', 'packages/web')).toBe('/repo/app/packages/web') + }) + + it('rejects sibling paths outside the worktree', () => { + expect(() => resolveTerminalStartupCwd('/repo/app', '/repo/app-other')).toThrow( + 'Terminal cwd must be inside the selected worktree.' + ) + }) + + it('rejects parent traversal outside the worktree', () => { + expect(() => resolveTerminalStartupCwd('/repo/app', '../other')).toThrow( + 'Terminal cwd must be inside the selected worktree.' + ) + }) + + it('trims whitespace-padded requested cwds before resolving', () => { + expect(resolveTerminalStartupCwd('/repo/app', ' packages/web ')).toBe('/repo/app/packages/web') + }) + + it('falls back to the default cwd when a symlink escapes the worktree', () => { + const canonicalize = (path: string): string | null => + path === '/repo/app/link' ? '/outside/target' : path + expect( + resolveTerminalStartupCwd('/repo/app', 'link', { canonicalizePath: canonicalize }) + ).toBeUndefined() + }) + + it('accepts cwds under a symlinked worktree root', () => { + const canonicalize = (path: string): string | null => path.replace(/^\/tmp\//, '/private/tmp/') + expect( + resolveTerminalStartupCwd('/tmp/repo', 'packages/web', { canonicalizePath: canonicalize }) + ).toBe('/tmp/repo/packages/web') + }) + + it('skips the symlink re-check when a path cannot be canonicalized', () => { + expect( + resolveTerminalStartupCwd('/repo/app', 'missing', { canonicalizePath: () => null }) + ).toBe('/repo/app/missing') + }) + + it('handles Windows path containment without case drift', () => { + expect(resolveTerminalStartupCwd('C:\\Repo\\App', 'packages\\web')).toBe( + 'C:/Repo/App/packages/web' + ) + expect(() => resolveTerminalStartupCwd('C:\\Repo\\App', 'C:\\Repo\\AppOther')).toThrow( + 'Terminal cwd must be inside the selected worktree.' + ) + }) + + it('validates renderer PTY cwd values against raw worktree IDs', () => { + expect( + resolveTerminalStartupCwdForWorkspace({ + workspaceId: 'repo-1::/repo/app', + requestedCwd: '/repo/app/packages/web' + }) + ).toBe('/repo/app/packages/web') + expect(() => + resolveTerminalStartupCwdForWorkspace({ + workspaceId: 'repo-1::/repo/app', + requestedCwd: '/repo/app-other' + }) + ).toThrow('Terminal cwd must be inside the selected worktree.') + }) + + it('passes floating terminal cwds through untouched', () => { + // Why: floating terminal cwds are validated against trusted-directory + // grants in main and have no worktree root to contain within. + expect( + resolveTerminalStartupCwdForWorkspace({ + workspaceId: FLOATING_TERMINAL_WORKTREE_ID, + requestedCwd: '/Volumes/work/notes' + }) + ).toBe('/Volumes/work/notes') + }) + + it('refuses the requested cwd when no workspace root is resolvable', () => { + expect( + resolveTerminalStartupCwdForWorkspace({ + workspaceId: undefined, + requestedCwd: '/anywhere' + }) + ).toBeUndefined() + expect( + resolveTerminalStartupCwdForWorkspace({ + workspaceId: 'opaque-worktree-id', + requestedCwd: '/anywhere' + }) + ).toBeUndefined() + }) + + it('validates renderer PTY cwd values against folder workspace keys', () => { + expect( + resolveTerminalStartupCwdForWorkspace({ + workspaceId: folderWorkspaceKey('folder-1'), + requestedCwd: 'packages/web', + resolveFolderWorkspacePath: (id) => (id === 'folder-1' ? '/repo/app' : null) + }) + ).toBe('/repo/app/packages/web') + expect(() => + resolveTerminalStartupCwdForWorkspace({ + workspaceId: folderWorkspaceKey('folder-1'), + requestedCwd: '../other', + resolveFolderWorkspacePath: (id) => (id === 'folder-1' ? '/repo/app' : null) + }) + ).toThrow('Terminal cwd must be inside the selected worktree.') + }) +}) diff --git a/src/shared/terminal-startup-cwd.ts b/src/shared/terminal-startup-cwd.ts new file mode 100644 index 00000000000..b71b737fa43 --- /dev/null +++ b/src/shared/terminal-startup-cwd.ts @@ -0,0 +1,88 @@ +import { FLOATING_TERMINAL_WORKTREE_ID } from './constants' +import { isPathInsideOrEqual, resolveRuntimePath } from './cross-platform-path' +import { parseWorkspaceKey } from './workspace-scope' +import { splitWorktreeIdForFilesystem } from './worktree-id' + +export type TerminalStartupCwdOptions = { + // Why: the string containment check can't see symlinks; only local callers + // can canonicalize — SSH worktree paths live on the remote host. + canonicalizePath?: (path: string) => string | null +} + +export function resolveTerminalStartupCwd( + worktreePath: string, + requestedCwd?: string | null, + options?: TerminalStartupCwdOptions +): string | undefined { + const trimmedCwd = requestedCwd?.trim() + if (!trimmedCwd) { + return undefined + } + const resolvedCwd = resolveRuntimePath(worktreePath, trimmedCwd) + if (!isPathInsideOrEqual(worktreePath, resolvedCwd)) { + // Why: remote/session clients can request terminal cwd; never let that + // become a shell outside the selected workspace. + throw new Error('Terminal cwd must be inside the selected worktree.') + } + const canonicalizePath = options?.canonicalizePath + if (canonicalizePath) { + const canonicalWorktreePath = canonicalizePath(worktreePath) + const canonicalCwd = canonicalizePath(resolvedCwd) + if ( + canonicalWorktreePath && + canonicalCwd && + !isPathInsideOrEqual(canonicalWorktreePath, canonicalCwd) + ) { + // Why: a symlink escaping the worktree can be legitimate (e.g. a pnpm + // store link), so fall back to the default cwd instead of failing the + // spawn — but never grant the requested out-of-worktree shell. + return undefined + } + } + return resolvedCwd +} + +export function resolveTerminalStartupCwdForWorkspace(args: { + workspaceId?: string + requestedCwd?: string | null + resolveFolderWorkspacePath?: (folderWorkspaceId: string) => string | null | undefined + canonicalizePath?: (path: string) => string | null +}): string | undefined { + if (!args.requestedCwd || args.requestedCwd.trim().length === 0) { + return undefined + } + if (args.workspaceId === FLOATING_TERMINAL_WORKTREE_ID) { + // Why: floating terminals have no worktree root to contain within; their + // cwd was already validated against the trusted-directory grants in + // resolveFloatingTerminalCwd. + return args.requestedCwd + } + const workspacePath = resolveTerminalWorkspacePath( + args.workspaceId, + args.resolveFolderWorkspacePath + ) + if (!workspacePath) { + // Why: without a resolvable workspace root we cannot enforce the + // in-worktree containment check, so refuse the requested cwd rather + // than trusting an unvalidated path. + return undefined + } + return resolveTerminalStartupCwd(workspacePath, args.requestedCwd, { + canonicalizePath: args.canonicalizePath + }) +} + +function resolveTerminalWorkspacePath( + workspaceId: string | undefined, + resolveFolderWorkspacePath: ((folderWorkspaceId: string) => string | null | undefined) | undefined +): string | null { + if (!workspaceId) { + return null + } + const scope = parseWorkspaceKey(workspaceId) + if (scope?.type === 'folder') { + return resolveFolderWorkspacePath?.(scope.folderWorkspaceId) ?? null + } + const worktreeId = scope?.type === 'worktree' ? scope.worktreeId : workspaceId + return splitWorktreeIdForFilesystem(worktreeId)?.worktreePath ?? null +} diff --git a/src/shared/terminal-stream-protocol.test.ts b/src/shared/terminal-stream-protocol.test.ts index 626973b1051..58a8a0458a5 100644 --- a/src/shared/terminal-stream-protocol.test.ts +++ b/src/shared/terminal-stream-protocol.test.ts @@ -68,6 +68,20 @@ describe('terminal-stream-protocol', () => { expect(resize && decodeTerminalStreamJson(resize.payload)).toEqual({ cols: 120, rows: 40 }) }) + it('round-trips terminal metadata frames', () => { + const metadata = decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Metadata, + streamId: 11, + seq: 4, + payload: encodeTerminalStreamJson({ cwd: '/repo/src' }) + }) + ) + + expect(metadata?.opcode).toBe(TerminalStreamOpcode.Metadata) + expect(metadata && decodeTerminalStreamJson(metadata.payload)).toEqual({ cwd: '/repo/src' }) + }) + it('round-trips multiplex subscribe, snapshot request, and unsubscribe frames', () => { const subscribe = decodeTerminalStreamFrame( encodeTerminalStreamFrame({ diff --git a/src/shared/terminal-stream-protocol.ts b/src/shared/terminal-stream-protocol.ts index 9a993bee99a..2f8f4e04247 100644 --- a/src/shared/terminal-stream-protocol.ts +++ b/src/shared/terminal-stream-protocol.ts @@ -13,7 +13,8 @@ export enum TerminalStreamOpcode { Resize = 8, Subscribe = 9, Unsubscribe = 10, - SnapshotRequest = 11 + SnapshotRequest = 11, + Metadata = 12 } export type TerminalStreamFrame = { @@ -92,6 +93,7 @@ function isTerminalStreamOpcode(value: number): value is TerminalStreamOpcode { value === TerminalStreamOpcode.Resize || value === TerminalStreamOpcode.Subscribe || value === TerminalStreamOpcode.Unsubscribe || - value === TerminalStreamOpcode.SnapshotRequest + value === TerminalStreamOpcode.SnapshotRequest || + value === TerminalStreamOpcode.Metadata ) } diff --git a/src/shared/terminal-title-agent-type.ts b/src/shared/terminal-title-agent-type.ts index 6e453e5cb75..3671ef92998 100644 --- a/src/shared/terminal-title-agent-type.ts +++ b/src/shared/terminal-title-agent-type.ts @@ -52,7 +52,7 @@ function isGenericClaudeStatusClaim(title: string, titleAgent: TuiAgent | null): ) } -function resolveTerminalTitleAgentType(title: string): TuiAgent | null { +export function resolveTerminalTitleAgentType(title: string): TuiAgent | null { const label = getAgentLabel(title) return label ? (TITLE_LABEL_TO_AGENT[label] ?? null) : null } diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-unicode-provider.ts b/src/shared/terminal-unicode-provider.ts similarity index 100% rename from src/renderer/src/lib/pane-manager/pane-terminal-unicode-provider.ts rename to src/shared/terminal-unicode-provider.ts diff --git a/src/shared/types.ts b/src/shared/types.ts index 1247d7dc449..55ed3007608 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -836,6 +836,9 @@ export type TerminalTab = { * PTY and tab icon stay stable even if the default shell setting changes * later. Older persisted tabs may omit this field. */ shellOverride?: string + /** Why: explorer-created terminals can start below the workspace root while + * still belonging to that workspace for tab/session ownership. */ + startupCwd?: string /** Why: the coding-harness agent Orca launched in this tab. Lets the tab bar * show the provider icon immediately, before the agent emits its first hook * event (a freshly-launched, idle agent reports no live status yet). Live @@ -918,6 +921,9 @@ export type BrowserWorkspace = { // partition, which keeps backward compat with workspaces persisted before // session profiles existed. sessionProfileId?: string | null + // Why: runtime-created tabs resolve profile partition in main. Persisting it + // keeps isolated storage stable when the renderer profile mirror is stale. + sessionPartition?: string | null activePageId?: string | null pageIds?: string[] // Why: the active page owns real browser chrome state now, but the top-level @@ -1127,6 +1133,10 @@ export type PRInfo = { // Keeping the head SHA in cached PR metadata lets the checks panel poll the // correct commit without re-querying GitHub or guessing from local branch refs. headSha?: string + // Why: a merged branch-matched PR stays visible when the worktree head is one + // of the PR's own commits (behind update-branch/web commits). Cache staleness + // checks must honor that confirmation without re-querying GitHub. + confirmedContainedHeadOid?: string /** Target branch name for PR-created worktree compare-base repair. */ baseRefName?: string prRepo?: GitHubRepositoryIdentity @@ -1186,6 +1196,10 @@ export type GitHubPRRefreshCandidate = GitHubPRRefreshAlias & { cachedMergeable?: PRMergeableState | null cachedMergeStateStatus?: string | null localGitOptions?: { wslDistro?: string } + // Why: merged branch-matched PRs are only visible for heads that belong to + // the PR; without the worktree head, a panel-supplied fallback number would + // keep a merged PR alive head-blind after the branch moves on. + currentHeadOid?: string | null } export type GitHubPRRefreshSkippedReason = @@ -2176,6 +2190,11 @@ export type ChangelogData = { releasesBehind: number | null } +export type UpdateCheckOptions = { + includePrerelease?: boolean + includePerfPrerelease?: boolean +} + export type UpdateStatus = | { state: 'idle' } | { state: 'checking'; userInitiated?: boolean } @@ -2469,6 +2488,8 @@ export type GlobalSettings = { editorAutoSave: boolean editorAutoSaveDelayMs: number editorMinimapEnabled: boolean + /** Persisted opt-out for browser spellcheck noise in rich Markdown editing surfaces. */ + richMarkdownSpellcheckEnabled?: boolean /** Whether local markdown review note controls and the review panel are shown. */ markdownReviewToolsEnabled: boolean /** Why: mirrors terminal selection-paste muscle memory without mutating the @@ -2488,6 +2509,8 @@ export type GlobalSettings = { terminalScrollSensitivity: number terminalFastScrollSensitivity: number terminalTuiScrollSensitivity: number + /** One-shot migration guard for moving inherited TUI wheel reports from 3 to 1. */ + terminalTuiScrollSensitivityDefaultedToOne?: boolean /** Terminal renderer policy. * - 'auto': try xterm WebGL and fall back to DOM when unsupported or risky. * - 'on': always try xterm WebGL. @@ -2745,6 +2768,10 @@ export type GlobalSettings = { /** Optional workspace ID override for OpenCode Go. When set, skips the * workspaces lookup and fetches usage directly for this workspace. */ opencodeWorkspaceId: string + /** Optional MiniMax group id. When empty, the usage fetcher extracts minimax_group_id_v2 from the cookie. */ + minimaxGroupId: string + /** Comma-separated MiniMax model names to show in the status bar usage window. */ + minimaxUsageModels: string /** Whether to extract OAuth credentials from the local Gemini CLI installation * for rate-limit fetching. Disabled by default for explicit opt-in. */ geminiCliOAuthEnabled: boolean @@ -3091,6 +3118,7 @@ export type StatusBarItem = | 'gemini' | 'opencode-go' | 'kimi' + | 'minimax' | 'ssh' | 'resource-usage' | 'ports' @@ -3191,9 +3219,11 @@ export type PersistedUIState = { * default workspace statuses in reverse workflow order. Once stamped, * user-authored status ordering is never inferred from IDs/labels again. */ _workspaceStatusesDefaultOrderMigrated?: boolean - /** One-shot migration flag for the default status workflow order/label: - * Done -> In review -> In progress -> Todo. Exact legacy default payloads - * migrate; customized statuses are preserved. */ + /** One-shot repair flag for the exact default payload that a short-lived + * build persisted in reverse workflow order. */ + _workspaceStatusesReorderedDefaultRepaired?: boolean + /** One-shot migration flag for default status workflow labels/visuals. + * Exact legacy default payloads migrate; customized statuses are preserved. */ _workspaceStatusesDefaultWorkflowMigrated?: boolean /** One-shot migration flag for the old default blue/violet/emerald status * visuals. Once stamped, valid user-authored colors/icons are preserved. */ @@ -3202,6 +3232,8 @@ export type PersistedUIState = { _portsStatusBarDefaultAdded?: boolean /** One-shot migration flag for adding the default-on Kimi status item. */ _kimiStatusBarDefaultAdded?: boolean + /** One-shot migration flag for adding the default-on MiniMax status item. */ + _minimaxStatusBarDefaultAdded?: boolean statusBarItems: StatusBarItem[] statusBarVisible: boolean dismissedUpdateVersion: string | null diff --git a/src/shared/window-shortcut-policy.ts b/src/shared/window-shortcut-policy.ts index e56c0f596e3..0099c7e88a0 100644 --- a/src/shared/window-shortcut-policy.ts +++ b/src/shared/window-shortcut-policy.ts @@ -133,6 +133,38 @@ function actionMatches( return keybindingMatchesAction(actionId, input, platform, keybindings, options) } +export function nativeZoomCommandMatchesKeybindings( + direction: 'in' | 'out', + platform: NodeJS.Platform, + keybindings?: KeybindingOverrides, + options: KeybindingMatchOptions = {} +): boolean { + const primary = + platform === 'darwin' ? { meta: true, control: false } : { meta: false, control: true } + const actionId = direction === 'in' ? 'zoom.in' : 'zoom.out' + const candidates = + direction === 'in' + ? [ + { key: '=', code: 'Equal', shift: false }, + { key: '+', code: 'Equal', shift: true }, + { key: 'Add', code: 'NumpadAdd', shift: false } + ] + : [ + { key: '-', code: 'Minus', shift: false }, + { key: 'Subtract', code: 'NumpadSubtract', shift: false } + ] + + return candidates.some((candidate) => + keybindingMatchesAction( + actionId, + { ...primary, alt: false, ...candidate }, + platform, + keybindings, + options + ) + ) +} + export function resolveWindowShortcutAction( input: WindowShortcutInput, platform: NodeJS.Platform, diff --git a/src/shared/workspace-session-schema.sleeping-agent.test.ts b/src/shared/workspace-session-schema.sleeping-agent.test.ts new file mode 100644 index 00000000000..9c65a4ddcd6 --- /dev/null +++ b/src/shared/workspace-session-schema.sleeping-agent.test.ts @@ -0,0 +1,431 @@ +import { describe, it, expect } from 'vitest' +import { parseWorkspaceSession } from './workspace-session-schema' + +describe('parseWorkspaceSession sleeping agents', () => { + it('preserves valid sleeping agent resume records', () => { + const result = parseWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + sleepingAgentSessionsByPaneKey: { + 'tab1:pane-1': { + paneKey: 'tab1:pane-1', + tabId: 'tab1', + worktreeId: 'wt', + agent: 'codex', + providerSession: { key: 'session_id', id: 'codex-session' }, + prompt: 'continue', + state: 'working', + capturedAt: 10, + updatedAt: 9, + terminalTitle: 'Codex', + lastAssistantMessage: 'done', + launchConfig: { + agentArgs: '', + agentEnv: {} + }, + origin: 'live' + } + } + }) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value.sleepingAgentSessionsByPaneKey?.['tab1:pane-1']?.agent).toBe('codex') + expect(result.value.sleepingAgentSessionsByPaneKey?.['tab1:pane-1']?.origin).toBe('live') + expect(result.value.sleepingAgentSessionsByPaneKey?.['tab1:pane-1']?.launchConfig).toEqual({ + agentArgs: '', + agentEnv: {} + }) + } + }) + + it('drops invalid sleeping agent launch config without dropping the record', () => { + const result = parseWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + sleepingAgentSessionsByPaneKey: { + 'tab1:pane-1': { + paneKey: 'tab1:pane-1', + tabId: 'tab1', + worktreeId: 'wt', + agent: 'codex', + providerSession: { key: 'session_id', id: 'codex-session' }, + prompt: 'continue', + state: 'working', + capturedAt: 10, + updatedAt: 9, + launchConfig: { + agentArgs: '--model high', + agentEnv: { 'BAD=KEY': 'value' } + } + } + } + }) + + expect(result.ok).toBe(true) + if (result.ok) { + const record = result.value.sleepingAgentSessionsByPaneKey?.['tab1:pane-1'] + expect(record?.agent).toBe('codex') + expect(record?.launchConfig).toBeUndefined() + } + }) + + it('drops launch config with prototype-polluting env keys without dropping siblings', () => { + const sessions = JSON.parse(`{ + "__proto__": { + "paneKey": "__proto__", + "worktreeId": "wt", + "agent": "codex", + "providerSession": { "key": "session_id", "id": "bad-session" }, + "prompt": "bad", + "state": "working", + "capturedAt": 10, + "updatedAt": 9 + }, + "tab1:pane-1": { + "paneKey": "tab1:pane-1", + "tabId": "tab1", + "worktreeId": "wt", + "agent": "codex", + "providerSession": { "key": "session_id", "id": "codex-session" }, + "prompt": "continue", + "state": "working", + "capturedAt": 10, + "updatedAt": 9, + "launchConfig": { + "agentArgs": "", + "agentEnv": { "__proto__": "polluted" } + } + } + }`) + const result = parseWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + sleepingAgentSessionsByPaneKey: sessions + }) + + expect(result.ok).toBe(true) + if (result.ok) { + expect( + Object.prototype.hasOwnProperty.call( + result.value.sleepingAgentSessionsByPaneKey ?? {}, + '__proto__' + ) + ).toBe(false) + const record = result.value.sleepingAgentSessionsByPaneKey?.['tab1:pane-1'] + expect(record?.agent).toBe('codex') + expect(record?.launchConfig).toBeUndefined() + expect(({} as Record).polluted).toBeUndefined() + } + }) + + it('preserves sleeping agent launch env values with whitespace characters', () => { + const result = parseWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + sleepingAgentSessionsByPaneKey: { + 'tab1:pane-1': { + paneKey: 'tab1:pane-1', + tabId: 'tab1', + worktreeId: 'wt', + agent: 'codex', + providerSession: { key: 'session_id', id: 'codex-session' }, + prompt: 'continue', + state: 'working', + capturedAt: 10, + updatedAt: 9, + launchConfig: { + agentArgs: '', + agentEnv: { MULTILINE: 'line1\nline2\tok' } + } + } + } + }) + + expect(result.ok).toBe(true) + if (result.ok) { + expect( + result.value.sleepingAgentSessionsByPaneKey?.['tab1:pane-1']?.launchConfig?.agentEnv + ).toEqual({ MULTILINE: 'line1\nline2\tok' }) + } + }) + + it('drops sleeping agent launch config with NUL env values without dropping the record', () => { + const result = parseWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + sleepingAgentSessionsByPaneKey: { + 'tab1:pane-1': { + paneKey: 'tab1:pane-1', + tabId: 'tab1', + worktreeId: 'wt', + agent: 'codex', + providerSession: { key: 'session_id', id: 'codex-session' }, + prompt: 'continue', + state: 'working', + capturedAt: 10, + updatedAt: 9, + launchConfig: { + agentArgs: '', + agentEnv: { BAD_VALUE: 'ok\0bad' } + } + } + } + }) + + expect(result.ok).toBe(true) + if (result.ok) { + const record = result.value.sleepingAgentSessionsByPaneKey?.['tab1:pane-1'] + expect(record?.agent).toBe('codex') + expect(record?.launchConfig).toBeUndefined() + } + }) + + it('preserves sleeping agent record origin across hydration', () => { + const result = parseWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + sleepingAgentSessionsByPaneKey: { + 'tab1:pane-1': { + paneKey: 'tab1:pane-1', + tabId: 'tab1', + worktreeId: 'wt', + agent: 'devin', + providerSession: { key: 'session_id', id: 'devin-session' }, + prompt: 'continue', + state: 'working', + capturedAt: 10, + updatedAt: 9, + origin: 'quit' + } + } + }) + + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value.sleepingAgentSessionsByPaneKey?.['tab1:pane-1']?.origin).toBe('quit') + } + }) + + it('preserves interrupted sleeping agent records across hydration', () => { + const result = parseWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + sleepingAgentSessionsByPaneKey: { + 'tab1:pane-1': { + paneKey: 'tab1:pane-1', + tabId: 'tab1', + worktreeId: 'wt', + agent: 'codex', + providerSession: { key: 'session_id', id: 'codex-session' }, + prompt: 'continue', + state: 'done', + capturedAt: 10, + updatedAt: 9, + interrupted: true, + origin: 'worktree-sleep' + } + } + }) + + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value.sleepingAgentSessionsByPaneKey?.['tab1:pane-1']?.interrupted).toBe(true) + } + }) + + it('preserves legacy live sleeping agent origins across hydration', () => { + const result = parseWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + sleepingAgentSessionsByPaneKey: { + 'tab1:pane-1': { + paneKey: 'tab1:pane-1', + tabId: 'tab1', + worktreeId: 'wt', + agent: 'codex', + providerSession: { key: 'session_id', id: 'codex-session' }, + prompt: 'continue', + state: 'working', + capturedAt: 10, + updatedAt: 9, + origin: 'live' + } + } + }) + + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value.sleepingAgentSessionsByPaneKey?.['tab1:pane-1']?.origin).toBe('live') + } + }) + + it('drops malformed sleeping agent resume records without failing the whole session', () => { + const result = parseWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + sleepingAgentSessionsByPaneKey: { + 'tab1:pane-1': { + paneKey: 'tab1:pane-1', + worktreeId: 'wt', + agent: 'pi', + providerSession: { key: 'session_id', id: 'pi-session' }, + prompt: 'continue', + state: 'working', + capturedAt: 10, + updatedAt: 9 + } + } + }) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value.sleepingAgentSessionsByPaneKey).toBeUndefined() + } + }) + + it('preserves valid sleeping agent resume records when sibling records are malformed', () => { + const result = parseWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + sleepingAgentSessionsByPaneKey: { + 'tab1:pane-1': { + paneKey: 'tab1:pane-1', + tabId: 'tab1', + worktreeId: 'wt', + agent: 'codex', + providerSession: { key: 'session_id', id: 'codex-session' }, + prompt: 'continue', + state: 'working', + capturedAt: 10, + updatedAt: 9 + }, + 'tab2:pane-1': { + paneKey: 'tab2:pane-1', + worktreeId: 'wt', + agent: 'not-real', + providerSession: { key: 'session_id', id: 'bad-session' }, + prompt: 'ignore me', + state: 'working', + capturedAt: 10, + updatedAt: 9 + } + } + }) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value.sleepingAgentSessionsByPaneKey?.['tab1:pane-1']?.agent).toBe('codex') + expect(result.value.sleepingAgentSessionsByPaneKey?.['tab2:pane-1']).toBeUndefined() + } + }) + + it('drops sleeping agent records with unsafe provider session ids without dropping valid siblings', () => { + const result = parseWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + sleepingAgentSessionsByPaneKey: { + 'tab1:pane-1': { + paneKey: 'tab1:pane-1', + tabId: 'tab1', + worktreeId: 'wt', + agent: 'codex', + providerSession: { key: 'session_id', id: 'codex-session' }, + prompt: 'continue', + state: 'working', + capturedAt: 10, + updatedAt: 9 + }, + 'tab2:pane-1': { + paneKey: 'tab2:pane-1', + tabId: 'tab2', + worktreeId: 'wt', + agent: 'codex', + providerSession: { key: 'session_id', id: '--last' }, + prompt: 'ignore me', + state: 'working', + capturedAt: 10, + updatedAt: 9 + } + } + }) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value.sleepingAgentSessionsByPaneKey?.['tab1:pane-1']?.providerSession.id).toBe( + 'codex-session' + ) + expect(result.value.sleepingAgentSessionsByPaneKey?.['tab2:pane-1']).toBeUndefined() + } + }) + + it('drops sleeping agent records whose embedded pane key differs from the map key', () => { + const result = parseWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + sleepingAgentSessionsByPaneKey: { + 'tab1:pane-1': { + paneKey: 'tab1:pane-1', + tabId: 'tab1', + worktreeId: 'wt', + agent: 'codex', + providerSession: { key: 'session_id', id: 'codex-session' }, + prompt: 'continue', + state: 'working', + capturedAt: 10, + updatedAt: 9 + }, + 'tab2:pane-1': { + paneKey: 'other-tab:pane-1', + tabId: 'tab2', + worktreeId: 'wt', + agent: 'codex', + providerSession: { key: 'session_id', id: 'mismatched-session' }, + prompt: 'ignore me', + state: 'working', + capturedAt: 10, + updatedAt: 9 + } + } + }) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value.sleepingAgentSessionsByPaneKey?.['tab1:pane-1']?.providerSession.id).toBe( + 'codex-session' + ) + expect(result.value.sleepingAgentSessionsByPaneKey?.['tab2:pane-1']).toBeUndefined() + } + }) +}) diff --git a/src/shared/workspace-session-schema.test.ts b/src/shared/workspace-session-schema.test.ts index b5976996062..97e3e68bda6 100644 --- a/src/shared/workspace-session-schema.test.ts +++ b/src/shared/workspace-session-schema.test.ts @@ -51,6 +51,44 @@ describe('parseWorkspaceSession', () => { expect(result.ok).toBe(true) }) + it('preserves an isolated browser tab session partition across hydration', () => { + // Regression for #6923: the resolved partition must survive persist→load, + // otherwise a restored isolated tab falls back to the shared default + // partition when the renderer profile mirror is stale at startup. + const result = parseWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + browserTabsByWorktree: { + wt: [ + { + id: 'browser-1', + worktreeId: 'wt', + sessionProfileId: 'iso-profile', + sessionPartition: 'persist:orca-browser-session-iso-profile', + url: 'https://example.com', + title: 'Example', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 1 + } + ] + } + }) + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + expect(result.value.browserTabsByWorktree?.wt?.[0]?.sessionPartition).toBe( + 'persist:orca-browser-session-iso-profile' + ) + }) + it('preserves a valid launchAgent on a terminal tab', () => { const result = parseWorkspaceSession({ activeRepoId: null, @@ -107,433 +145,6 @@ describe('parseWorkspaceSession', () => { } }) - it('preserves valid sleeping agent resume records', () => { - const result = parseWorkspaceSession({ - activeRepoId: null, - activeWorktreeId: null, - activeTabId: null, - tabsByWorktree: {}, - terminalLayoutsByTabId: {}, - sleepingAgentSessionsByPaneKey: { - 'tab1:pane-1': { - paneKey: 'tab1:pane-1', - tabId: 'tab1', - worktreeId: 'wt', - agent: 'codex', - providerSession: { key: 'session_id', id: 'codex-session' }, - prompt: 'continue', - state: 'working', - capturedAt: 10, - updatedAt: 9, - terminalTitle: 'Codex', - lastAssistantMessage: 'done', - launchConfig: { - agentArgs: '', - agentEnv: {} - }, - origin: 'live' - } - } - }) - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.value.sleepingAgentSessionsByPaneKey?.['tab1:pane-1']?.agent).toBe('codex') - expect(result.value.sleepingAgentSessionsByPaneKey?.['tab1:pane-1']?.origin).toBe('live') - expect(result.value.sleepingAgentSessionsByPaneKey?.['tab1:pane-1']?.launchConfig).toEqual({ - agentArgs: '', - agentEnv: {} - }) - } - }) - - it('drops invalid sleeping agent launch config without dropping the record', () => { - const result = parseWorkspaceSession({ - activeRepoId: null, - activeWorktreeId: null, - activeTabId: null, - tabsByWorktree: {}, - terminalLayoutsByTabId: {}, - sleepingAgentSessionsByPaneKey: { - 'tab1:pane-1': { - paneKey: 'tab1:pane-1', - tabId: 'tab1', - worktreeId: 'wt', - agent: 'codex', - providerSession: { key: 'session_id', id: 'codex-session' }, - prompt: 'continue', - state: 'working', - capturedAt: 10, - updatedAt: 9, - launchConfig: { - agentArgs: '--model high', - agentEnv: { 'BAD=KEY': 'value' } - } - } - } - }) - - expect(result.ok).toBe(true) - if (result.ok) { - const record = result.value.sleepingAgentSessionsByPaneKey?.['tab1:pane-1'] - expect(record?.agent).toBe('codex') - expect(record?.launchConfig).toBeUndefined() - } - }) - - it('drops launch config with prototype-polluting env keys without dropping siblings', () => { - const sessions = JSON.parse(`{ - "__proto__": { - "paneKey": "__proto__", - "worktreeId": "wt", - "agent": "codex", - "providerSession": { "key": "session_id", "id": "bad-session" }, - "prompt": "bad", - "state": "working", - "capturedAt": 10, - "updatedAt": 9 - }, - "tab1:pane-1": { - "paneKey": "tab1:pane-1", - "tabId": "tab1", - "worktreeId": "wt", - "agent": "codex", - "providerSession": { "key": "session_id", "id": "codex-session" }, - "prompt": "continue", - "state": "working", - "capturedAt": 10, - "updatedAt": 9, - "launchConfig": { - "agentArgs": "", - "agentEnv": { "__proto__": "polluted" } - } - } - }`) - const result = parseWorkspaceSession({ - activeRepoId: null, - activeWorktreeId: null, - activeTabId: null, - tabsByWorktree: {}, - terminalLayoutsByTabId: {}, - sleepingAgentSessionsByPaneKey: sessions - }) - - expect(result.ok).toBe(true) - if (result.ok) { - expect( - Object.prototype.hasOwnProperty.call( - result.value.sleepingAgentSessionsByPaneKey ?? {}, - '__proto__' - ) - ).toBe(false) - const record = result.value.sleepingAgentSessionsByPaneKey?.['tab1:pane-1'] - expect(record?.agent).toBe('codex') - expect(record?.launchConfig).toBeUndefined() - expect(({} as Record).polluted).toBeUndefined() - } - }) - - it('preserves sleeping agent launch env values with whitespace characters', () => { - const result = parseWorkspaceSession({ - activeRepoId: null, - activeWorktreeId: null, - activeTabId: null, - tabsByWorktree: {}, - terminalLayoutsByTabId: {}, - sleepingAgentSessionsByPaneKey: { - 'tab1:pane-1': { - paneKey: 'tab1:pane-1', - tabId: 'tab1', - worktreeId: 'wt', - agent: 'codex', - providerSession: { key: 'session_id', id: 'codex-session' }, - prompt: 'continue', - state: 'working', - capturedAt: 10, - updatedAt: 9, - launchConfig: { - agentArgs: '', - agentEnv: { MULTILINE: 'line1\nline2\tok' } - } - } - } - }) - - expect(result.ok).toBe(true) - if (result.ok) { - expect( - result.value.sleepingAgentSessionsByPaneKey?.['tab1:pane-1']?.launchConfig?.agentEnv - ).toEqual({ MULTILINE: 'line1\nline2\tok' }) - } - }) - - it('drops sleeping agent launch config with NUL env values without dropping the record', () => { - const result = parseWorkspaceSession({ - activeRepoId: null, - activeWorktreeId: null, - activeTabId: null, - tabsByWorktree: {}, - terminalLayoutsByTabId: {}, - sleepingAgentSessionsByPaneKey: { - 'tab1:pane-1': { - paneKey: 'tab1:pane-1', - tabId: 'tab1', - worktreeId: 'wt', - agent: 'codex', - providerSession: { key: 'session_id', id: 'codex-session' }, - prompt: 'continue', - state: 'working', - capturedAt: 10, - updatedAt: 9, - launchConfig: { - agentArgs: '', - agentEnv: { BAD_VALUE: 'ok\0bad' } - } - } - } - }) - - expect(result.ok).toBe(true) - if (result.ok) { - const record = result.value.sleepingAgentSessionsByPaneKey?.['tab1:pane-1'] - expect(record?.agent).toBe('codex') - expect(record?.launchConfig).toBeUndefined() - } - }) - - it('preserves sleeping agent record origin across hydration', () => { - const result = parseWorkspaceSession({ - activeRepoId: null, - activeWorktreeId: null, - activeTabId: null, - tabsByWorktree: {}, - terminalLayoutsByTabId: {}, - sleepingAgentSessionsByPaneKey: { - 'tab1:pane-1': { - paneKey: 'tab1:pane-1', - tabId: 'tab1', - worktreeId: 'wt', - agent: 'devin', - providerSession: { key: 'session_id', id: 'devin-session' }, - prompt: 'continue', - state: 'working', - capturedAt: 10, - updatedAt: 9, - origin: 'quit' - } - } - }) - - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.value.sleepingAgentSessionsByPaneKey?.['tab1:pane-1']?.origin).toBe('quit') - } - }) - - it('preserves interrupted sleeping agent records across hydration', () => { - const result = parseWorkspaceSession({ - activeRepoId: null, - activeWorktreeId: null, - activeTabId: null, - tabsByWorktree: {}, - terminalLayoutsByTabId: {}, - sleepingAgentSessionsByPaneKey: { - 'tab1:pane-1': { - paneKey: 'tab1:pane-1', - tabId: 'tab1', - worktreeId: 'wt', - agent: 'codex', - providerSession: { key: 'session_id', id: 'codex-session' }, - prompt: 'continue', - state: 'done', - capturedAt: 10, - updatedAt: 9, - interrupted: true, - origin: 'worktree-sleep' - } - } - }) - - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.value.sleepingAgentSessionsByPaneKey?.['tab1:pane-1']?.interrupted).toBe(true) - } - }) - - it('preserves legacy live sleeping agent origins across hydration', () => { - const result = parseWorkspaceSession({ - activeRepoId: null, - activeWorktreeId: null, - activeTabId: null, - tabsByWorktree: {}, - terminalLayoutsByTabId: {}, - sleepingAgentSessionsByPaneKey: { - 'tab1:pane-1': { - paneKey: 'tab1:pane-1', - tabId: 'tab1', - worktreeId: 'wt', - agent: 'codex', - providerSession: { key: 'session_id', id: 'codex-session' }, - prompt: 'continue', - state: 'working', - capturedAt: 10, - updatedAt: 9, - origin: 'live' - } - } - }) - - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.value.sleepingAgentSessionsByPaneKey?.['tab1:pane-1']?.origin).toBe('live') - } - }) - - it('drops malformed sleeping agent resume records without failing the whole session', () => { - const result = parseWorkspaceSession({ - activeRepoId: null, - activeWorktreeId: null, - activeTabId: null, - tabsByWorktree: {}, - terminalLayoutsByTabId: {}, - sleepingAgentSessionsByPaneKey: { - 'tab1:pane-1': { - paneKey: 'tab1:pane-1', - worktreeId: 'wt', - agent: 'pi', - providerSession: { key: 'session_id', id: 'pi-session' }, - prompt: 'continue', - state: 'working', - capturedAt: 10, - updatedAt: 9 - } - } - }) - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.value.sleepingAgentSessionsByPaneKey).toBeUndefined() - } - }) - - it('preserves valid sleeping agent resume records when sibling records are malformed', () => { - const result = parseWorkspaceSession({ - activeRepoId: null, - activeWorktreeId: null, - activeTabId: null, - tabsByWorktree: {}, - terminalLayoutsByTabId: {}, - sleepingAgentSessionsByPaneKey: { - 'tab1:pane-1': { - paneKey: 'tab1:pane-1', - tabId: 'tab1', - worktreeId: 'wt', - agent: 'codex', - providerSession: { key: 'session_id', id: 'codex-session' }, - prompt: 'continue', - state: 'working', - capturedAt: 10, - updatedAt: 9 - }, - 'tab2:pane-1': { - paneKey: 'tab2:pane-1', - worktreeId: 'wt', - agent: 'not-real', - providerSession: { key: 'session_id', id: 'bad-session' }, - prompt: 'ignore me', - state: 'working', - capturedAt: 10, - updatedAt: 9 - } - } - }) - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.value.sleepingAgentSessionsByPaneKey?.['tab1:pane-1']?.agent).toBe('codex') - expect(result.value.sleepingAgentSessionsByPaneKey?.['tab2:pane-1']).toBeUndefined() - } - }) - - it('drops sleeping agent records with unsafe provider session ids without dropping valid siblings', () => { - const result = parseWorkspaceSession({ - activeRepoId: null, - activeWorktreeId: null, - activeTabId: null, - tabsByWorktree: {}, - terminalLayoutsByTabId: {}, - sleepingAgentSessionsByPaneKey: { - 'tab1:pane-1': { - paneKey: 'tab1:pane-1', - tabId: 'tab1', - worktreeId: 'wt', - agent: 'codex', - providerSession: { key: 'session_id', id: 'codex-session' }, - prompt: 'continue', - state: 'working', - capturedAt: 10, - updatedAt: 9 - }, - 'tab2:pane-1': { - paneKey: 'tab2:pane-1', - tabId: 'tab2', - worktreeId: 'wt', - agent: 'codex', - providerSession: { key: 'session_id', id: '--last' }, - prompt: 'ignore me', - state: 'working', - capturedAt: 10, - updatedAt: 9 - } - } - }) - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.value.sleepingAgentSessionsByPaneKey?.['tab1:pane-1']?.providerSession.id).toBe( - 'codex-session' - ) - expect(result.value.sleepingAgentSessionsByPaneKey?.['tab2:pane-1']).toBeUndefined() - } - }) - - it('drops sleeping agent records whose embedded pane key differs from the map key', () => { - const result = parseWorkspaceSession({ - activeRepoId: null, - activeWorktreeId: null, - activeTabId: null, - tabsByWorktree: {}, - terminalLayoutsByTabId: {}, - sleepingAgentSessionsByPaneKey: { - 'tab1:pane-1': { - paneKey: 'tab1:pane-1', - tabId: 'tab1', - worktreeId: 'wt', - agent: 'codex', - providerSession: { key: 'session_id', id: 'codex-session' }, - prompt: 'continue', - state: 'working', - capturedAt: 10, - updatedAt: 9 - }, - 'tab2:pane-1': { - paneKey: 'other-tab:pane-1', - tabId: 'tab2', - worktreeId: 'wt', - agent: 'codex', - providerSession: { key: 'session_id', id: 'mismatched-session' }, - prompt: 'ignore me', - state: 'working', - capturedAt: 10, - updatedAt: 9 - } - } - }) - expect(result.ok).toBe(true) - if (result.ok) { - expect(result.value.sleepingAgentSessionsByPaneKey?.['tab1:pane-1']?.providerSession.id).toBe( - 'codex-session' - ) - expect(result.value.sleepingAgentSessionsByPaneKey?.['tab2:pane-1']).toBeUndefined() - } - }) - it('rejects a session where ptyId is a number (schema drift)', () => { const result = parseWorkspaceSession({ activeRepoId: null, diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts index 15ed9047160..07e98996e34 100644 --- a/src/shared/workspace-session-schema.ts +++ b/src/shared/workspace-session-schema.ts @@ -78,6 +78,7 @@ const terminalTabSchema = z.object({ sortOrder: z.number(), createdAt: z.number(), generation: z.number().optional(), + startupCwd: z.string().min(1).optional(), // Why: persist the launched agent so a restored idle agent tab keeps its // provider icon before any hook fires. `.catch(undefined)` keeps a stale or // unknown agent id from failing the whole-session parse (which would reset @@ -181,13 +182,20 @@ const browserViewportPresetIdSchema = z.enum([ 'desktop' ]) -// Why: cast to WorkspaceSessionState's embedded BrowserWorkspace so future -// additive fields in the type flow through without requiring a schema edit. +// Why: the z.ZodType cast only aligns the static type — it +// does NOT let new fields survive parsing. z.object strips unknown keys, so +// every additive field must be listed below (optional+nullable) or it is +// dropped on restore. const browserWorkspaceSchema: z.ZodType = z.object({ id: z.string(), worktreeId: z.string(), label: z.string().optional(), sessionProfileId: z.string().nullable().optional(), + // Why: optional+nullable so pre-field sessions still validate; without this + // zod strips the persisted partition on restore, and an isolated tab whose + // profile mirror is stale at startup would silently fall back to the shared + // default partition — reopening the storage leak (#6923) across restarts. + sessionPartition: z.string().nullable().optional(), activePageId: z.string().nullable().optional(), pageIds: z.array(z.string()).optional(), url: z.string(), diff --git a/src/shared/workspace-session-terminal-schema.test.ts b/src/shared/workspace-session-terminal-schema.test.ts new file mode 100644 index 00000000000..367ee953f78 --- /dev/null +++ b/src/shared/workspace-session-terminal-schema.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' +import { parseWorkspaceSession } from './workspace-session-schema' + +describe('parseWorkspaceSession terminal fields', () => { + it('preserves terminal startup cwd while accepting older omitted fields', () => { + const result = parseWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: 'wt', + activeTabId: 'tab1', + tabsByWorktree: { + wt: [ + { + id: 'tab1', + ptyId: null, + worktreeId: 'wt', + title: 'Terminal 1', + defaultTitle: 'Terminal 1', + startupCwd: '/repo/packages/app', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0 + }, + { + id: 'tab2', + ptyId: null, + worktreeId: 'wt', + title: 'Terminal 2', + customTitle: null, + color: null, + sortOrder: 1, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: {} + }) + + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value.tabsByWorktree.wt[0].startupCwd).toBe('/repo/packages/app') + expect(result.value.tabsByWorktree.wt[1].startupCwd).toBeUndefined() + } + }) + + it('rejects empty terminal startup cwd values', () => { + const result = parseWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: 'wt', + activeTabId: 'tab1', + tabsByWorktree: { + wt: [ + { + id: 'tab1', + ptyId: null, + worktreeId: 'wt', + title: 'Terminal 1', + defaultTitle: 'Terminal 1', + startupCwd: '', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0 + } + ] + }, + terminalLayoutsByTabId: {} + }) + + expect(result.ok).toBe(false) + }) +}) diff --git a/src/shared/workspace-status-default-migration.ts b/src/shared/workspace-status-default-migration.ts index 6e56f8b5da0..f6d02f67c85 100644 --- a/src/shared/workspace-status-default-migration.ts +++ b/src/shared/workspace-status-default-migration.ts @@ -1,8 +1,9 @@ -const LEGACY_DEFAULT_STATUS_LABELS: Record = { - todo: 'Todo', - 'in-progress': 'In progress', - 'in-review': 'In review', - completed: 'Completed' +const DEFAULT_STATUS_LABELS: Record = { + todo: ['Todo'], + 'in-progress': ['In progress'], + 'in-review': ['In review'], + // Why: both labels have shipped as defaults; either raw shape is safe to migrate. + completed: ['Completed', 'Done'] } const CONDUCTOR_DEFAULT_STATUS_VISUALS: Record = { @@ -45,7 +46,7 @@ function isLegacyDefaultStatusPayload( return ( Object.keys(raw).length === 4 && raw.id === expectedId && - raw.label === LEGACY_DEFAULT_STATUS_LABELS[expectedId] && + DEFAULT_STATUS_LABELS[expectedId]?.includes(raw.label as string) === true && raw.color === expectedVisual?.color && raw.icon === expectedVisual?.icon ) diff --git a/src/shared/workspace-status-defaults.ts b/src/shared/workspace-status-defaults.ts index a698c9a374e..f0c4f10e426 100644 --- a/src/shared/workspace-status-defaults.ts +++ b/src/shared/workspace-status-defaults.ts @@ -8,13 +8,13 @@ export const DEFAULT_STATUS_VISUALS: Record { it('keeps the default workflow order', () => { expect(cloneDefaultWorkspaceStatuses().map((status) => status.id)).toEqual([ - 'completed', - 'in-review', + 'todo', 'in-progress', - 'todo' + 'in-review', + 'completed' ]) - expect(cloneDefaultWorkspaceStatuses()[0]).toMatchObject({ id: 'completed', label: 'Done' }) + expect(cloneDefaultWorkspaceStatuses()[0]).toMatchObject({ id: 'todo', label: 'Todo' }) + expect(cloneDefaultWorkspaceStatuses().at(-1)).toMatchObject({ id: 'completed', label: 'Done' }) }) it('migrates legacy default statuses to the default workflow order', () => { @@ -161,6 +162,30 @@ describe('workspace status visuals', () => { expect(statuses).toEqual(cloneDefaultWorkspaceStatuses()) }) + it('repairs the exact reordered default status payload with the Done label', () => { + const statuses = normalizePersistedWorkspaceStatuses( + [ + { id: 'completed', label: 'Done', color: 'conductor-done', icon: 'conductor-done' }, + { + id: 'in-review', + label: 'In review', + color: 'conductor-review', + icon: 'conductor-review' + }, + { + id: 'in-progress', + label: 'In progress', + color: 'conductor-progress', + icon: 'conductor-progress' + }, + { id: 'todo', label: 'Todo', color: 'neutral', icon: 'circle' } + ], + { repairReorderedDefaultStatuses: true } + ) + + expect(statuses).toEqual(cloneDefaultWorkspaceStatuses()) + }) + it('does not repair reordered default-label statuses with a different raw shape', () => { const statuses = normalizePersistedWorkspaceStatuses( [ diff --git a/src/shared/workspace-statuses.ts b/src/shared/workspace-statuses.ts index 08bc54c9ed1..b3aa587043b 100644 --- a/src/shared/workspace-statuses.ts +++ b/src/shared/workspace-statuses.ts @@ -214,7 +214,7 @@ export function normalizePersistedWorkspaceStatuses( ) { return cloneDefaultWorkspaceStatuses() } - // Why: this PR briefly wrote the default columns in reverse workflow order. + // Why: a previous build briefly wrote the default columns in reverse order. // The repair is one-shot and checks the raw payload, because normalized // IDs/labels are indistinguishable from a user-authored column reorder. if ( diff --git a/src/shared/worktree-ownership-worktree-base-path.test.ts b/src/shared/worktree-ownership-worktree-base-path.test.ts index 968b1f3dab2..d6af4dab77d 100644 --- a/src/shared/worktree-ownership-worktree-base-path.test.ts +++ b/src/shared/worktree-ownership-worktree-base-path.test.ts @@ -48,7 +48,7 @@ describe('repo-specific worktree ownership layouts', () => { worktree: makeWorktree('/projects/a/worktrees/repo/feature'), knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repoA) }) - ).toBe('orca-managed') + ).toBe('external') expect( classifyWorktreeOwnership({ repo: repoB, @@ -73,7 +73,7 @@ describe('repo-specific worktree ownership layouts', () => { worktree: makeWorktree('C:\\projects\\App\\worktrees\\repo\\Feature'), knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo) }) - ).toBe('orca-managed') + ).toBe('external') }) it('includes relative global layouts for SSH repos without applying absolute desktop paths', () => { diff --git a/src/shared/worktree-ownership.test.ts b/src/shared/worktree-ownership.test.ts index c7c77ea5937..0b41859d28b 100644 --- a/src/shared/worktree-ownership.test.ts +++ b/src/shared/worktree-ownership.test.ts @@ -108,7 +108,7 @@ describe('worktree ownership classification', () => { ).toBe('orca-managed') }) - it('requires the nested repo-specific path shape for path-only ownership', () => { + it('treats nested Orca workspace paths without metadata as external', () => { const repo = makeRepo() const settings = makeSettings() const layouts = buildKnownOrcaWorkspaceLayouts(settings, repo) @@ -119,7 +119,7 @@ describe('worktree ownership classification', () => { worktree: makeWorktree({ path: '/orca/workspaces/app/feature' }), knownOrcaLayouts: layouts }) - ).toBe('orca-managed') + ).toBe('external') expect( classifyWorktreeOwnership({ repo, @@ -130,6 +130,94 @@ describe('worktree ownership classification', () => { ).toBe('external') }) + it('treats explicit Orca creation layout metadata as managed', () => { + const repo = makeRepo() + const settings = makeSettings() + expect( + classifyWorktreeOwnership({ + repo, + settings, + worktree: makeWorktree({ path: '/orca/workspaces/app/feature' }), + meta: makeMeta({ + orcaCreationWorkspaceLayout: { path: '/orca/workspaces', nestWorkspaces: true } + }), + knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo) + }) + ).toBe('orca-managed') + }) + + it('does not treat metadata-free nested workspace paths as Orca-managed for new repos', () => { + const repo = makeRepo({ externalWorktreeVisibility: 'hide' }) + const settings = makeSettings() + const detected = toDetectedWorktree({ + repo, + settings, + worktree: makeWorktree({ + path: '/orca/workspaces/app/manual-git-worktree', + isMainWorktree: false + }), + knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo) + }) + + expect(detected.ownership).toBe('external') + expect(detected.visible).toBe(false) + }) + + it('does not treat generic discovery metadata on nested workspace paths as Orca-managed', () => { + const repo = makeRepo({ externalWorktreeVisibility: 'hide' }) + const settings = makeSettings() + const detected = toDetectedWorktree({ + repo, + settings, + worktree: makeWorktree({ + path: '/orca/workspaces/app/manual-git-worktree', + isMainWorktree: false + }), + meta: makeMeta({ displayName: 'manual-git-worktree' }), + knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo) + }) + + expect(detected.ownership).toBe('external') + expect(detected.visible).toBe(false) + }) + + it('keeps nested workspace paths visible for legacy repos without explicit visibility', () => { + const repo = makeRepo() + const settings = makeSettings() + const detected = toDetectedWorktree({ + repo, + settings, + worktree: makeWorktree({ + path: '/orca/workspaces/app/manual-git-worktree', + isMainWorktree: false + }), + knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo) + }) + + expect(detected.ownership).toBe('external') + expect(detected.visible).toBe(true) + }) + + it('hides metadata-free nested workspace paths for legacy repos that hide external worktrees', () => { + const repo = makeRepo({ + externalWorktreeVisibility: 'hide', + externalWorktreeVisibilityLegacy: true + }) + const settings = makeSettings() + const detected = toDetectedWorktree({ + repo, + settings, + worktree: makeWorktree({ + path: '/orca/workspaces/app/manual-git-worktree', + isMainWorktree: false + }), + knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo) + }) + + expect(detected.ownership).toBe('external') + expect(detected.visible).toBe(false) + }) + it('treats flat workspace-root descendants as unknown legacy without strong metadata', () => { const repo = makeRepo() const settings = makeSettings({ nestWorkspaces: false }) @@ -172,7 +260,7 @@ describe('worktree ownership classification', () => { worktree: makeWorktree({ path: '/old/workspaces/app/feature' }), knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo) }) - ).toBe('orca-managed') + ).toBe('external') }) it('builds known layouts from large workspace history lists', () => { @@ -214,7 +302,7 @@ describe('worktree ownership classification', () => { }), knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo) }) - ).toBe('orca-managed') + ).toBe('external') }) it('keeps selected linked checkouts visible without trusting Git main-worktree', () => { diff --git a/src/shared/worktree-ownership.ts b/src/shared/worktree-ownership.ts index 9140f41795b..94ba1d908ff 100644 --- a/src/shared/worktree-ownership.ts +++ b/src/shared/worktree-ownership.ts @@ -1,5 +1,4 @@ import { - getRuntimePathBasename, isRuntimePathAbsolute, isWindowsAbsolutePathLike, normalizeRuntimePathForComparison, @@ -160,15 +159,13 @@ export function classifyWorktreeOwnership(args: { return 'orca-managed' } - if (matchesStrongOrcaCreatePath(args.worktree.path, args.knownOrcaLayouts, args.repo)) { - return 'orca-managed' - } - if (isUnderFlatOrUntrustedOrcaRoot(args.worktree.path, args.knownOrcaLayouts)) { return 'unknown-legacy' } if (canClassifyAsExternal(args.worktree.path, args.knownOrcaLayouts)) { + // Why: a plain `git worktree add` can target Orca's nested workspace + // folder. Only metadata proves Orca created it. return 'external' } @@ -240,6 +237,7 @@ export function areRuntimePathsEqual(leftPath: string, rightPath: string): boole function hasStrongOrcaMetadata(meta: WorktreeMeta | undefined): boolean { return Boolean( meta?.orcaCreatedAt || + meta?.orcaCreationWorkspaceLayout || meta?.createdAt || meta?.createdWithAgent || meta?.pushTarget || @@ -249,38 +247,6 @@ function hasStrongOrcaMetadata(meta: WorktreeMeta | undefined): boolean { ) } -export function matchesStrongOrcaCreatePath( - worktreePath: string, - knownOrcaLayouts: readonly OrcaWorkspaceLayout[], - repo: Pick -): boolean { - const repoName = getRuntimePathBasename(repo.path).replace(/\.git$/i, '') - if (!repoName) { - return false - } - for (const layout of knownOrcaLayouts) { - if (!layout.nestWorkspaces) { - continue - } - const relative = relativePathInsideRoot(layout.path, worktreePath) - if (relative === null) { - continue - } - const segments = splitNormalizedPath(relative) - const caseInsensitive = - isWindowsAbsolutePathLike(layout.path) || isWindowsAbsolutePathLike(worktreePath) - if ( - segments.length === 2 && - normalizePathSegment(segments[0], caseInsensitive) === - normalizePathSegment(repoName, caseInsensitive) && - segments[1].length > 0 - ) { - return true - } - } - return false -} - function isUnderFlatOrUntrustedOrcaRoot( worktreePath: string, knownOrcaLayouts: OrcaWorkspaceLayout[] @@ -313,12 +279,3 @@ function canClassifyAsExternal( } return true } - -function splitNormalizedPath(value: string): string[] { - return normalizeRuntimePathSeparators(value).split('/').filter(Boolean) -} - -function normalizePathSegment(value: string, caseInsensitive: boolean): string { - const normalized = normalizeRuntimePathSeparators(value) - return caseInsensitive ? normalized.toLowerCase() : normalized -} diff --git a/tests/e2e/fixtures/visible-tui-scroll-fixture.cjs b/tests/e2e/fixtures/visible-tui-scroll-fixture.cjs new file mode 100644 index 00000000000..137bc48dee1 --- /dev/null +++ b/tests/e2e/fixtures/visible-tui-scroll-fixture.cjs @@ -0,0 +1,96 @@ +const fs = require('node:fs') + +const ESC = '\x1b' +const MOUSE_REPORT_PATTERN = new RegExp(`${ESC}\\[<(64|65);\\d+;\\d+M`, 'g') + +// Optional flags: +// --log append " " per consumed stdin +// chunk so tests can measure when reports reached the PTY. +// --heavy emit Claude-Code-scale redraw frames (~4KB of styled cells +// per render, ~430KB/s at 120 reports/s — matches output +// volume measured from a real Claude Code session scrolling +// at 120 reports/s) instead of minimal rows. +const args = process.argv.slice(2) +const logIndex = args.indexOf('--log') +const LOG_PATH = logIndex >= 0 ? args[logIndex + 1] : null +const HEAVY_FRAMES = args.includes('--heavy') + +let offset = 0 +let pending = '' + +function write(data) { + process.stdout.write(data) +} + +function visibleRows() { + return Math.max(3, process.stdout.rows || 24) +} + +function visibleCols() { + return Math.max(20, process.stdout.columns || 80) +} + +function heavyRowFiller(row, cols) { + // ~19 bytes per 8 visible columns of styled filler. + const unit = `${ESC}[38;5;${((row * 17) % 200) + 16}m········` + const units = Math.max(0, Math.floor((cols - 24) / 8)) + return `${unit.repeat(units)}${ESC}[0m` +} + +function render() { + const rows = visibleRows() + const cols = visibleCols() + let frame = `${ESC}[?2026h${ESC}[H` + frame += `TUI_SCROLL_READY offset=${offset}${ESC}[K` + for (let row = 1; row < rows; row += 1) { + const label = `TUI_SCROLL_ROW_${String(offset + row - 1).padStart(4, '0')}` + frame += `\r\n${label}${HEAVY_FRAMES ? ` ${heavyRowFiller(row, cols)}` : ''}${ESC}[K` + } + frame += `${ESC}[?2026l` + write(frame) +} + +function cleanup() { + write(`${ESC}[?1003l${ESC}[?1006l${ESC}[?25h${ESC}[?1049l`) + process.exit(0) +} + +process.stdin.setEncoding('utf8') +if (process.stdin.isTTY) { + process.stdin.setRawMode(true) +} +process.stdin.resume() + +write(`${ESC}[?1049h${ESC}[?1003h${ESC}[?1006h${ESC}[?25l${ESC}[2J`) +render() + +process.stdin.on('data', (chunk) => { + if (chunk.includes('\x03') || chunk.includes('q')) { + cleanup() + } + + pending += chunk + let match + let lastIndex = 0 + let reportsInChunk = 0 + + MOUSE_REPORT_PATTERN.lastIndex = 0 + while ((match = MOUSE_REPORT_PATTERN.exec(pending)) !== null) { + offset = Math.max(0, offset + (match[1] === '65' ? 1 : -1)) + reportsInChunk += 1 + lastIndex = MOUSE_REPORT_PATTERN.lastIndex + } + + if (lastIndex > 0) { + if (LOG_PATH) { + fs.appendFileSync(LOG_PATH, `${Date.now()} ${reportsInChunk}\n`) + } + pending = pending.slice(lastIndex) + render() + return + } + + pending = pending.slice(-32) +}) + +process.on('SIGINT', cleanup) diff --git a/tests/e2e/helpers/orca-app.ts b/tests/e2e/helpers/orca-app.ts index ecfa238d9d4..64dfe9d2148 100644 --- a/tests/e2e/helpers/orca-app.ts +++ b/tests/e2e/helpers/orca-app.ts @@ -314,23 +314,38 @@ export const test = base.extend({ await window.api.repos.add({ path: repoPath }) }, repoPath) - // Fetch repos in the renderer store so it picks up the new repo - await page.evaluate(async (repoPath) => { - const store = window.__store - if (!store) { - return - } - - await store.getState().fetchRepos() - const repo = store.getState().repos.find((candidate) => candidate.path === repoPath) - if (!repo) { - throw new Error(`Expected e2e repo to be loaded: ${repoPath}`) - } - // Why: the fixture deliberately creates external Git worktrees. New - // repos hide those by default after the visibility rollout, so opt this - // disposable repo into showing them before specs assert on worktree state. - await store.getState().updateRepo(repo.id, { externalWorktreeVisibility: 'show' }) - }, repoPath) + // Fetch repos in the renderer store so it picks up the new repo, then opt + // this disposable repo into showing external worktrees. + // Why: repos.add() fires a repos:changed echo that triggers a *concurrent* + // fetchRepos() in the renderer; the store's generation guard can then drop + // this awaited fetch's result, leaving `repos` briefly stale. Poll the + // public fetch path until the repo lands instead of asserting on the first + // tick (mirrors the seeded-worktree poll below). updateRepo is idempotent, + // so running it once the repo appears is safe across poll ticks. + await playwrightExpect + .poll( + () => + page.evaluate(async (repoPath) => { + const store = window.__store + if (!store) { + return false + } + await store.getState().fetchRepos() + const repo = store.getState().repos.find((candidate) => candidate.path === repoPath) + if (!repo) { + return false + } + // Why: the fixture deliberately creates external Git worktrees. New + // repos hide those by default after the visibility rollout. + await store.getState().updateRepo(repo.id, { externalWorktreeVisibility: 'show' }) + return true + }, repoPath), + { + timeout: 30_000, + message: `Expected e2e repo to be loaded: ${repoPath}` + } + ) + .toBe(true) // Wait for the repo to appear and fetch its worktrees await page.evaluate(async () => { diff --git a/tests/e2e/markdown-prose-reflow.spec.ts b/tests/e2e/markdown-prose-reflow.spec.ts new file mode 100644 index 00000000000..6767d3ea2ae --- /dev/null +++ b/tests/e2e/markdown-prose-reflow.spec.ts @@ -0,0 +1,246 @@ +import { test, expect } from './helpers/orca-app' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + cleanupMarkdownFixture, + createMarkdownFixture, + getActiveWorktreeContext, + openMarkdownFixture, + waitForRichMarkdownEditor +} from './helpers/markdown-ordered-list-exit' + +const HARD_WRAPPED_MARKDOWN = + 'The store owns launch-lifetime presentation state keyed by tab id, while native\n' + + 'chat owns conversion, ordering, and transcript reconciliation through pure\n' + + 'helpers. This keeps the bridge removable later if provider-native launch\n' + + 'delivery becomes universal.\n\n' + + '## Next section\n' + +type ReflowMetrics = { + hardBreakCount: number + lineCount: number + paragraphCount: number + sourceLineCount: number + textContent: string + whiteSpace: string +} + +type PageEditorDocNode = { + textContent?: string + type?: { name?: string } +} + +type PageRichMarkdownEditorElement = Element & { + editor?: { + commands?: { + focus?: () => boolean + setTextSelection?: (position: number) => boolean + } + state?: { + doc?: { + descendants?: (callback: (node: PageEditorDocNode, pos: number) => boolean) => void + } + } + } +} + +async function openHardWrappedFixture( + page: Parameters[0], + testInfo: { workerIndex: number } +): Promise { + const context = await getActiveWorktreeContext(page) + const filePath = await createMarkdownFixture( + context, + 'prose-reflow', + testInfo.workerIndex, + HARD_WRAPPED_MARKDOWN + ) + await openMarkdownFixture(page, context, filePath) + await waitForRichMarkdownEditor(page) + return filePath +} + +async function getGoalParagraphMetrics( + page: Parameters[0] +): Promise { + return page.evaluate(() => { + const editor = document.querySelector('.rich-markdown-editor') + if (!editor) { + throw new Error('Rich markdown editor was not mounted') + } + + const paragraphs = Array.from(editor.querySelectorAll('p')).filter((paragraph) => + paragraph.textContent?.includes('launch-lifetime') + ) + const paragraph = paragraphs[0] + if (!paragraph) { + throw new Error('Hard-wrapped paragraph was not rendered') + } + + const range = document.createRange() + range.selectNodeContents(paragraph) + const lineTops: number[] = [] + for (const rect of Array.from(range.getClientRects())) { + if (rect.width <= 0 || rect.height <= 0) { + continue + } + if (!lineTops.some((top) => Math.abs(top - rect.top) < 2)) { + lineTops.push(rect.top) + } + } + + return { + hardBreakCount: paragraph.querySelectorAll('br').length, + lineCount: lineTops.length, + paragraphCount: paragraphs.length, + sourceLineCount: paragraph.textContent?.split('\n').length ?? 0, + textContent: paragraph.textContent ?? '', + whiteSpace: window.getComputedStyle(paragraph).whiteSpace + } + }) +} + +async function placeCaretAtGoalParagraphEnd( + page: Parameters[0] +): Promise { + await page.evaluate(() => { + const editorElement = document.querySelector( + '.rich-markdown-editor' + ) as PageRichMarkdownEditorElement | null + const paragraph = Array.from(editorElement?.querySelectorAll('p') ?? []).find((candidate) => + candidate.textContent?.includes('launch-lifetime') + ) as + | (HTMLParagraphElement & { + pmViewDesc?: { + posAtEnd?: number + } + }) + | undefined + const selectionPosition = paragraph?.pmViewDesc?.posAtEnd + if (!editorElement?.editor?.commands || typeof selectionPosition !== 'number') { + throw new Error('Cannot place caret at the hard-wrapped paragraph end') + } + + editorElement.editor.commands.setTextSelection?.(selectionPosition) + editorElement.editor.commands.focus?.() + }) +} + +async function placeCaretAtHeadingStart( + page: Parameters[0] +): Promise { + await page.evaluate(() => { + const editorElement = document.querySelector( + '.rich-markdown-editor' + ) as PageRichMarkdownEditorElement | null + const editor = editorElement?.editor + let selectionPosition: number | null = null + editor?.state?.doc?.descendants?.((node, pos) => { + if (node.type?.name === 'heading' && node.textContent?.includes('Next section')) { + selectionPosition = pos + 1 + return false + } + return true + }) + + if (!editor?.commands || selectionPosition === null) { + throw new Error('Cannot place caret at the heading start') + } + + editor.commands.setTextSelection?.(selectionPosition) + editor.commands.focus?.() + }) +} + +test.describe('Markdown prose reflow', () => { + test.beforeEach(async ({ orcaPage }) => { + await orcaPage.setViewportSize({ width: 1440, height: 900 }) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + }) + + test('hard-wrapped prose reflows as one document paragraph', async ({ orcaPage }, testInfo) => { + let filePath: string | null = null + + try { + filePath = await openHardWrappedFixture(orcaPage, testInfo) + const metrics = await getGoalParagraphMetrics(orcaPage) + + expect(metrics.paragraphCount).toBe(1) + expect(metrics.sourceLineCount).toBe(4) + expect(metrics.whiteSpace).toBe('normal') + expect(metrics.lineCount).toBeLessThan(metrics.sourceLineCount) + } finally { + await cleanupMarkdownFixture(filePath) + } + }) + + test('deleting an inserted empty paragraph keeps hard-wrapped prose reflowing', async ({ + orcaPage + }, testInfo) => { + let filePath: string | null = null + + try { + filePath = await openHardWrappedFixture(orcaPage, testInfo) + await placeCaretAtGoalParagraphEnd(orcaPage) + await orcaPage.keyboard.press('Enter') + await orcaPage.keyboard.press('Backspace') + + const metrics = await getGoalParagraphMetrics(orcaPage) + + expect(metrics.hardBreakCount).toBe(0) + expect(metrics.paragraphCount).toBe(1) + expect(metrics.sourceLineCount).toBe(4) + expect(metrics.whiteSpace).toBe('normal') + expect(metrics.lineCount).toBeLessThan(metrics.sourceLineCount) + } finally { + await cleanupMarkdownFixture(filePath) + } + }) + + test('deleting slash text then the empty paragraph keeps hard-wrapped prose reflowing', async ({ + orcaPage + }, testInfo) => { + let filePath: string | null = null + + try { + filePath = await openHardWrappedFixture(orcaPage, testInfo) + await placeCaretAtGoalParagraphEnd(orcaPage) + await orcaPage.keyboard.press('Enter') + await orcaPage.keyboard.type('/') + await orcaPage.keyboard.press('Backspace') + await orcaPage.keyboard.press('Backspace') + + const metrics = await getGoalParagraphMetrics(orcaPage) + + expect(metrics.hardBreakCount).toBe(0) + expect(metrics.paragraphCount).toBe(1) + expect(metrics.sourceLineCount).toBe(4) + expect(metrics.whiteSpace).toBe('normal') + expect(metrics.lineCount).toBeLessThan(metrics.sourceLineCount) + } finally { + await cleanupMarkdownFixture(filePath) + } + }) + + test('deleting the block boundary before a heading keeps hard-wrapped prose reflowing', async ({ + orcaPage + }, testInfo) => { + let filePath: string | null = null + + try { + filePath = await openHardWrappedFixture(orcaPage, testInfo) + await placeCaretAtHeadingStart(orcaPage) + await orcaPage.keyboard.press('Backspace') + + const metrics = await getGoalParagraphMetrics(orcaPage) + + expect(metrics.hardBreakCount).toBe(0) + expect(metrics.paragraphCount).toBe(1) + expect(metrics.sourceLineCount).toBe(4) + expect(metrics.whiteSpace).toBe('normal') + expect(metrics.lineCount).toBeLessThan(metrics.sourceLineCount) + } finally { + await cleanupMarkdownFixture(filePath) + } + }) +}) diff --git a/tests/e2e/project-group-manual-sort.spec.ts b/tests/e2e/project-group-manual-sort.spec.ts new file mode 100644 index 00000000000..48c6dca1663 --- /dev/null +++ b/tests/e2e/project-group-manual-sort.spec.ts @@ -0,0 +1,339 @@ +import { execFileSync } from 'node:child_process' +import { mkdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtemp } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { test, expect } from './helpers/orca-app' +import { waitForSessionReady } from './helpers/store' +import type { Page } from '@stablyai/playwright-test' + +const GROUP_NAMES = [ + 'E2E Manual Group Alpha', + 'E2E Manual Group Bravo', + 'E2E Manual Group Charlie', + 'E2E Manual Group Delta' +] as const + +const PROJECT_NAMES = [ + 'e2e-manual-project-alpha', + 'e2e-manual-project-bravo', + 'e2e-manual-project-charlie' +] as const + +const tempRoots: string[] = [] + +type SeededProjectGroupSortScenario = { + alphaId: string + bravoId: string + charlieId: string + deltaId: string +} + +type SeededProjectHeaderSortScenario = { + alphaId: string + bravoId: string + charlieId: string +} + +function initializeGitRepo(repoPath: string): void { + mkdirSync(repoPath, { recursive: true }) + execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' }) + execFileSync('git', ['config', 'user.email', 'e2e@test.local'], { + cwd: repoPath, + stdio: 'pipe' + }) + execFileSync('git', ['config', 'user.name', 'E2E Test'], { cwd: repoPath, stdio: 'pipe' }) + writeFileSync(path.join(repoPath, 'README.md'), `# ${path.basename(repoPath)}\n`) + execFileSync('git', ['add', 'README.md'], { cwd: repoPath, stdio: 'pipe' }) + execFileSync('git', ['commit', '-m', 'Initial commit'], { cwd: repoPath, stdio: 'pipe' }) +} + +async function createProjectHeaderSortFixture(): Promise { + // Why: match the app's canonical repo.path on macOS, where os.tmpdir() + // can resolve through /var -> /private/var. + const root = realpathSync(await mkdtemp(path.join(os.tmpdir(), 'orca-e2e-project-sort-'))) + tempRoots.push(root) + const repoPaths = PROJECT_NAMES.map((name) => path.join(root, name)) + for (const repoPath of repoPaths) { + initializeGitRepo(repoPath) + } + return repoPaths +} + +async function seedProjectHeaderSortScenario( + page: Page, + repoPaths: readonly string[] +): Promise { + return page.evaluate(async (paths) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + + for (const repoPath of paths) { + await window.api.repos.add({ path: repoPath }) + } + + const state = store.getState() + state.setActiveView('terminal') + state.setSidebarOpen(true) + state.setGroupBy('repo') + state.setProjectOrderBy('manual') + await state.fetchRepos() + + const repos = paths.map((repoPath) => { + const repo = store.getState().repos.find((candidate) => candidate.path === repoPath) + if (!repo) { + throw new Error(`Expected project repo to be loaded: ${repoPath}`) + } + return repo + }) + + for (const repo of repos) { + await store.getState().fetchWorktrees(repo.id) + } + + return { + alphaId: repos[0]!.id, + bravoId: repos[1]!.id, + charlieId: repos[2]!.id + } + }, repoPaths) +} + +async function seedDuplicateTabOrderProjectGroups( + page: Page +): Promise { + return page.evaluate(async (groupNames) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + + const state = store.getState() + state.setActiveView('terminal') + state.setSidebarOpen(true) + state.setGroupBy('repo') + state.setProjectOrderBy('manual') + + const groups = [] + for (const name of groupNames) { + const created = await state.createProjectGroup(name) + if (!created) { + throw new Error(`Failed to create Project Group: ${name}`) + } + // Why: existing profiles can have several groups with the same legacy rank; + // the drag must still be able to insert one between its siblings. + await state.updateProjectGroup(created.id, { tabOrder: 0 }) + groups.push(created) + } + + return { + alphaId: groups[0]!.id, + bravoId: groups[1]!.id, + charlieId: groups[2]!.id, + deltaId: groups[3]!.id + } + }, GROUP_NAMES) +} + +async function getProjectHeaderOrder( + page: Page, + projectIds: SeededProjectHeaderSortScenario +): Promise { + const expectedIds = new Set(Object.values(projectIds)) + return page.locator('[data-worktree-sidebar] [data-repo-header-id]').evaluateAll( + (elements, ids) => + elements + .map((element) => ({ + id: element.getAttribute('data-repo-header-id') ?? '', + top: element.getBoundingClientRect().top + })) + .filter((entry) => ids.includes(entry.id)) + .sort((left, right) => left.top - right.top) + .map((entry) => entry.id), + [...expectedIds] + ) +} + +async function getProjectGroupHeaderOrder( + page: Page, + groupIds: SeededProjectGroupSortScenario +): Promise { + const expectedIds = new Set(Object.values(groupIds)) + return page.locator('[data-worktree-sidebar] [data-project-group-header-id]').evaluateAll( + (elements, ids) => + elements + .map((element) => ({ + id: element.getAttribute('data-project-group-header-id') ?? '', + top: element.getBoundingClientRect().top + })) + .filter((entry) => ids.includes(entry.id)) + .sort((left, right) => left.top - right.top) + .map((entry) => entry.id), + [...expectedIds] + ) +} + +async function dragProjectBefore(args: { + page: Page + draggedProjectId: string + targetProjectId: string +}): Promise { + const source = args.page.locator(`[data-repo-header-id="${args.draggedProjectId}"]`) + const target = args.page.locator(`[data-repo-header-id="${args.targetProjectId}"]`) + await source.scrollIntoViewIfNeeded() + await target.scrollIntoViewIfNeeded() + const sourceBox = await source.boundingBox() + const targetBox = await target.boundingBox() + if (!sourceBox || !targetBox) { + throw new Error('Project header bounding box was not available') + } + + await args.page.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2) + await args.page.mouse.down() + await args.page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + 3, { steps: 8 }) + await args.page.mouse.up() +} + +async function dragProjectIntoProjectBody(args: { + page: Page + draggedProjectId: string + targetProjectId: string +}): Promise { + const source = args.page.locator(`[data-repo-header-id="${args.draggedProjectId}"]`) + const targetHeader = args.page.locator(`[data-repo-header-id="${args.targetProjectId}"]`) + await source.scrollIntoViewIfNeeded() + await targetHeader.scrollIntoViewIfNeeded() + const sourceBox = await source.boundingBox() + const targetHeaderBox = await targetHeader.boundingBox() + if (!sourceBox || !targetHeaderBox) { + throw new Error('Project body drag bounding box was not available') + } + + await args.page.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2) + await args.page.mouse.down() + await args.page.mouse.move( + targetHeaderBox.x + targetHeaderBox.width / 2, + targetHeaderBox.y + targetHeaderBox.height * 0.75, + { steps: 4 } + ) + await args.page.mouse.move( + targetHeaderBox.x + targetHeaderBox.width / 2, + targetHeaderBox.y + targetHeaderBox.height + 32, + { steps: 8 } + ) + await args.page.mouse.up() +} + +async function dragProjectGroupBefore(args: { + page: Page + draggedGroupId: string + targetGroupId: string +}): Promise { + const source = args.page.locator(`[data-project-group-header-id="${args.draggedGroupId}"]`) + const target = args.page.locator(`[data-project-group-header-id="${args.targetGroupId}"]`) + await source.scrollIntoViewIfNeeded() + await target.scrollIntoViewIfNeeded() + const sourceBox = await source.boundingBox() + const targetBox = await target.boundingBox() + if (!sourceBox || !targetBox) { + throw new Error('Project Group header bounding box was not available') + } + + await args.page.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2) + await args.page.mouse.down() + await args.page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + 3, { steps: 8 }) + await args.page.mouse.up() +} + +test.afterEach(() => { + for (const root of tempRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +test.describe('Project Group manual sorting', () => { + test('dragging a project header body reorders the visible project headers', async ({ + orcaPage + }) => { + await waitForSessionReady(orcaPage) + const repoPaths = await createProjectHeaderSortFixture() + const projects = await seedProjectHeaderSortScenario(orcaPage, repoPaths) + + await expect + .poll(() => getProjectHeaderOrder(orcaPage, projects), { + timeout: 12_000, + message: 'Project headers did not render in manual order' + }) + .toEqual([projects.alphaId, projects.bravoId, projects.charlieId]) + + await dragProjectBefore({ + page: orcaPage, + draggedProjectId: projects.charlieId, + targetProjectId: projects.bravoId + }) + + await expect + .poll(() => getProjectHeaderOrder(orcaPage, projects), { + timeout: 12_000, + message: 'Dragged project header body did not persist the requested visible order' + }) + .toEqual([projects.alphaId, projects.charlieId, projects.bravoId]) + }) + + test('dropping a project over another project body does not reorder projects', async ({ + orcaPage + }) => { + await waitForSessionReady(orcaPage) + const repoPaths = await createProjectHeaderSortFixture() + const projects = await seedProjectHeaderSortScenario(orcaPage, repoPaths) + + await expect + .poll(() => getProjectHeaderOrder(orcaPage, projects), { + timeout: 12_000, + message: 'Project headers did not render in manual order' + }) + .toEqual([projects.alphaId, projects.bravoId, projects.charlieId]) + + await dragProjectIntoProjectBody({ + page: orcaPage, + draggedProjectId: projects.alphaId, + targetProjectId: projects.bravoId + }) + + await expect + .poll(() => getProjectHeaderOrder(orcaPage, projects), { + timeout: 12_000, + message: 'Dropping on a project body should not persist a project reorder' + }) + .toEqual([projects.alphaId, projects.bravoId, projects.charlieId]) + }) + + test('dragging a duplicate-ranked Project Group header reorders the visible headers', async ({ + orcaPage + }) => { + await waitForSessionReady(orcaPage) + const groups = await seedDuplicateTabOrderProjectGroups(orcaPage) + + await expect + .poll(() => getProjectGroupHeaderOrder(orcaPage, groups), { + timeout: 12_000, + message: 'Project Group headers did not render in duplicate-rank name order' + }) + .toEqual([groups.alphaId, groups.bravoId, groups.charlieId, groups.deltaId]) + + await dragProjectGroupBefore({ + page: orcaPage, + draggedGroupId: groups.deltaId, + targetGroupId: groups.charlieId + }) + + await expect + .poll(() => getProjectGroupHeaderOrder(orcaPage, groups), { + timeout: 12_000, + message: 'Dragged Project Group header did not persist the requested visible order' + }) + .toEqual([groups.alphaId, groups.bravoId, groups.deltaId, groups.charlieId]) + }) +}) diff --git a/tests/e2e/ssh-ai-vault-session-history.spec.ts b/tests/e2e/ssh-ai-vault-session-history.spec.ts new file mode 100644 index 00000000000..0cb6a485ca4 --- /dev/null +++ b/tests/e2e/ssh-ai-vault-session-history.spec.ts @@ -0,0 +1,293 @@ +import type { Page, TestInfo } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { + cleanupDockerSshRelayTarget, + DOCKER_SSH_RELAY_REMOTE_REPO_PATH, + startDockerSshRelayTarget, + type DockerSshRelayTarget +} from './helpers/docker-ssh-relay-target' +import { connectDockerRemote } from './ssh-codex-reconnect-replay-driver' +import { dockerExec, dockerWriteFile } from './ssh-codex-repro-remote-fixtures' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' + +const RUN_DOCKER_SSH = process.env.ORCA_E2E_SSH_DOCKER === '1' + +test.describe('SSH Agent Session History', () => { + test.skip(!RUN_DOCKER_SSH, 'Set ORCA_E2E_SSH_DOCKER=1 to run Docker-backed SSH tests.') + test.skip(process.platform === 'win32', 'Docker SSH tests use POSIX ssh tooling.') + + test('shows remote session history only for the SSH host and resumes Codex on that worktree', async ({ + orcaPage + }, testInfo: TestInfo) => { + test.slow() + let target: DockerSshRelayTarget | null = null + const stamp = Date.now() + const defaultSessionId = `remote-ai-vault-${stamp}` + const runtimeSessionId = `remote-ai-vault-runtime-${stamp}` + const claudeSessionId = `remote-ai-vault-claude-${stamp}` + const defaultTitle = `Remote AI Vault ${stamp}` + const runtimeTitle = `Remote Runtime AI Vault ${stamp}` + const claudeTitle = `Remote Claude AI Vault ${stamp}` + + try { + target = startDockerSshRelayTarget(testInfo) + seedRemoteAiVaultHistory(target, { + defaultSessionId, + runtimeSessionId, + claudeSessionId, + defaultTitle, + runtimeTitle, + claudeTitle + }) + + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + const remote = await connectDockerRemote(orcaPage, target) + const sshScope = `ssh:${encodeURIComponent(remote.targetId)}` + + const scan = await orcaPage.evaluate( + async ({ sshScope, defaultTitle, runtimeTitle, claudeTitle }) => { + const local = await window.api.aiVault.listSessions({ + executionHostScope: 'local', + force: true + }) + const ssh = await window.api.aiVault.listSessions({ + executionHostScope: sshScope, + force: true + }) + const all = await window.api.aiVault.listSessions({ + executionHostScope: 'all', + force: true + }) + return { + localHasRemote: local.sessions.some((session) => session.title === defaultTitle), + sshTitles: ssh.sessions.map((session) => session.title), + allHasRuntime: all.sessions.some((session) => session.title === runtimeTitle), + allHasClaude: all.sessions.some((session) => session.title === claudeTitle), + remoteHostIds: ssh.sessions + .filter((session) => + [defaultTitle, runtimeTitle, claudeTitle].includes(session.title) + ) + .map((session) => session.executionHostId), + remoteCommands: ssh.sessions + .filter((session) => session.title === defaultTitle || session.title === runtimeTitle) + .map((session) => session.resumeCommand) + } + }, + { sshScope, defaultTitle, runtimeTitle, claudeTitle } + ) + expect(scan.localHasRemote).toBe(false) + expect(scan.sshTitles).toEqual( + expect.arrayContaining([defaultTitle, runtimeTitle, claudeTitle]) + ) + expect(scan.allHasRuntime).toBe(true) + expect(scan.allHasClaude).toBe(true) + expect(new Set(scan.remoteHostIds)).toEqual(new Set([sshScope])) + expect(scan.remoteCommands.join('\n')).toContain("CODEX_HOME='/root/.codex'") + expect(scan.remoteCommands.join('\n')).toContain( + "CODEX_HOME='/root/.local/share/orca/codex-runtime-home/home'" + ) + + const defaultSessionTitle = orcaPage.getByText(defaultTitle, { exact: true }) + const runtimeSessionTitle = orcaPage.getByText(runtimeTitle, { exact: true }) + + await openAiVaultSidebar(orcaPage) + await expect(defaultSessionTitle.first()).toBeVisible({ timeout: 30_000 }) + + const hostButton = orcaPage.getByRole('button', { name: /Session History host:/ }) + await hostButton.click() + await orcaPage.getByRole('menuitemradio', { name: /Local/ }).click() + await expect(defaultSessionTitle).toHaveCount(0, { timeout: 30_000 }) + + await hostButton.click() + await orcaPage.getByRole('menuitemradio', { name: 'All hosts' }).click() + await expect(runtimeSessionTitle.first()).toBeVisible({ timeout: 30_000 }) + + await hostButton.click() + await orcaPage + .getByRole('menuitemradio') + .filter({ hasNotText: /Local|All hosts/ }) + .click() + await expect(defaultSessionTitle.first()).toBeVisible({ timeout: 30_000 }) + + await installStartupQueueProbe(orcaPage) + await defaultSessionTitle.first().click() + await orcaPage.getByText('Resume in Worktree', { exact: true }).click() + + await expect + .poll(() => readLastQueuedStartupCommand(orcaPage), { timeout: 30_000 }) + .toContain(`CODEX_HOME='/root/.codex' codex resume '${defaultSessionId}'`) + const queuedWorktreeId = await readLastQueuedStartupWorktreeId(orcaPage) + expect(queuedWorktreeId).toBe(remote.worktreeId) + } finally { + cleanupDockerSshRelayTarget(target) + } + }) +}) + +function seedRemoteAiVaultHistory( + target: DockerSshRelayTarget, + args: { + defaultSessionId: string + runtimeSessionId: string + claudeSessionId: string + defaultTitle: string + runtimeTitle: string + claudeTitle: string + } +): void { + dockerExec( + target, + [ + 'mkdir -p /root/.codex/sessions/2026/07/04', + 'mkdir -p /root/.local/share/orca/codex-runtime-home/home/sessions/2026/07/04', + 'mkdir -p /root/.claude/projects/orca' + ].join(' && ') + ) + dockerWriteFile( + target, + '/root/.codex/session_index.jsonl', + jsonLines([{ id: args.defaultSessionId, thread_name: args.defaultTitle }]), + '600' + ) + dockerWriteFile( + target, + `/root/.codex/sessions/2026/07/04/${args.defaultSessionId}.jsonl`, + codexTranscript({ + sessionId: args.defaultSessionId, + title: args.defaultTitle, + cwd: DOCKER_SSH_RELAY_REMOTE_REPO_PATH, + timestamp: '2026-07-04T01:00:00.000Z' + }), + '600' + ) + dockerWriteFile( + target, + `/root/.local/share/orca/codex-runtime-home/home/sessions/2026/07/04/${args.runtimeSessionId}.jsonl`, + codexTranscript({ + sessionId: args.runtimeSessionId, + title: args.runtimeTitle, + cwd: DOCKER_SSH_RELAY_REMOTE_REPO_PATH, + timestamp: '2026-07-04T02:00:00.000Z' + }), + '600' + ) + dockerWriteFile( + target, + `/root/.claude/projects/orca/${args.claudeSessionId}.jsonl`, + claudeTranscript({ + sessionId: args.claudeSessionId, + title: args.claudeTitle, + timestamp: '2026-07-04T03:00:00.000Z' + }), + '600' + ) +} + +function codexTranscript(args: { + sessionId: string + title: string + cwd: string + timestamp: string +}): string { + return jsonLines([ + { + timestamp: args.timestamp, + type: 'session_meta', + payload: { id: args.sessionId, cwd: args.cwd } + }, + { + timestamp: args.timestamp.replace(':00.000Z', ':01.000Z'), + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'text', text: args.title }] + } + } + ]) +} + +function jsonLines(records: unknown[]): string { + return records.map((record) => JSON.stringify(record)).join('\n') +} + +function claudeTranscript(args: { sessionId: string; title: string; timestamp: string }): string { + return jsonLines([ + { + sessionId: args.sessionId, + timestamp: args.timestamp, + type: 'user', + message: { content: [{ type: 'text', text: args.title }] } + }, + { + sessionId: args.sessionId, + timestamp: args.timestamp.replace(':00.000Z', ':01.000Z'), + type: 'assistant', + message: { model: 'claude-opus-4', content: 'Remote session acknowledged.' } + } + ]) +} + +async function openAiVaultSidebar(page: Page): Promise { + await page.evaluate(() => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + store.getState().setRightSidebarOpen(true) + store.getState().setRightSidebarTab('vault') + }) +} + +async function installStartupQueueProbe(page: Page): Promise { + await page.evaluate(() => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + const holder = window as unknown as { + __aiVaultQueuedStartups?: { tabId: string; startup: { command: string } }[] + } + holder.__aiVaultQueuedStartups = [] + const current = store.getState() + const original = current.queueTabStartupCommand + store.setState({ + queueTabStartupCommand: (tabId, startup) => { + holder.__aiVaultQueuedStartups?.push({ tabId, startup: { command: startup.command } }) + original(tabId, startup) + } + }) + }) +} + +async function readLastQueuedStartupCommand(page: Page): Promise { + return page.evaluate(() => { + const holder = window as unknown as { + __aiVaultQueuedStartups?: { startup: { command: string } }[] + } + return holder.__aiVaultQueuedStartups?.at(-1)?.startup.command ?? null + }) +} + +async function readLastQueuedStartupWorktreeId(page: Page): Promise { + return page.evaluate(() => { + const holder = window as unknown as { + __aiVaultQueuedStartups?: { tabId: string }[] + } + const tabId = holder.__aiVaultQueuedStartups?.at(-1)?.tabId + if (!tabId) { + return null + } + const state = window.__store?.getState() + if (!state) { + return null + } + for (const [worktreeId, tabs] of Object.entries(state.tabsByWorktree)) { + if (tabs.some((tab) => tab.id === tabId)) { + return worktreeId + } + } + return null + }) +} diff --git a/tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts b/tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts index 043fbe61012..238bd4f9d26 100644 --- a/tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts +++ b/tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts @@ -486,9 +486,28 @@ test.describe('Terminal raw emoji table scroll restore repro', () => { await sendToTerminal(orcaPage, ptyId, `printf ${JSON.stringify(`${marker}\\n`)}\r`) await waitForTerminalOutput(orcaPage, marker, 10_000) - const diagnostics = await readTerminalRenderDiagnostics(orcaPage) + const expectedWebgl = await expectAutoWebgl(orcaPage) + // Why: WebGL (re)attaches asynchronously via React visibility effects and a + // transient ESC[?25l during a redraw can momentarily set cursorHidden. Let + // those eventually-consistent fields settle before the single-shot golden + // asserts so runner timing can't flake-block the release. hasComplexScriptOutput + // stays single-shot: its not-ready default is also false, so timing can't + // turn it into a false failure. + let diagnostics = await readTerminalRenderDiagnostics(orcaPage) + await expect + .poll( + async () => { + diagnostics = await readTerminalRenderDiagnostics(orcaPage) + return diagnostics.hasWebgl === expectedWebgl && diagnostics.cursorHidden === false + }, + { + timeout: 15_000, + message: `terminal render diagnostics did not settle (expected hasWebgl=${expectedWebgl}, cursorHidden=false)` + } + ) + .toBe(true) expect(diagnostics.hasComplexScriptOutput).toBe(false) - expect(diagnostics.hasWebgl).toBe(await expectAutoWebgl(orcaPage)) + expect(diagnostics.hasWebgl).toBe(expectedWebgl) expect(diagnostics.cursorHidden).toBe(false) }) @@ -553,7 +572,26 @@ test.describe('Terminal raw emoji table scroll restore repro', () => { await scrollActiveTerminalToText(orcaPage, 'Singer') await closeFeatureTips(orcaPage) - const diagnostics = await readTerminalRenderDiagnostics(orcaPage) + const expectedWebgl = await expectAutoWebgl(orcaPage) + // Why: after the worktree switch, WebGL reattaches asynchronously (React + // visibility effect + attach backoff) and a transient ESC[?25l during the + // restore redraw can momentarily set cursorHidden. Let those settle before + // the single-shot golden asserts so runner timing can't flake-block the + // release; the geometry/wrap/overpaint checks below stay single-shot as the + // real regression signal. + let diagnostics = await readTerminalRenderDiagnostics(orcaPage) + await expect + .poll( + async () => { + diagnostics = await readTerminalRenderDiagnostics(orcaPage) + return diagnostics.hasWebgl === expectedWebgl && diagnostics.cursorHidden === false + }, + { + timeout: 15_000, + message: `terminal render diagnostics did not settle (expected hasWebgl=${expectedWebgl}, cursorHidden=false)` + } + ) + .toBe(true) const overpaint = await readTerminalRightEdgeOverpaint(orcaPage) const wrapDiagnostics = await readTerminalBoxTableWrapDiagnostics(orcaPage) const singerGeometry = await readVisibleSingerRowGeometry(orcaPage) @@ -579,7 +617,7 @@ test.describe('Terminal raw emoji table scroll restore repro', () => { expect(wrapDiagnostics.cols).toBeGreaterThanOrEqual(RAW_EMOJI_BOX_TABLE_WIDTH) expect(diagnostics.hasComplexScriptOutput).toBe(false) - expect(diagnostics.hasWebgl).toBe(await expectAutoWebgl(orcaPage)) + expect(diagnostics.hasWebgl).toBe(expectedWebgl) expect(diagnostics.cursorHidden).toBe(false) expect(overpaint.offenders).toEqual([]) expect(wrapDiagnostics.wrappedBoxLines).toEqual([]) diff --git a/tests/e2e/terminal-tui-wheel-drain.spec.ts b/tests/e2e/terminal-tui-wheel-drain.spec.ts new file mode 100644 index 00000000000..c228159852c --- /dev/null +++ b/tests/e2e/terminal-tui-wheel-drain.spec.ts @@ -0,0 +1,195 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + execInTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager +} from './helpers/terminal' + +const VISIBLE_TUI_FIXTURE_PATH = path.join( + process.cwd(), + 'tests/e2e/fixtures/visible-tui-scroll-fixture.cjs' +) + +// Reports must stop reaching the PTY shortly after the gesture ends. Before +// the input-write-queue coalescing fix, each SGR report drained in its own +// >=4ms-clamped macrotask turn; a dense trackpad stream delivered through the +// real input pipeline (which outprioritizes timers) plus TUI-scale redraw +// output starved that drain, and the backlog replayed for seconds after the +// fingers left the trackpad. +const MAX_ARRIVAL_LAG_MS = 900 +const WHEEL_EVENTS = 240 + +type WheelStreamResult = { + dispatchedEvents: number + inputEndWallClockMs: number +} + +async function startHeavyTuiFixture(page: Page, logPath: string): Promise { + await waitForSessionReady(page) + await waitForActiveWorktree(page) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) + await page.evaluate(() => + window.__store?.getState().updateSettings({ terminalTuiScrollSensitivity: 1 }) + ) + + const ptyId = await waitForActivePanePtyId(page) + await execInTerminal( + page, + ptyId, + `node ${JSON.stringify(VISIBLE_TUI_FIXTURE_PATH)} --heavy --log ${JSON.stringify(logPath)}` + ) + + await expect + .poll( + () => + page.evaluate(() => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + return pane?.terminal.element?.classList.contains('enable-mouse-events') ?? false + }), + { timeout: 15_000, message: 'fixture did not enable mouse reporting' } + ) + .toBe(true) +} + +async function terminalWheelTarget( + page: Page +): Promise<{ x: number; y: number; cellHeight: number }> { + return page.evaluate(() => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + const screen = pane?.terminal.element?.querySelector('.xterm-screen') + if (!pane?.terminal || !screen) { + throw new Error('Active terminal screen unavailable') + } + const rect = screen.getBoundingClientRect() + return { + x: rect.left + rect.width / 2, + y: rect.top + Math.min(rect.height - 1, 40), + cellHeight: + pane.terminal._core?._renderService?.dimensions?.css?.cell?.height ?? + rect.height / pane.terminal.rows + } + }) +} + +/** + * Drives real wheel input (CDP mouse events through the compositor input + * pipeline — the same priority class as physical trackpad input) rather than + * synthetic DOM dispatchEvent, so input can genuinely compete with the + * renderer's timer-based PTY input drain the way a physical gesture does. + */ +async function dispatchTrackpadWheelStream( + page: Page, + options: { alternate: boolean; events: number; deltaY: number } +): Promise { + const target = await terminalWheelTarget(page) + await page.mouse.move(target.x, target.y) + for (let i = 0; i < options.events; i += 1) { + const direction = options.alternate && Math.floor(i / 18) % 2 === 1 ? -1 : 1 + // No artificial sleep: CDP round-trips pace this near real trackpad rates + // while keeping the renderer's input queue continuously occupied. + await page.mouse.wheel(0, direction * options.deltaY) + } + const inputEndWallClockMs = await page.evaluate(() => Date.now()) + return { dispatchedEvents: options.events, inputEndWallClockMs } +} + +type ReportArrival = { atMs: number; reports: number } + +function readReportArrivalLog(logPath: string): ReportArrival[] { + if (!fs.existsSync(logPath)) { + return [] + } + return fs + .readFileSync(logPath, 'utf8') + .split('\n') + .filter(Boolean) + .map((line) => { + const [atMs, reports] = line.split(' ') + return { atMs: Number(atMs), reports: Number(reports) } + }) +} + +function summarizeArrivals( + arrivals: ReportArrival[], + input: WheelStreamResult +): { arrivalLagMs: number; chunks: number; totalReports: number } { + const totalReports = arrivals.reduce((sum, entry) => sum + entry.reports, 0) + const lastArrivalMs = arrivals.at(-1)?.atMs ?? 0 + return { + arrivalLagMs: lastArrivalMs - input.inputEndWallClockMs, + chunks: arrivals.length, + totalReports + } +} + +test.describe('terminal TUI wheel report drain', () => { + test('dense trackpad-like wheel stream reaches the PTY while the gesture happens', async ({ + orcaPage + }) => { + const logPath = path.join(os.tmpdir(), `tui-wheel-drain-${Date.now()}.log`) + await startHeavyTuiFixture(orcaPage, logPath) + + const target = await terminalWheelTarget(orcaPage) + const input = await dispatchTrackpadWheelStream(orcaPage, { + alternate: false, + events: WHEEL_EVENTS, + deltaY: Math.min(49, target.cellHeight) + }) + // Give a laggy drain ample time to expose itself before reading the log. + await orcaPage.waitForTimeout(8000) + + const summary = summarizeArrivals(readReportArrivalLog(logPath), input) + fs.rmSync(logPath, { force: true }) + console.log(`[tui-wheel-drain] dense: ${JSON.stringify(summary)}`) + + // The full gesture distance must reach the TUI (no dead/eaten scrolls)... + expect(summary.totalReports, JSON.stringify(summary)).toBeGreaterThanOrEqual(WHEEL_EVENTS - 10) + // ...while the gesture happens, not replayed 1-by-1 afterwards. + expect(summary.arrivalLagMs, JSON.stringify(summary)).toBeLessThanOrEqual(MAX_ARRIVAL_LAG_MS) + }) + + test('aggressive alternating trackpad-like gesture does not replay after input ends', async ({ + orcaPage + }) => { + const logPath = path.join(os.tmpdir(), `tui-wheel-drain-alt-${Date.now()}.log`) + await startHeavyTuiFixture(orcaPage, logPath) + + const target = await terminalWheelTarget(orcaPage) + const input = await dispatchTrackpadWheelStream(orcaPage, { + alternate: true, + events: WHEEL_EVENTS, + deltaY: Math.min(49, target.cellHeight) + }) + await orcaPage.waitForTimeout(8000) + + const summary = summarizeArrivals(readReportArrivalLog(logPath), input) + fs.rmSync(logPath, { force: true }) + console.log(`[tui-wheel-drain] alternate: ${JSON.stringify(summary)}`) + + expect(summary.arrivalLagMs, JSON.stringify(summary)).toBeLessThanOrEqual(MAX_ARRIVAL_LAG_MS) + }) +}) diff --git a/tests/e2e/terminal-tui-wheel-reports.spec.ts b/tests/e2e/terminal-tui-wheel-reports.spec.ts new file mode 100644 index 00000000000..a2718e2d0ab --- /dev/null +++ b/tests/e2e/terminal-tui-wheel-reports.spec.ts @@ -0,0 +1,421 @@ +import type { Page } from '@stablyai/playwright-test' +import path from 'node:path' +import { test, expect } from './helpers/orca-app' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + execInTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager +} from './helpers/terminal' + +type WheelReportSample = { + reportCount: number + reportDelta: number + reports: string[] +} + +type TimedWheelReportSample = WheelReportSample & { + elapsedMs: number +} + +const PHYSICAL_MOUSE_WHEEL_DELTA = -120 +const VISIBLE_TUI_FIXTURE_PATH = path.join( + process.cwd(), + 'tests/e2e/fixtures/visible-tui-scroll-fixture.cjs' +) + +async function probeSmallMouseWheelReports( + page: Page, + ticks: number +): Promise { + return page.evaluate( + async ({ tickCount, physicalMouseWheelDelta }) => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!pane?.terminal.element) { + throw new Error('Active terminal pane unavailable') + } + + const reports: string[] = [] + const disposable = pane.terminal.onData((data) => reports.push(data)) + try { + await new Promise((resolve) => pane.terminal.write('\x1b[?1003h\x1b[?1006h', resolve)) + await new Promise((resolve) => requestAnimationFrame(() => resolve(undefined))) + if (!pane.terminal.element.classList.contains('enable-mouse-events')) { + throw new Error('Mouse reporting mode did not activate') + } + + const screen = pane.terminal.element.querySelector('.xterm-screen') + if (!screen) { + throw new Error('Active terminal screen unavailable') + } + + const rect = screen.getBoundingClientRect() + const cellHeight = + pane.terminal._core?._renderService?.dimensions?.css?.cell?.height ?? + rect.height / pane.terminal.rows + const scrollSensitivity = Number(pane.terminal.options.scrollSensitivity ?? 1) + // Why: this is a notched mouse wheel event that Chromium can surface as a + // small pixel delta; xterm's <50px damping accumulates it for four ticks. + const deltaY = (cellHeight * 0.28) / (scrollSensitivity * 0.3) + const samples: WheelReportSample[] = [] + + for (let i = 0; i < tickCount; i += 1) { + const before = reports.length + const event = new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + clientX: rect.left + rect.width / 2, + clientY: rect.top + Math.min(rect.height - 1, cellHeight * 4), + deltaMode: WheelEvent.DOM_DELTA_PIXEL, + deltaY + }) + Object.defineProperty(event, 'wheelDeltaY', { + configurable: true, + value: physicalMouseWheelDelta + }) + Object.defineProperty(event, 'wheelDelta', { + configurable: true, + value: physicalMouseWheelDelta + }) + pane.terminal.element.dispatchEvent(event) + await new Promise((resolve) => setTimeout(resolve, 0)) + samples.push({ + reportCount: reports.length, + reportDelta: reports.length - before, + reports: [...reports] + }) + } + + return samples + } finally { + disposable.dispose() + } + }, + { tickCount: ticks, physicalMouseWheelDelta: PHYSICAL_MOUSE_WHEEL_DELTA } + ) +} + +async function probeTimedSmallMouseWheelReports( + page: Page, + options: { + drainWaitMs: number + intervalMs: number + ticks: number + } +): Promise<{ + reports: string[] + samples: TimedWheelReportSample[] +}> { + return page.evaluate( + async ({ drainWaitMs, intervalMs, physicalMouseWheelDelta, tickCount }) => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!pane?.terminal.element) { + throw new Error('Active terminal pane unavailable') + } + + const reports: string[] = [] + const disposable = pane.terminal.onData((data) => reports.push(data)) + try { + await new Promise((resolve) => pane.terminal.write('\x1b[?1003h\x1b[?1006h', resolve)) + await new Promise((resolve) => requestAnimationFrame(() => resolve(undefined))) + if (!pane.terminal.element.classList.contains('enable-mouse-events')) { + throw new Error('Mouse reporting mode did not activate') + } + + const screen = pane.terminal.element.querySelector('.xterm-screen') + if (!screen) { + throw new Error('Active terminal screen unavailable') + } + + const rect = screen.getBoundingClientRect() + const cellHeight = + pane.terminal._core?._renderService?.dimensions?.css?.cell?.height ?? + rect.height / pane.terminal.rows + const scrollSensitivity = Number(pane.terminal.options.scrollSensitivity ?? 1) + // Why: this is a notched mouse wheel event that Chromium can surface as a + // small pixel delta; xterm's <50px damping accumulates it for four ticks. + const deltaY = (cellHeight * 0.28) / (scrollSensitivity * 0.3) + const samples: TimedWheelReportSample[] = [] + const startedAt = performance.now() + + for (let i = 0; i < tickCount; i += 1) { + const before = reports.length + const event = new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + clientX: rect.left + rect.width / 2, + clientY: rect.top + Math.min(rect.height - 1, cellHeight * 4), + deltaMode: WheelEvent.DOM_DELTA_PIXEL, + deltaY + }) + Object.defineProperty(event, 'wheelDeltaY', { + configurable: true, + value: physicalMouseWheelDelta + }) + Object.defineProperty(event, 'wheelDelta', { + configurable: true, + value: physicalMouseWheelDelta + }) + pane.terminal.element.dispatchEvent(event) + await new Promise((resolve) => setTimeout(resolve, intervalMs)) + samples.push({ + elapsedMs: performance.now() - startedAt, + reportCount: reports.length, + reportDelta: reports.length - before, + reports: [...reports] + }) + } + + await new Promise((resolve) => setTimeout(resolve, drainWaitMs)) + + return { + reports: [...reports], + samples + } + } finally { + disposable.dispose() + } + }, + { + drainWaitMs: options.drainWaitMs, + intervalMs: options.intervalMs, + physicalMouseWheelDelta: PHYSICAL_MOUSE_WHEEL_DELTA, + tickCount: options.ticks + } + ) +} + +async function readVisibleTuiOffset(page: Page): Promise { + return page.evaluate(() => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!pane?.terminal) { + return null + } + + for (let row = 0; row < pane.terminal.rows; row += 1) { + const text = pane.terminal.buffer.active.getLine(row)?.translateToString(true) ?? '' + const match = /TUI_SCROLL_ROW_(\d+)/.exec(text) + if (match) { + return Number(match[1]) + } + } + return null + }) +} + +async function dispatchTuiWheel( + page: Page, + options: { + deltaY: number + wheelDeltaY?: number + } +): Promise { + await page.evaluate(({ deltaY, wheelDeltaY }) => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!pane?.terminal.element) { + throw new Error('Active terminal pane unavailable') + } + + const screen = pane.terminal.element.querySelector('.xterm-screen') + if (!screen) { + throw new Error('Active terminal screen unavailable') + } + const rect = screen.getBoundingClientRect() + const event = new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + clientX: rect.left + rect.width / 2, + clientY: rect.top + Math.min(rect.height - 1, 40), + deltaMode: WheelEvent.DOM_DELTA_PIXEL, + deltaY + }) + if (wheelDeltaY !== undefined) { + Object.defineProperty(event, 'wheelDeltaY', { + configurable: true, + value: wheelDeltaY + }) + Object.defineProperty(event, 'wheelDelta', { + configurable: true, + value: wheelDeltaY + }) + } + pane.terminal.element.dispatchEvent(event) + }, options) +} + +test.describe('terminal TUI wheel reports', () => { + test('notched mouse wheel ticks produce immediate mouse-reporting TUI scroll reports', async ({ + orcaPage + }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + await orcaPage.evaluate(() => + window.__store?.getState().updateSettings({ terminalTuiScrollSensitivity: 1 }) + ) + + const samples = await probeSmallMouseWheelReports(orcaPage, 4) + + expect( + samples.map((sample) => sample.reportDelta), + `per-tick SGR mouse reports: ${JSON.stringify(samples)}` + ).toEqual([1, 1, 1, 1]) + expect(samples.at(-1)?.reports.join('')).toContain('\x1b[<65;') + }) + + test('fullscreen mouse-reporting TUI scroll distance follows wheel magnitude @headful', async ({ + electronApp, + orcaPage + }) => { + await electronApp.evaluate(({ BrowserWindow }) => { + const win = BrowserWindow.getAllWindows()[0] + if (!win) { + throw new Error('No BrowserWindow available') + } + if (win.isMinimized()) { + win.restore() + } + win.show() + win.focus() + win.setFullScreen(true) + }) + await expect + .poll(() => + electronApp.evaluate( + ({ BrowserWindow }) => BrowserWindow.getAllWindows()[0]?.isFullScreen() ?? false + ) + ) + .toBe(true) + await orcaPage.waitForTimeout(1200) + + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + await orcaPage.evaluate(() => + window.__store?.getState().updateSettings({ terminalTuiScrollSensitivity: 1 }) + ) + + const ptyId = await waitForActivePanePtyId(orcaPage) + await execInTerminal(orcaPage, ptyId, `node ${JSON.stringify(VISIBLE_TUI_FIXTURE_PATH)}`) + + await expect + .poll(() => readVisibleTuiOffset(orcaPage), { + timeout: 10_000, + message: 'visible fullscreen TUI did not render numbered rows' + }) + .toBe(0) + + await dispatchTuiWheel(orcaPage, { + deltaY: 10, + wheelDeltaY: PHYSICAL_MOUSE_WHEEL_DELTA + }) + await expect + .poll(() => readVisibleTuiOffset(orcaPage), { + timeout: 5_000, + message: 'single notched wheel tick did not visibly scroll the TUI' + }) + .toBe(1) + + const cellHeight = await orcaPage.evaluate(() => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + const screen = pane?.terminal.element?.querySelector('.xterm-screen') + if (!pane?.terminal || !screen) { + throw new Error('Active terminal screen unavailable') + } + return screen.getBoundingClientRect().height / pane.terminal.rows + }) + + await dispatchTuiWheel(orcaPage, { + deltaY: cellHeight * 12, + wheelDeltaY: PHYSICAL_MOUSE_WHEEL_DELTA * 12 + }) + + await expect + .poll(() => readVisibleTuiOffset(orcaPage), { + timeout: 5_000, + message: 'larger wheel movement did not visibly move the TUI farther' + }) + .toBe(7) + }) + + test('TUI scroll setting scales notched mouse wheel reports', async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + await orcaPage.evaluate(() => + window.__store?.getState().updateSettings({ terminalTuiScrollSensitivity: 5 }) + ) + + const slow = await probeTimedSmallMouseWheelReports(orcaPage, { + drainWaitMs: 120, + intervalMs: 220, + ticks: 5 + }) + await orcaPage.waitForTimeout(220) + const paced = await probeTimedSmallMouseWheelReports(orcaPage, { + drainWaitMs: 220, + intervalMs: 80, + ticks: 5 + }) + + expect( + slow.samples.map((sample) => sample.reportDelta), + `slow per-tick SGR mouse reports: ${JSON.stringify(slow.samples)}` + ).toEqual([5, 5, 5, 5, 5]) + expect( + paced.samples.map((sample) => sample.reportDelta), + `paced per-tick SGR mouse reports: ${JSON.stringify(paced.samples)}` + ).toEqual([5, 5, 5, 5, 5]) + expect(paced.reports.length, `paced SGR mouse reports: ${JSON.stringify(paced.samples)}`).toBe( + 25 + ) + }) +}) diff --git a/tests/e2e/worktree-scroll-to-current.spec.ts b/tests/e2e/worktree-scroll-to-current.spec.ts index 2cbd0df4a08..91ae5e88142 100644 --- a/tests/e2e/worktree-scroll-to-current.spec.ts +++ b/tests/e2e/worktree-scroll-to-current.spec.ts @@ -1,11 +1,6 @@ import type { Page } from '@stablyai/playwright-test' import { test, expect } from './helpers/orca-app' import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' -import { worktreeRow } from './worktree-row-locators' - -function worktreeOption(page: Page, worktreeId: string) { - return worktreeRow(page, worktreeId) -} async function prepareSidebarForScrollTest(page: Page): Promise { await page.evaluate(() => { @@ -117,7 +112,9 @@ test.describe('Reveal active workspace button', () => { throw new Error('Bottom workspace row did not expose a data-worktree-id') } - const targetRow = worktreeOption(orcaPage, targetId) + const targetRow = orcaPage + .locator(`[data-worktree-sidebar] [data-worktree-id=${JSON.stringify(targetId)}]`) + .first() const revealButton = orcaPage.getByRole('button', { name: 'Reveal active workspace' }) await renderedOptions.last().click() @@ -175,10 +172,31 @@ test.describe('Reveal active workspace button', () => { throw new Error('Bottom workspace row did not expose a data-worktree-id') } - const targetRow = worktreeOption(orcaPage, targetId) + const targetRows = orcaPage.locator( + `[data-worktree-sidebar] [data-worktree-id=${JSON.stringify(targetId)}]` + ) + const targetRow = targetRows.first() const revealButton = orcaPage.getByRole('button', { name: 'Reveal active workspace' }) - await renderedOptions.last().click() + await orcaPage.evaluate((targetId) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const state = store.getState() + const target = Object.values(state.worktreesByRepo) + .flat() + .find((worktree) => worktree.id === targetId) + if (!target) { + throw new Error(`Target workspace ${targetId} not found`) + } + store.setState({ + activeRepoId: target.repoId, + activeWorktreeId: target.id, + activeWorkspaceKey: `worktree:${target.id}`, + pendingRevealWorktree: null + }) + }, targetId) await expect(targetRow).toHaveAttribute('aria-current', 'page') await orcaPage.evaluate(() => { @@ -190,7 +208,7 @@ test.describe('Reveal active workspace button', () => { }) await expect(renderedOptions).toHaveCount(0) - await expect(orcaPage.getByText('No workspaces found')).toBeVisible() + await expect(targetRows).toHaveCount(0) await revealButton.click() @@ -213,4 +231,232 @@ test.describe('Reveal active workspace button', () => { ) .toEqual([]) }) + + test('reveals the current workspace when it starts outside the virtualized window', async ({ + orcaPage + }) => { + await prepareSidebarForScrollTest(orcaPage) + + const targetId = await orcaPage.evaluate(() => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + + const state = store.getState() + const repo = state.repos[0] + if (!repo) { + throw new Error('Expected a seeded e2e repo') + } + + const now = Date.now() + const seededWorktrees = state.worktreesByRepo[repo.id] ?? [] + const syntheticWorktrees = Array.from({ length: 60 }, (_, index) => { + const suffix = String(index).padStart(2, '0') + return { + id: `${repo.id}::/virtual-reveal-${suffix}`, + instanceId: `virtual-reveal-${suffix}`, + repoId: repo.id, + path: `${repo.path}/../virtual-reveal-${suffix}`, + displayName: `Virtual reveal ${suffix}`, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 10_000 - index, + lastActivityAt: now - index - 100, + head: '0000000000000000000000000000000000000000', + branch: `virtual-reveal-${suffix}`, + isBare: false, + isMainWorktree: false + } + }) + const target = syntheticWorktrees.at(-1) + if (!target) { + throw new Error('Expected a synthetic target worktree') + } + + store.setState({ + activeRepoId: repo.id, + activeWorktreeId: target.id, + activeWorkspaceKey: `worktree:${target.id}`, + pendingRevealWorktree: null, + sortBy: 'manual', + worktreesByRepo: { + ...state.worktreesByRepo, + [repo.id]: [...seededWorktrees, ...syntheticWorktrees] + } + }) + return target.id + }) + + const scroller = orcaPage.locator('[data-worktree-sidebar]') + await expect + .poll( + () => + orcaPage.evaluate(() => { + const scroller = document.querySelector('[data-worktree-sidebar]') + return scroller?.scrollTop ?? null + }), + { timeout: 10_000, message: 'sidebar scroller did not mount' } + ) + .not.toBeNull() + + await scroller.evaluate((element) => { + element.scrollTop = 0 + element.dispatchEvent(new Event('scroll', { bubbles: true })) + }) + await expect + .poll( + () => + orcaPage.evaluate((targetId) => { + const scroller = document.querySelector('[data-worktree-sidebar]') + const target = [...document.querySelectorAll('[data-worktree-id]')].find( + (candidate) => candidate.dataset.worktreeId === targetId + ) + if (!scroller || !target) { + return false + } + const scrollerBounds = scroller.getBoundingClientRect() + const targetBounds = target.getBoundingClientRect() + return ( + targetBounds.top >= scrollerBounds.top - 1 && + targetBounds.bottom <= scrollerBounds.bottom + 1 + ) + }, targetId), + { timeout: 10_000, message: 'target workspace should start outside the sidebar viewport' } + ) + .toBe(false) + + const revealButton = orcaPage.getByRole('button', { name: 'Reveal active workspace' }) + await expect(revealButton).toBeVisible() + await expect(revealButton).toBeEnabled() + + await revealButton.click() + const targetRow = orcaPage + .locator(`[data-worktree-sidebar] [data-worktree-id=${JSON.stringify(targetId)}]`) + .first() + await expect(targetRow).toBeVisible() + await expect(targetRow).toHaveAttribute('data-scroll-reveal-highlight', 'true') + }) + + test('uses the active workspace key when the legacy active worktree id is not set', async ({ + orcaPage + }) => { + await prepareSidebarForScrollTest(orcaPage) + + const folderWorktreeId = await orcaPage.evaluate(() => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + + const now = Date.now() + const folderWorkspaces = Array.from({ length: 36 }, (_, index) => { + const suffix = String(index).padStart(2, '0') + return { + id: `reveal-folder-workspace-${suffix}`, + projectGroupId: 'reveal-folder-group', + name: `Reveal folder workspace ${suffix}`, + folderPath: `/tmp/reveal-folder-workspace-${suffix}`, + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 1_000 - index, + lastActivityAt: now - index, + createdAt: now, + updatedAt: now + } + }) + const targetFolder = folderWorkspaces.at(-1) + if (!targetFolder) { + throw new Error('Expected a target folder workspace') + } + const folderWorktreeId = `folder:${targetFolder.id}` + store.setState({ + activeRepoId: null, + activeWorktreeId: null, + activeWorkspaceKey: folderWorktreeId, + projectGroups: [ + { + id: 'reveal-folder-group', + name: 'Reveal folder group', + parentPath: '/tmp/reveal-folder-group', + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 1, + isCollapsed: false, + color: null, + createdAt: now, + updatedAt: now + } + ], + folderWorkspaces + }) + store.getState().setGroupBy('repo') + return folderWorktreeId + }) + + const scroller = orcaPage.locator('[data-worktree-sidebar]') + await scroller.evaluate((element) => { + element.scrollTop = 0 + element.dispatchEvent(new Event('scroll', { bubbles: true })) + }) + await expect + .poll( + () => + orcaPage.evaluate((targetId) => { + const scroller = document.querySelector('[data-worktree-sidebar]') + const target = [...document.querySelectorAll('[data-worktree-id]')].find( + (candidate) => candidate.dataset.worktreeId === targetId + ) + if (!scroller || !target) { + return false + } + const scrollerBounds = scroller.getBoundingClientRect() + const targetBounds = target.getBoundingClientRect() + return ( + targetBounds.top >= scrollerBounds.top - 1 && + targetBounds.bottom <= scrollerBounds.bottom + 1 + ) + }, folderWorktreeId), + { timeout: 10_000, message: 'target folder workspace should start outside the viewport' } + ) + .toBe(false) + + const revealButton = orcaPage.getByRole('button', { name: 'Reveal active workspace' }) + await expect(revealButton).toBeVisible() + await expect(revealButton).toBeEnabled() + await revealButton.click() + + await expect + .poll( + () => + orcaPage.evaluate((targetId) => { + const scroller = document.querySelector('[data-worktree-sidebar]') + const target = [...document.querySelectorAll('[data-worktree-id]')].find( + (candidate) => candidate.dataset.worktreeId === targetId + ) + if (!scroller || !target) { + return false + } + const scrollerBounds = scroller.getBoundingClientRect() + const targetBounds = target.getBoundingClientRect() + return ( + targetBounds.top >= scrollerBounds.top - 1 && + targetBounds.bottom <= scrollerBounds.bottom + 1 + ) + }, folderWorktreeId), + { + timeout: 10_000, + message: 'Reveal button should scroll to the active folder workspace' + } + ) + .toBe(true) + }) }) diff --git a/tools/benchmarks/results/terminal-perf-baseline-2026-07-02T07-23-46-933Z.json b/tools/benchmarks/results/terminal-perf-baseline-2026-07-02T07-23-46-933Z.json new file mode 100644 index 00000000000..87d8634572f --- /dev/null +++ b/tools/benchmarks/results/terminal-perf-baseline-2026-07-02T07-23-46-933Z.json @@ -0,0 +1,1041 @@ +{ + "label": "baseline", + "startedAt": "2026-07-02T07:23:46.933Z", + "platform": "win32 10.0.26200", + "shell": "default", + "args": { + "iterations": 8, + "switches": 24, + "cycles": 12 + }, + "scenarios": { + "tab-create": { + "samples": [ + { + "index": 0, + "storeCreateMs": 2.700000047683716, + "ptyBindMs": 5853.5, + "firstOutputMs": 5875.900000035763, + "paintSettleMs": 5878.100000023842, + "timedOut": false + }, + { + "index": 1, + "storeCreateMs": 1.9000000357627869, + "ptyBindMs": 476.9000000357628, + "firstOutputMs": 489.10000002384186, + "paintSettleMs": 493.10000002384186, + "timedOut": false + }, + { + "index": 2, + "storeCreateMs": 1.9000000357627869, + "ptyBindMs": 381.60000002384186, + "firstOutputMs": 401, + "paintSettleMs": 404.10000002384186, + "timedOut": false + }, + { + "index": 3, + "storeCreateMs": 1.300000011920929, + "ptyBindMs": 391.10000002384186, + "firstOutputMs": 405.80000001192093, + "paintSettleMs": 408.10000002384186, + "timedOut": false + }, + { + "index": 4, + "storeCreateMs": 1.5, + "ptyBindMs": 454, + "firstOutputMs": 467.80000001192093, + "paintSettleMs": 474, + "timedOut": false + }, + { + "index": 5, + "storeCreateMs": 2, + "ptyBindMs": 527.6999999880791, + "firstOutputMs": 544.5, + "paintSettleMs": 551.3999999761581, + "timedOut": false + }, + { + "index": 6, + "storeCreateMs": 1.9000000357627869, + "ptyBindMs": 483.60000002384186, + "firstOutputMs": 498.5, + "paintSettleMs": 501.10000002384186, + "timedOut": false + }, + { + "index": 7, + "storeCreateMs": 1.600000023841858, + "ptyBindMs": 492.89999997615814, + "firstOutputMs": 509.19999998807907, + "paintSettleMs": 517, + "timedOut": false + } + ], + "longtasks": [ + 411, 65, 184, 124, 191, 163, 141, 183, 59, 158, 217, 61, 61, 253, 89, 70, 248, 152, 73, 170, + 184, 160, 178, 176, 151 + ] + }, + "tab-switch": { + "samples": [ + { + "index": 0, + "targetTabId": "3db20953-661a-4e65-8e4c-ca6a6f9d0538", + "storeMs": 1.100000023841858, + "paneVisibleMs": 1.100000023841858, + "paintSettleMs": 109, + "timedOut": false + }, + { + "index": 1, + "targetTabId": "f4dca755-e41c-4f03-aa25-ed67526ca90e", + "storeMs": 1.699999988079071, + "paneVisibleMs": 1.699999988079071, + "paintSettleMs": 114.5, + "timedOut": false + }, + { + "index": 2, + "targetTabId": "8f40ca49-476c-4959-ae9f-f976293e0b62", + "storeMs": 1.699999988079071, + "paneVisibleMs": 1.699999988079071, + "paintSettleMs": 138.5, + "timedOut": false + }, + { + "index": 3, + "targetTabId": "d7adfbbb-a7eb-48ed-876b-440835e7174f", + "storeMs": 1.100000023841858, + "paneVisibleMs": 1.100000023841858, + "paintSettleMs": 73, + "timedOut": false + }, + { + "index": 4, + "targetTabId": "3db20953-661a-4e65-8e4c-ca6a6f9d0538", + "storeMs": 1.800000011920929, + "paneVisibleMs": 1.800000011920929, + "paintSettleMs": 79.60000002384186, + "timedOut": false + }, + { + "index": 5, + "targetTabId": "f4dca755-e41c-4f03-aa25-ed67526ca90e", + "storeMs": 2, + "paneVisibleMs": 2, + "paintSettleMs": 43.80000001192093, + "timedOut": false + }, + { + "index": 6, + "targetTabId": "8f40ca49-476c-4959-ae9f-f976293e0b62", + "storeMs": 2.5, + "paneVisibleMs": 2.5, + "paintSettleMs": 125.89999997615814, + "timedOut": false + }, + { + "index": 7, + "targetTabId": "d7adfbbb-a7eb-48ed-876b-440835e7174f", + "storeMs": 1.699999988079071, + "paneVisibleMs": 1.699999988079071, + "paintSettleMs": 116.5, + "timedOut": false + }, + { + "index": 8, + "targetTabId": "3db20953-661a-4e65-8e4c-ca6a6f9d0538", + "storeMs": 1.399999976158142, + "paneVisibleMs": 1.399999976158142, + "paintSettleMs": 51.80000001192093, + "timedOut": false + }, + { + "index": 9, + "targetTabId": "f4dca755-e41c-4f03-aa25-ed67526ca90e", + "storeMs": 1, + "paneVisibleMs": 1, + "paintSettleMs": 45.19999998807907, + "timedOut": false + }, + { + "index": 10, + "targetTabId": "8f40ca49-476c-4959-ae9f-f976293e0b62", + "storeMs": 1.600000023841858, + "paneVisibleMs": 1.600000023841858, + "paintSettleMs": 50.30000001192093, + "timedOut": false + }, + { + "index": 11, + "targetTabId": "d7adfbbb-a7eb-48ed-876b-440835e7174f", + "storeMs": 1.399999976158142, + "paneVisibleMs": 1.399999976158142, + "paintSettleMs": 97.89999997615814, + "timedOut": false + }, + { + "index": 12, + "targetTabId": "3db20953-661a-4e65-8e4c-ca6a6f9d0538", + "storeMs": 1.0999999642372131, + "paneVisibleMs": 1.0999999642372131, + "paintSettleMs": 112.5, + "timedOut": false + }, + { + "index": 13, + "targetTabId": "f4dca755-e41c-4f03-aa25-ed67526ca90e", + "storeMs": 2.100000023841858, + "paneVisibleMs": 2.100000023841858, + "paintSettleMs": 48.69999998807907, + "timedOut": false + }, + { + "index": 14, + "targetTabId": "8f40ca49-476c-4959-ae9f-f976293e0b62", + "storeMs": 1.399999976158142, + "paneVisibleMs": 1.5, + "paintSettleMs": 35.09999996423721, + "timedOut": false + }, + { + "index": 15, + "targetTabId": "d7adfbbb-a7eb-48ed-876b-440835e7174f", + "storeMs": 1, + "paneVisibleMs": 1, + "paintSettleMs": 28.30000001192093, + "timedOut": false + }, + { + "index": 16, + "targetTabId": "3db20953-661a-4e65-8e4c-ca6a6f9d0538", + "storeMs": 0.8999999761581421, + "paneVisibleMs": 0.8999999761581421, + "paintSettleMs": 35.09999996423721, + "timedOut": false + }, + { + "index": 17, + "targetTabId": "f4dca755-e41c-4f03-aa25-ed67526ca90e", + "storeMs": 1.300000011920929, + "paneVisibleMs": 1.300000011920929, + "paintSettleMs": 150.70000004768372, + "timedOut": false + }, + { + "index": 18, + "targetTabId": "8f40ca49-476c-4959-ae9f-f976293e0b62", + "storeMs": 1.9000000357627869, + "paneVisibleMs": 1.9000000357627869, + "paintSettleMs": 95.60000002384186, + "timedOut": false + }, + { + "index": 19, + "targetTabId": "d7adfbbb-a7eb-48ed-876b-440835e7174f", + "storeMs": 2.099999964237213, + "paneVisibleMs": 2.099999964237213, + "paintSettleMs": 75.79999995231628, + "timedOut": false + }, + { + "index": 20, + "targetTabId": "3db20953-661a-4e65-8e4c-ca6a6f9d0538", + "storeMs": 1.2000000476837158, + "paneVisibleMs": 1.2000000476837158, + "paintSettleMs": 40.30000001192093, + "timedOut": false + }, + { + "index": 21, + "targetTabId": "f4dca755-e41c-4f03-aa25-ed67526ca90e", + "storeMs": 1.5999999642372131, + "paneVisibleMs": 1.5999999642372131, + "paintSettleMs": 30.099999964237213, + "timedOut": false + }, + { + "index": 22, + "targetTabId": "8f40ca49-476c-4959-ae9f-f976293e0b62", + "storeMs": 1.399999976158142, + "paneVisibleMs": 1.399999976158142, + "paintSettleMs": 86.89999997615814, + "timedOut": false + }, + { + "index": 23, + "targetTabId": "d7adfbbb-a7eb-48ed-876b-440835e7174f", + "storeMs": 1.300000011920929, + "paneVisibleMs": 1.300000011920929, + "paintSettleMs": 133, + "timedOut": false + } + ], + "longtasks": [98, 109, 130, 66, 71, 117, 110, 94, 100, 144, 91, 64, 82, 122] + }, + "workspace-switch": { + "samples": [ + { + "index": 0, + "worktreeId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/repo", + "ptyId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/repo@@f226ee15", + "activationMs": 202, + "ptyBindMs": 204, + "paintSettleMs": 211 + }, + { + "index": 1, + "worktreeId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-one", + "ptyId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-one@@d00bc639", + "activationMs": 406, + "ptyBindMs": 550, + "paintSettleMs": 563 + }, + { + "index": 2, + "worktreeId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-two", + "ptyId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-two@@e12f992a", + "activationMs": 373, + "ptyBindMs": 476, + "paintSettleMs": 488 + }, + { + "index": 3, + "worktreeId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/repo", + "ptyId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/repo@@f226ee15", + "activationMs": 205, + "ptyBindMs": 251, + "paintSettleMs": 258 + }, + { + "index": 4, + "worktreeId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-one", + "ptyId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-one@@d00bc639", + "activationMs": 345, + "ptyBindMs": 369, + "paintSettleMs": 379 + }, + { + "index": 5, + "worktreeId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-two", + "ptyId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-two@@e12f992a", + "activationMs": 149, + "ptyBindMs": 177, + "paintSettleMs": 184 + }, + { + "index": 6, + "worktreeId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/repo", + "ptyId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/repo@@f226ee15", + "activationMs": 147, + "ptyBindMs": 162, + "paintSettleMs": 170 + }, + { + "index": 7, + "worktreeId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-one", + "ptyId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-one@@d00bc639", + "activationMs": 178, + "ptyBindMs": 202, + "paintSettleMs": 208 + }, + { + "index": 8, + "worktreeId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-two", + "ptyId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-two@@e12f992a", + "activationMs": 336, + "ptyBindMs": 366, + "paintSettleMs": 375 + }, + { + "index": 9, + "worktreeId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/repo", + "ptyId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/repo@@f226ee15", + "activationMs": 157, + "ptyBindMs": 192, + "paintSettleMs": 200 + }, + { + "index": 10, + "worktreeId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-one", + "ptyId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-one@@d00bc639", + "activationMs": 140, + "ptyBindMs": 164, + "paintSettleMs": 173 + }, + { + "index": 11, + "worktreeId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-two", + "ptyId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-two@@e12f992a", + "activationMs": 159, + "ptyBindMs": 188, + "paintSettleMs": 199 + } + ] + } + }, + "summaries": [ + { + "name": "tab-create", + "samples": 8, + "timedOut": 0, + "fields": { + "storeCreateMs": { + "count": 8, + "median": 2, + "p95": 3, + "max": 3 + }, + "ptyBindMs": { + "count": 8, + "median": 484, + "p95": 5854, + "max": 5854 + }, + "firstOutputMs": { + "count": 8, + "median": 499, + "p95": 5876, + "max": 5876 + }, + "paintSettleMs": { + "count": 8, + "median": 501, + "p95": 5878, + "max": 5878 + } + }, + "longtaskCount": 25, + "longtaskMaxMs": 411 + }, + { + "name": "tab-switch", + "samples": 24, + "timedOut": 0, + "fields": { + "storeMs": { + "count": 24, + "median": 1, + "p95": 2, + "max": 3 + }, + "paneVisibleMs": { + "count": 24, + "median": 2, + "p95": 2, + "max": 3 + }, + "paintSettleMs": { + "count": 24, + "median": 80, + "p95": 139, + "max": 151 + } + }, + "longtaskCount": 14, + "longtaskMaxMs": 144 + }, + { + "name": "workspace-switch", + "samples": 12, + "timedOut": 0, + "fields": { + "activationMs": { + "count": 12, + "median": 202, + "p95": 373, + "max": 406 + }, + "ptyBindMs": { + "count": 12, + "median": 204, + "p95": 476, + "max": 550 + }, + "paintSettleMs": { + "count": 12, + "median": 211, + "p95": 488, + "max": 563 + } + }, + "longtaskCount": 0, + "longtaskMaxMs": 0 + } + ], + "finalDiagnostics": { + "hasStore": true, + "workspaceSessionReady": true, + "hydrationSucceeded": true, + "activeView": "terminal", + "activeRepoId": "f93c43c3-57ef-4811-8edc-73d924918ed8", + "activeWorktreeId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-two", + "activeTabType": "terminal", + "activeTabId": "2a067075-6acf-4aca-a0a6-bc88c1e6abc7", + "terminalGpuAcceleration": "auto", + "repoCount": 1, + "worktreeCountsByRepo": { + "f93c43c3-57ef-4811-8edc-73d924918ed8": 3 + }, + "ptyIdsByTabId": [ + "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-two@@e12f992a" + ], + "paneManagerCount": 3, + "activePane": { + "id": 1, + "leafId": "6a3ca793-864f-4182-bec5-5b044a884b9b", + "ptyId": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-two@@e12f992a", + "cols": 175, + "rows": 73, + "buffer": { + "baseY": 0, + "viewportY": 0, + "cursorY": 0, + "length": 73 + } + }, + "renderingDiagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": false, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": true + } + ], + "allPaneManagersDiagnostics": [ + { + "tabId": "ab3a14da-e087-4677-9fa8-df990c149951", + "diagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": true, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": false + } + ] + }, + { + "tabId": "afdd9731-982a-4255-8c0e-b4360453f433", + "diagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": true, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": false + } + ] + }, + { + "tabId": "2a067075-6acf-4aca-a0a6-bc88c1e6abc7", + "diagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": false, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": true + } + ] + } + ], + "webglContextCounts": { + "attachedWebglCount": 1, + "deferredWebglCount": 2, + "managerCount": 3, + "paneCount": 3 + }, + "webglIdentity": { + "available": true, + "vendor": "Google Inc. (Intel)", + "renderer": "ANGLE (Intel, Intel(R) Arc(TM) Graphics (0x00007D55) Direct3D11 vs_5_0 ps_5_0, D3D11)" + }, + "rendererProbe": { + "intervalMs": 50, + "last": 50268.60000002384, + "maxDriftMs": 440.10000002384186, + "samples": 335, + "startedAt": 25060.600000023842, + "lastTickAt": 50268.60000002384 + }, + "ptySessions": { + "value": [ + { + "id": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/repo@@f226ee15", + "cwd": "", + "title": "shell" + }, + { + "id": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-one@@d00bc639", + "cwd": "", + "title": "shell" + }, + { + "id": "f93c43c3-57ef-4811-8edc-73d924918ed8::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-uLA6Xx/wt-two@@e12f992a", + "cwd": "", + "title": "shell" + } + ] + }, + "rendererDeliveryDebug": { + "value": { + "pendingPtyCount": 0, + "pendingChars": 0, + "maxPendingCharsByPty": 0, + "rendererInFlightPtyCount": 0, + "rendererInFlightChars": 0, + "maxRendererInFlightCharsByPty": 0, + "activeRendererPtyCount": 1, + "flushScheduled": false, + "peakPendingChars": 82, + "peakMaxPendingCharsByPty": 82, + "peakRendererInFlightChars": 82, + "peakMaxRendererInFlightCharsByPty": 82, + "ackGatedFlushSkipCount": 0 + } + } + }, + "cleanupErrors": [], + "elapsedMs": 71875, + "appLogsTail": [ + { + "source": "stderr", + "line": "https://electronjs.org/docs/tutorial/security.", + "at": 1782977071571 + }, + { + "source": "stderr", + "line": "This warning will not show up", + "at": 1782977071571 + }, + { + "source": "stderr", + "line": "once the app is packaged.\", source: node:electron/js2c/sandbox_bundle (2)", + "at": 1782977071571 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-settings-done t=30952 rendererT=23288 durationMs=454", + "at": 1782977071792 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-settings-done t=31069 rendererT=23405 durationMs=533", + "at": 1782977071909 + }, + { + "source": "stderr", + "line": "[50192:0702/032432.013:INFO:CONSOLE:1078] \"%cReact Grab v0.1.34%c", + "at": 1782977072013 + }, + { + "source": "stderr", + "line": "https://react-grab.com background: #330039; color: #ffffff; border: 1px solid #d75fcb; padding: 4px 4px 4px 24px; border-radius: 4px; background-image: url(\"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9Im5vbmUiIHZpZXdCb3g9IjAgMCAyOTQgMjk0Ij48cGF0aCBmaWxsPSIjZmY0MGUwIiBkPSJNMTQ1IDQ3YzI1LTIwIDUwLTI3IDY3LTE3IDE2IDkgMjMgMzAgMjAgNjAgMCAyLTEgNS0xIDdsLTIgMTNoLTFsLTEyLTRjLTgtMy0xNy01LTI1LTZsLTE3LTNjLTEwLTEtMjAtMS0yOS0xLTEwIDAtMjAgMC0yOSAxLTYgOC0xMSAxNi0xNiAyNC01IDktOSAxNy0xMyAyNiA0IDkgOCAxOCAxMyAyNnMxMCAxNiAxNiAyNGwxMSAxNGM1IDcgMTEgMTMgMTggMTlsMTAgOHMtMSAwIDAgMGwtMTEgOWMtMTcgMTQtMzUgMjItNDkgMjItNiAwLTEzLTItMTgtNS0xNi05LTIzLTMwLTIwLTU5IDEtMyAxLTUgMS04LTMwLTEyLTQ4LTI5LTQ4LTUwIDAtMTggMTQtMzUgNDEtNDcgMi0xIDUtMiA3LTMgMC0yIDAtNS0xLTctMy0zMCA0LTUxIDIwLTYwIDE4LTEwIDQyLTMgNjggMTdNNzEgMjAxYy0xIDItMSA0LTEgNS0yIDI0IDMgNDEgMTMgNDdoMWMxMSA3IDMwIDEgNTEtMTUtMTAtOS0xOC0xOC0yNi0yOS0xMy0xLTI2LTQtMzgtOG05LTM4Yy0zIDktNSAxNy03IDI2IDggMiAxNyA0IDI1IDYtMy01LTYtMTAtOS0xNi0zLTUtNi0xMC05LTE2bS0xOS01M2MtMiAxLTMgMS01IDItMjEgMTAtMzQgMjMtMzQgMzUgMCAxMyAxNCAyNyAzOSAzNyAzLTEyIDctMjUgMTItMzctNS0xMi05LTI0LTEyLTM3bTM3LTEwYy04IDEtMTcgMy0yNSA2IDIgOCA0IDE2IDcgMjUgMy01IDYtMTEgOS0xNnptLTMtNjFjLTQtMS04IDAtMTIgMi0xMCA3LTE1IDI0LTEzIDQ3IDAgMiAwIDMgMSA1IDEyLTQgMjUtNiAzOC04IDgtMTAgMTYtMjAgMjYtMjktMTUtMTEtMjktMTctNDAtMTdtMTExIDJjLTQtMi04LTMtMTItMi0xMSAwLTI1IDUtNDAgMTcgMTAgOSAxOSAxOSAyNiAyOSAxMyAyIDI2IDQgMzkgOHYtNWMzLTIzLTItNDAtMTMtNDdtLTYxIDIzYy03IDYtMTMgMTMtMTkgMTloMzdjLTYtNi0xMi0xMy0xOC0xOSIvPjxtYXNrIGlkPSJhIj48cGF0aCBmaWxsPSIjZmZmIiBkPSJtMjM1IDg1LTEzMyAyNyAyOCAxMzMgMTMzLTI3WiIvPjwvbWFzaz48cGF0aCBmaWxsPSIjZmY0MGUwIiBkPSJtMTM3IDEzMCA3NiAxMWM4IDEgOSAxMSAzIDE1bC0yOCAxNi00IDMyYy0xIDgtMTAgMTAtMTQgNGwtNDEtNjZjLTMtNiAxLTEzIDgtMTIiIG1hc2s9InVybCgjYSkiLz48L3N2Zz4=\"); background-size: 16px 16px; background-repeat: no-repeat; background-position: 4px center; display: inline-block; margin-bottom: 4px; \", source: http://localhost:5173/@fs/C:/Users/jinwo/orca/workspaces/orca/windows-performance-improvement/node_modules/.vite/deps/react-grab.js?v=e74fde05 (1078)", + "at": 1782977072013 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=31229 maxGapMs=223", + "at": 1782977072070 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-repos-done t=31330 rendererT=23666 durationMs=378", + "at": 1782977072171 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-repos-done t=31330 rendererT=23667 durationMs=262", + "at": 1782977072171 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-project-groups-done t=31425 rendererT=23762 durationMs=95", + "at": 1782977072266 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-project-groups-done t=31426 rendererT=23762 durationMs=96", + "at": 1782977072266 + }, + { + "source": "stderr", + "line": "[50192:0702/032432.274:INFO:CONSOLE:1080] \"[React Grab] v0.1.34 is outdated (latest: v0.1.47). Run `npx grab@latest upgrade` to upgrade.\", source: http://localhost:5173/@fs/C:/Users/jinwo/orca/workspaces/orca/windows-performance-improvement/node_modules/.vite/deps/react-grab.js?v=e74fde05 (1080)", + "at": 1782977072274 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-folder-workspaces-done t=31469 rendererT=23805 durationMs=43", + "at": 1782977072309 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-worktrees-done t=31469 rendererT=23806 durationMs=1", + "at": 1782977072310 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-folder-workspaces-done t=31470 rendererT=23807 durationMs=44", + "at": 1782977072311 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-worktrees-done t=31470 rendererT=23807 durationMs=0", + "at": 1782977072311 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-worktree-lineage-done t=31486 rendererT=23823 durationMs=16", + "at": 1782977072327 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-worktree-lineage-done t=31490 rendererT=23827 durationMs=20", + "at": 1782977072331 + }, + { + "source": "stderr", + "line": "[startup] renderer-ui-get-done t=31511 rendererT=23848 durationMs=25", + "at": 1782977072352 + }, + { + "source": "stderr", + "line": "[startup] renderer-hydrate-persisted-ui-done t=31511 rendererT=23848 durationMs=0", + "at": 1782977072352 + }, + { + "source": "stderr", + "line": "[startup] renderer-ui-get-done t=31512 rendererT=23848 durationMs=22", + "at": 1782977072353 + }, + { + "source": "stderr", + "line": "[startup] renderer-hydrate-persisted-ui-done t=31515 rendererT=23851 durationMs=3", + "at": 1782977072355 + }, + { + "source": "stderr", + "line": "[50192:0702/032432.892:INFO:CONSOLE:397] \"Tooltip is changing from uncontrolled to controlled. Components should not switch from controlled to uncontrolled (or vice versa). Decide between using a controlled or uncontrolled value for the lifetime of the component.\", source: http://localhost:5173/@fs/C:/Users/jinwo/orca/workspaces/orca/windows-performance-improvement/node_modules/.vite/deps/chunk-22L5D3A7.js?v=33341407 (397)", + "at": 1782977072892 + }, + { + "source": "stderr", + "line": "[startup] renderer-session-get-done t=32052 rendererT=24110 durationMs=262", + "at": 1782977072893 + }, + { + "source": "stderr", + "line": "[startup] renderer-session-get-done t=32054 rendererT=24149 durationMs=298", + "at": 1782977072894 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-keybindings-done t=32142 rendererT=24479 durationMs=368", + "at": 1782977072983 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-keybindings-done t=32218 rendererT=24554 durationMs=405", + "at": 1782977073059 + }, + { + "source": "stderr", + "line": "[startup] renderer-hydrate-session-stores-done t=32222 rendererT=24559 durationMs=5", + "at": 1782977073063 + }, + { + "source": "stderr", + "line": "[startup] renderer-visit-timestamp-prune-done t=32225 rendererT=24560 durationMs=1", + "at": 1782977073066 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-browser-session-profiles-done t=32574 rendererT=24606 durationMs=46", + "at": 1782977073415 + }, + { + "source": "stderr", + "line": "[startup] renderer-onboarding-get-done t=32587 rendererT=24924 durationMs=318", + "at": 1782977073428 + }, + { + "source": "stderr", + "line": "[startup] renderer-ssh-reconnect-skipped t=32588 rendererT=24924 connectionIds=0", + "at": 1782977073428 + }, + { + "source": "stderr", + "line": "[startup] renderer-first-window-services-await-done t=32628 rendererT=24965 durationMs=41", + "at": 1782977073472 + }, + { + "source": "stderr", + "line": "[startup] renderer-reconnect-terminals-done t=32675 rendererT=25011 durationMs=46", + "at": 1782977073515 + }, + { + "source": "stderr", + "line": "[startup] renderer-startup-hydration-done t=32677 rendererT=25013 durationMs=2142", + "at": 1782977073517 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=33422 maxGapMs=401", + "at": 1782977074262 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=35433 maxGapMs=309", + "at": 1782977076274 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=37456 maxGapMs=10", + "at": 1782977078296 + }, + { + "source": "stderr", + "line": "[50192:0702/032438.587:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=ab3a14da-e087-4677-9fa8-df990c149951 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=ab3a14da-e087-4677-9fa8-df990c149951:4f90bd45-a941-4d4f-b132-614d700c43c8\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977078587 + }, + { + "source": "stderr", + "line": "[50192:0702/032438.587:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977078587 + }, + { + "source": "stderr", + "line": "[50192:0702/032438.636:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=0d6b052e-77c8-463f-afc9-d8f151d2cdeb restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=0d6b052e-77c8-463f-afc9-d8f151d2cdeb:69c12565-c05a-493d-8de2-11ff97619d51\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977078637 + }, + { + "source": "stderr", + "line": "[50192:0702/032438.636:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977078637 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=39469 maxGapMs=57", + "at": 1782977080309 + }, + { + "source": "stderr", + "line": "[50192:0702/032441.957:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=c9f77dc6-cd6f-4965-809c-c09127a40a03 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=c9f77dc6-cd6f-4965-809c-c09127a40a03:f1346d6f-965f-41fb-924b-764c7e3fc305\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977081957 + }, + { + "source": "stderr", + "line": "[50192:0702/032441.957:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977081957 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=41474 maxGapMs=48", + "at": 1782977082314 + }, + { + "source": "stderr", + "line": "[50192:0702/032442.858:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=cfb07cef-9bf1-4c78-996b-849186b593a4 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=cfb07cef-9bf1-4c78-996b-849186b593a4:de8f4a47-b5c9-4bf7-a650-1689aba5a8a8\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977082858 + }, + { + "source": "stderr", + "line": "[50192:0702/032442.858:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977082858 + }, + { + "source": "stderr", + "line": "[50192:0702/032443.702:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=3a994118-6004-4225-9c79-b7f00e7d72f1 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=3a994118-6004-4225-9c79-b7f00e7d72f1:89f847c7-b204-4b3a-b4cf-06f5c8c513bd\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977083702 + }, + { + "source": "stderr", + "line": "[50192:0702/032443.702:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977083702 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=43486 maxGapMs=53", + "at": 1782977084327 + }, + { + "source": "stderr", + "line": "[50192:0702/032444.556:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=8fc5cf91-915a-4244-a4da-1724f3682004 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=8fc5cf91-915a-4244-a4da-1724f3682004:805fa077-9c84-409e-95c0-d6974394f92a\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977084556 + }, + { + "source": "stderr", + "line": "[50192:0702/032444.556:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977084556 + }, + { + "source": "stderr", + "line": "[50192:0702/032444.635:INFO:CONSOLE:397] \"Tooltip is changing from controlled to uncontrolled. Components should not switch from controlled to uncontrolled (or vice versa). Decide between using a controlled or uncontrolled value for the lifetime of the component.\", source: http://localhost:5173/@fs/C:/Users/jinwo/orca/workspaces/orca/windows-performance-improvement/node_modules/.vite/deps/chunk-22L5D3A7.js?v=33341407 (397)", + "at": 1782977084635 + }, + { + "source": "stderr", + "line": "[50192:0702/032445.429:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=9fa0c94f-508a-44a0-b8ba-5c24ac4ee536 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=9fa0c94f-508a-44a0-b8ba-5c24ac4ee536:89760fac-065f-4c31-a88a-3d57536749a6\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977085430 + }, + { + "source": "stderr", + "line": "[50192:0702/032445.429:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977085430 + }, + { + "source": "stderr", + "line": "[50192:0702/032446.318:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=be158070-c785-4cea-aca9-a94e71675335 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=be158070-c785-4cea-aca9-a94e71675335:8f686ee6-1816-4b55-8368-0cf249d9ca92\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977086318 + }, + { + "source": "stderr", + "line": "[50192:0702/032446.318:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977086319 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=45527 maxGapMs=50", + "at": 1782977086368 + }, + { + "source": "stderr", + "line": "[50192:0702/032447.232:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=257d758c-e00a-45f4-9543-41bb82652d68 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=257d758c-e00a-45f4-9543-41bb82652d68:d3e9824c-9691-4f5d-8bfa-8e7099c095a3\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977087232 + }, + { + "source": "stderr", + "line": "[50192:0702/032447.232:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977087233 + }, + { + "source": "stderr", + "line": "[50192:0702/032448.177:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=3db20953-661a-4e65-8e4c-ca6a6f9d0538 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=3db20953-661a-4e65-8e4c-ca6a6f9d0538:621eeeff-c3b9-4558-bb55-fe3470e05025\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977088177 + }, + { + "source": "stderr", + "line": "[50192:0702/032448.177:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977088178 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=47556 maxGapMs=42", + "at": 1782977088397 + }, + { + "source": "stderr", + "line": "[50192:0702/032448.612:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=f4dca755-e41c-4f03-aa25-ed67526ca90e restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=f4dca755-e41c-4f03-aa25-ed67526ca90e:edcb7b76-70ec-4c21-b6c1-be29e795657d\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977088612 + }, + { + "source": "stderr", + "line": "[50192:0702/032448.612:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977088612 + }, + { + "source": "stderr", + "line": "[50192:0702/032449.077:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=8f40ca49-476c-4959-ae9f-f976293e0b62 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=8f40ca49-476c-4959-ae9f-f976293e0b62:f690ab96-e8c7-416a-a8e2-26cc38e11325\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977089078 + }, + { + "source": "stderr", + "line": "[50192:0702/032449.078:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977089078 + }, + { + "source": "stderr", + "line": "[50192:0702/032449.492:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=d7adfbbb-a7eb-48ed-876b-440835e7174f restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=d7adfbbb-a7eb-48ed-876b-440835e7174f:dee0c9f1-8ff4-4c92-894b-178d9a23b3a0\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977089493 + }, + { + "source": "stderr", + "line": "[50192:0702/032449.493:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977089494 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=49559 maxGapMs=57", + "at": 1782977090400 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=51588 maxGapMs=12", + "at": 1782977092429 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=53613 maxGapMs=12", + "at": 1782977094454 + }, + { + "source": "stderr", + "line": "[50192:0702/032455.950:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=afdd9731-982a-4255-8c0e-b4360453f433 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=afdd9731-982a-4255-8c0e-b4360453f433:e6721533-a824-4847-98d2-b2c9f073a233\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977095950 + }, + { + "source": "stderr", + "line": "[50192:0702/032455.950:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977095951 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=55626 maxGapMs=26", + "at": 1782977096467 + }, + { + "source": "stderr", + "line": "[50192:0702/032456.469:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=2a067075-6acf-4aca-a0a6-bc88c1e6abc7 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=2a067075-6acf-4aca-a0a6-bc88c1e6abc7:6a3ca793-864f-4182-bec5-5b044a884b9b\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977096469 + }, + { + "source": "stderr", + "line": "[50192:0702/032456.469:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782977096469 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=57642 maxGapMs=30", + "at": 1782977098483 + } + ] +} diff --git a/tools/benchmarks/results/terminal-perf-fix-a-b-2026-07-02T07-46-58-178Z.json b/tools/benchmarks/results/terminal-perf-fix-a-b-2026-07-02T07-46-58-178Z.json new file mode 100644 index 00000000000..ece15c26cfc --- /dev/null +++ b/tools/benchmarks/results/terminal-perf-fix-a-b-2026-07-02T07-46-58-178Z.json @@ -0,0 +1,1058 @@ +{ + "label": "fix-a-b", + "startedAt": "2026-07-02T07:46:58.178Z", + "platform": "win32 10.0.26200", + "shell": "default", + "args": { + "iterations": 8, + "switches": 24, + "cycles": 12 + }, + "scenarios": { + "tab-create": { + "samples": [ + { + "index": 0, + "storeCreateMs": 3.099999964237213, + "ptyBindMs": 5504, + "firstOutputMs": 5550.799999952316, + "paintSettleMs": 5553.099999964237, + "timedOut": false + }, + { + "index": 1, + "storeCreateMs": 1.9000000357627869, + "ptyBindMs": 518.2000000476837, + "firstOutputMs": 535.7000000476837, + "paintSettleMs": 544.7000000476837, + "timedOut": false + }, + { + "index": 2, + "storeCreateMs": 2.399999976158142, + "ptyBindMs": 412.5999999642372, + "firstOutputMs": 432.89999997615814, + "paintSettleMs": 434.7999999523163, + "timedOut": false + }, + { + "index": 3, + "storeCreateMs": 1.5, + "ptyBindMs": 388.9000000357628, + "firstOutputMs": 401.10000002384186, + "paintSettleMs": 408.60000002384186, + "timedOut": false + }, + { + "index": 4, + "storeCreateMs": 2.800000011920929, + "ptyBindMs": 469, + "firstOutputMs": 482.5999999642372, + "paintSettleMs": 485.30000001192093, + "timedOut": false + }, + { + "index": 5, + "storeCreateMs": 1.899999976158142, + "ptyBindMs": 499.60000002384186, + "firstOutputMs": 511.60000002384186, + "paintSettleMs": 519, + "timedOut": false + }, + { + "index": 6, + "storeCreateMs": 2, + "ptyBindMs": 441.10000002384186, + "firstOutputMs": 455.60000002384186, + "paintSettleMs": 456.39999997615814, + "timedOut": false + }, + { + "index": 7, + "storeCreateMs": 2.300000011920929, + "ptyBindMs": 312.80000001192093, + "firstOutputMs": 326.69999998807907, + "paintSettleMs": 334.19999998807907, + "timedOut": false + } + ], + "longtasks": [ + 404, 60, 184, 142, 208, 169, 148, 194, 87, 159, 211, 62, 67, 258, 95, 55, 226, 151, 53, 149, + 172, 142, 144, 56, 64 + ] + }, + "tab-switch": { + "samples": [ + { + "index": 0, + "targetTabId": "8d57a052-276e-4882-ade4-dcc5278b822c", + "storeMs": 1.7000000476837158, + "paneVisibleMs": 1.7000000476837158, + "paintSettleMs": 95.5, + "timedOut": false + }, + { + "index": 1, + "targetTabId": "d0231601-75d6-4b5e-b6f9-edae135f3beb", + "storeMs": 1.100000023841858, + "paneVisibleMs": 1.100000023841858, + "paintSettleMs": 40.5, + "timedOut": false + }, + { + "index": 2, + "targetTabId": "37b9b3da-9361-47ea-b14e-eb9bc33fd17a", + "storeMs": 2, + "paneVisibleMs": 2, + "paintSettleMs": 129.5999999642372, + "timedOut": false + }, + { + "index": 3, + "targetTabId": "ac3abf8a-ed89-4c51-83ae-5fca5e395ed5", + "storeMs": 1.899999976158142, + "paneVisibleMs": 1.899999976158142, + "paintSettleMs": 118.39999997615814, + "timedOut": false + }, + { + "index": 4, + "targetTabId": "8d57a052-276e-4882-ade4-dcc5278b822c", + "storeMs": 1.9000000357627869, + "paneVisibleMs": 1.9000000357627869, + "paintSettleMs": 86.60000002384186, + "timedOut": false + }, + { + "index": 5, + "targetTabId": "d0231601-75d6-4b5e-b6f9-edae135f3beb", + "storeMs": 1.600000023841858, + "paneVisibleMs": 1.600000023841858, + "paintSettleMs": 34.80000001192093, + "timedOut": false + }, + { + "index": 6, + "targetTabId": "37b9b3da-9361-47ea-b14e-eb9bc33fd17a", + "storeMs": 1, + "paneVisibleMs": 1, + "paintSettleMs": 43.59999996423721, + "timedOut": false + }, + { + "index": 7, + "targetTabId": "ac3abf8a-ed89-4c51-83ae-5fca5e395ed5", + "storeMs": 1.7999999523162842, + "paneVisibleMs": 1.7999999523162842, + "paintSettleMs": 128.89999997615814, + "timedOut": false + }, + { + "index": 8, + "targetTabId": "8d57a052-276e-4882-ade4-dcc5278b822c", + "storeMs": 1.100000023841858, + "paneVisibleMs": 1.100000023841858, + "paintSettleMs": 95.5, + "timedOut": false + }, + { + "index": 9, + "targetTabId": "d0231601-75d6-4b5e-b6f9-edae135f3beb", + "storeMs": 1.399999976158142, + "paneVisibleMs": 1.399999976158142, + "paintSettleMs": 94.19999998807907, + "timedOut": false + }, + { + "index": 10, + "targetTabId": "37b9b3da-9361-47ea-b14e-eb9bc33fd17a", + "storeMs": 1.5, + "paneVisibleMs": 1.5, + "paintSettleMs": 29.100000023841858, + "timedOut": false + }, + { + "index": 11, + "targetTabId": "ac3abf8a-ed89-4c51-83ae-5fca5e395ed5", + "storeMs": 1.199999988079071, + "paneVisibleMs": 1.199999988079071, + "paintSettleMs": 35, + "timedOut": false + }, + { + "index": 12, + "targetTabId": "8d57a052-276e-4882-ade4-dcc5278b822c", + "storeMs": 1.800000011920929, + "paneVisibleMs": 1.800000011920929, + "paintSettleMs": 167.30000001192093, + "timedOut": false + }, + { + "index": 13, + "targetTabId": "d0231601-75d6-4b5e-b6f9-edae135f3beb", + "storeMs": 2.900000035762787, + "paneVisibleMs": 2.900000035762787, + "paintSettleMs": 141.9000000357628, + "timedOut": false + }, + { + "index": 14, + "targetTabId": "37b9b3da-9361-47ea-b14e-eb9bc33fd17a", + "storeMs": 1, + "paneVisibleMs": 1, + "paintSettleMs": 57.69999998807907, + "timedOut": false + }, + { + "index": 15, + "targetTabId": "ac3abf8a-ed89-4c51-83ae-5fca5e395ed5", + "storeMs": 1.7000000476837158, + "paneVisibleMs": 1.7000000476837158, + "paintSettleMs": 42.90000003576279, + "timedOut": false + }, + { + "index": 16, + "targetTabId": "8d57a052-276e-4882-ade4-dcc5278b822c", + "storeMs": 2.399999976158142, + "paneVisibleMs": 2.399999976158142, + "paintSettleMs": 49.30000001192093, + "timedOut": false + }, + { + "index": 17, + "targetTabId": "d0231601-75d6-4b5e-b6f9-edae135f3beb", + "storeMs": 1.2000000476837158, + "paneVisibleMs": 1.2000000476837158, + "paintSettleMs": 126.40000003576279, + "timedOut": false + }, + { + "index": 18, + "targetTabId": "37b9b3da-9361-47ea-b14e-eb9bc33fd17a", + "storeMs": 1.399999976158142, + "paneVisibleMs": 1.399999976158142, + "paintSettleMs": 134.89999997615814, + "timedOut": false + }, + { + "index": 19, + "targetTabId": "ac3abf8a-ed89-4c51-83ae-5fca5e395ed5", + "storeMs": 2.100000023841858, + "paneVisibleMs": 2.100000023841858, + "paintSettleMs": 75.19999998807907, + "timedOut": false + }, + { + "index": 20, + "targetTabId": "8d57a052-276e-4882-ade4-dcc5278b822c", + "storeMs": 1.699999988079071, + "paneVisibleMs": 1.699999988079071, + "paintSettleMs": 40.69999998807907, + "timedOut": false + }, + { + "index": 21, + "targetTabId": "d0231601-75d6-4b5e-b6f9-edae135f3beb", + "storeMs": 1.0999999642372131, + "paneVisibleMs": 1.0999999642372131, + "paintSettleMs": 40.80000001192093, + "timedOut": false + }, + { + "index": 22, + "targetTabId": "37b9b3da-9361-47ea-b14e-eb9bc33fd17a", + "storeMs": 2.099999964237213, + "paneVisibleMs": 2.099999964237213, + "paintSettleMs": 131.89999997615814, + "timedOut": false + }, + { + "index": 23, + "targetTabId": "ac3abf8a-ed89-4c51-83ae-5fca5e395ed5", + "storeMs": 2.100000023841858, + "paneVisibleMs": 2.100000023841858, + "paintSettleMs": 146.9000000357628, + "timedOut": false + } + ], + "longtasks": [86, 123, 111, 73, 122, 90, 84, 108, 127, 52, 121, 129, 64, 124, 134] + }, + "workspace-switch": { + "samples": [ + { + "index": 0, + "worktreeId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo", + "ptyId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@588e69ce", + "activationMs": 240, + "ptyBindMs": 247, + "paintSettleMs": 254 + }, + { + "index": 1, + "worktreeId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-one", + "ptyId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-one@@407494c8", + "activationMs": 423, + "ptyBindMs": 573, + "paintSettleMs": 584 + }, + { + "index": 2, + "worktreeId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-two", + "ptyId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-two@@5d681b07", + "activationMs": 355, + "ptyBindMs": 441, + "paintSettleMs": 464 + }, + { + "index": 3, + "worktreeId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo", + "ptyId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@588e69ce", + "activationMs": 152, + "ptyBindMs": 177, + "paintSettleMs": 182 + }, + { + "index": 4, + "worktreeId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-one", + "ptyId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-one@@407494c8", + "activationMs": 294, + "ptyBindMs": 330, + "paintSettleMs": 341 + }, + { + "index": 5, + "worktreeId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-two", + "ptyId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-two@@5d681b07", + "activationMs": 167, + "ptyBindMs": 192, + "paintSettleMs": 200 + }, + { + "index": 6, + "worktreeId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo", + "ptyId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@588e69ce", + "activationMs": 141, + "ptyBindMs": 158, + "paintSettleMs": 164 + }, + { + "index": 7, + "worktreeId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-one", + "ptyId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-one@@407494c8", + "activationMs": 115, + "ptyBindMs": 139, + "paintSettleMs": 145 + }, + { + "index": 8, + "worktreeId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-two", + "ptyId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-two@@5d681b07", + "activationMs": 123, + "ptyBindMs": 147, + "paintSettleMs": 154 + }, + { + "index": 9, + "worktreeId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo", + "ptyId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@588e69ce", + "activationMs": 258, + "ptyBindMs": 284, + "paintSettleMs": 294 + }, + { + "index": 10, + "worktreeId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-one", + "ptyId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-one@@407494c8", + "activationMs": 153, + "ptyBindMs": 176, + "paintSettleMs": 185 + }, + { + "index": 11, + "worktreeId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-two", + "ptyId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-two@@5d681b07", + "activationMs": 123, + "ptyBindMs": 151, + "paintSettleMs": 158 + } + ] + } + }, + "summaries": [ + { + "name": "tab-create", + "samples": 8, + "timedOut": 0, + "fields": { + "storeCreateMs": { + "count": 8, + "median": 2, + "p95": 3, + "max": 3 + }, + "ptyBindMs": { + "count": 8, + "median": 469, + "p95": 5504, + "max": 5504 + }, + "firstOutputMs": { + "count": 8, + "median": 483, + "p95": 5551, + "max": 5551 + }, + "paintSettleMs": { + "count": 8, + "median": 485, + "p95": 5553, + "max": 5553 + } + }, + "longtaskCount": 25, + "longtaskMaxMs": 404 + }, + { + "name": "tab-switch", + "samples": 24, + "timedOut": 0, + "fields": { + "storeMs": { + "count": 24, + "median": 2, + "p95": 2, + "max": 3 + }, + "paneVisibleMs": { + "count": 24, + "median": 2, + "p95": 2, + "max": 3 + }, + "paintSettleMs": { + "count": 24, + "median": 94, + "p95": 147, + "max": 167 + } + }, + "longtaskCount": 15, + "longtaskMaxMs": 134 + }, + { + "name": "workspace-switch", + "samples": 12, + "timedOut": 0, + "fields": { + "activationMs": { + "count": 12, + "median": 167, + "p95": 355, + "max": 423 + }, + "ptyBindMs": { + "count": 12, + "median": 192, + "p95": 441, + "max": 573 + }, + "paintSettleMs": { + "count": 12, + "median": 200, + "p95": 464, + "max": 584 + } + }, + "longtaskCount": 0, + "longtaskMaxMs": 0 + } + ], + "finalDiagnostics": { + "hasStore": true, + "workspaceSessionReady": true, + "hydrationSucceeded": true, + "activeView": "terminal", + "activeRepoId": "62e7f2fd-6fb6-446e-bded-2866c26a968e", + "activeWorktreeId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-two", + "activeTabType": "terminal", + "activeTabId": "5cf37be5-4f5e-4e6f-a00c-5116e3f6127e", + "terminalGpuAcceleration": "auto", + "repoCount": 1, + "worktreeCountsByRepo": { + "62e7f2fd-6fb6-446e-bded-2866c26a968e": 3 + }, + "ptyIdsByTabId": [ + "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-two@@5d681b07" + ], + "paneManagerCount": 3, + "activePane": { + "id": 1, + "leafId": "74365402-0e1f-4d28-b14b-212e48cb4584", + "ptyId": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-two@@5d681b07", + "cols": 175, + "rows": 73, + "buffer": { + "baseY": 0, + "viewportY": 0, + "cursorY": 0, + "length": 73 + } + }, + "renderingDiagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": false, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": true + } + ], + "allPaneManagersDiagnostics": [ + { + "tabId": "5ae16ac3-2ecb-4d5b-bdc2-3751e9b5c6a8", + "diagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": true, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": true + } + ] + }, + { + "tabId": "212a99f7-8de7-4b20-9a0b-c8caba7656e4", + "diagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": true, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": true + } + ] + }, + { + "tabId": "5cf37be5-4f5e-4e6f-a00c-5116e3f6127e", + "diagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": false, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": true + } + ] + } + ], + "webglContextCounts": { + "attachedWebglCount": 3, + "deferredWebglCount": 2, + "managerCount": 3, + "paneCount": 3 + }, + "webglIdentity": { + "available": true, + "vendor": "Google Inc. (Intel)", + "renderer": "ANGLE (Intel, Intel(R) Arc(TM) Graphics (0x00007D55) Direct3D11 vs_5_0 ps_5_0, D3D11)" + }, + "rendererProbe": { + "intervalMs": 50, + "last": 36550, + "maxDriftMs": 436.4000000357628, + "samples": 329, + "startedAt": 12199.699999988079, + "lastTickAt": 36550 + }, + "ptySessions": { + "value": [ + { + "id": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@588e69ce", + "cwd": "", + "title": "shell" + }, + { + "id": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-one@@407494c8", + "cwd": "", + "title": "shell" + }, + { + "id": "62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-two@@5d681b07", + "cwd": "", + "title": "shell" + } + ] + }, + "rendererDeliveryDebug": { + "value": { + "pendingPtyCount": 0, + "pendingChars": 0, + "maxPendingCharsByPty": 0, + "rendererInFlightPtyCount": 0, + "rendererInFlightChars": 0, + "maxRendererInFlightCharsByPty": 0, + "activeRendererPtyCount": 1, + "flushScheduled": false, + "peakPendingChars": 82, + "peakMaxPendingCharsByPty": 82, + "peakRendererInFlightChars": 82, + "peakMaxRendererInFlightCharsByPty": 82, + "ackGatedFlushSkipCount": 0 + } + } + }, + "cleanupErrors": [], + "elapsedMs": 50260, + "appLogsTail": [ + { + "source": "stderr", + "line": "[startup] renderer-fetch-folder-workspaces-done t=13843 rendererT=11282 durationMs=40", + "at": 1782978443148 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-worktrees-done t=13844 rendererT=11282 durationMs=0", + "at": 1782978443148 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-worktree-lineage-done t=13858 rendererT=11297 durationMs=16", + "at": 1782978443163 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-worktree-lineage-done t=13860 rendererT=11299 durationMs=17", + "at": 1782978443165 + }, + { + "source": "stderr", + "line": "[startup] renderer-ui-get-done t=13873 rendererT=11312 durationMs=15", + "at": 1782978443178 + }, + { + "source": "stderr", + "line": "[startup] renderer-hydrate-persisted-ui-done t=13873 rendererT=11312 durationMs=0", + "at": 1782978443178 + }, + { + "source": "stderr", + "line": "[startup] renderer-ui-get-done t=13875 rendererT=11314 durationMs=15", + "at": 1782978443180 + }, + { + "source": "stderr", + "line": "[startup] renderer-hydrate-persisted-ui-done t=13875 rendererT=11314 durationMs=0", + "at": 1782978443180 + }, + { + "source": "stderr", + "line": "[startup] renderer-session-get-done t=13891 rendererT=11330 durationMs=18", + "at": 1782978443196 + }, + { + "source": "stderr", + "line": "[startup] renderer-session-get-done t=13893 rendererT=11332 durationMs=17", + "at": 1782978443198 + }, + { + "source": "stderr", + "line": "[55788:0702/034723.201:INFO:CONSOLE:1080] \"[React Grab] v0.1.34 is outdated (latest: v0.1.47). Run `npx grab@latest upgrade` to upgrade.\", source: http://localhost:5173/@fs/C:/Users/jinwo/orca/workspaces/orca/windows-performance-improvement/node_modules/.vite/deps/react-grab.js?v=43681c89 (1080)", + "at": 1782978443201 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-keybindings-done t=14296 rendererT=11390 durationMs=59", + "at": 1782978443601 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-keybindings-done t=14297 rendererT=11429 durationMs=97", + "at": 1782978443602 + }, + { + "source": "stderr", + "line": "[startup] renderer-hydrate-session-stores-done t=14297 rendererT=11434 durationMs=5", + "at": 1782978443602 + }, + { + "source": "stderr", + "line": "[startup] renderer-visit-timestamp-prune-done t=14297 rendererT=11436 durationMs=1", + "at": 1782978443602 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-browser-session-profiles-done t=14305 rendererT=11744 durationMs=308", + "at": 1782978443610 + }, + { + "source": "stderr", + "line": "[startup] renderer-onboarding-get-done t=14306 rendererT=11745 durationMs=1", + "at": 1782978443611 + }, + { + "source": "stderr", + "line": "[startup] renderer-ssh-reconnect-skipped t=14306 rendererT=11745 connectionIds=0", + "at": 1782978443611 + }, + { + "source": "stderr", + "line": "[startup] renderer-first-window-services-await-done t=14338 rendererT=11777 durationMs=32", + "at": 1782978443643 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=14736 maxGapMs=360", + "at": 1782978444041 + }, + { + "source": "stderr", + "line": "[startup] renderer-reconnect-terminals-done t=14736 rendererT=11817 durationMs=41", + "at": 1782978444041 + }, + { + "source": "stderr", + "line": "[startup] renderer-startup-hydration-done t=14737 rendererT=11818 durationMs=1577", + "at": 1782978444042 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=16763 maxGapMs=352", + "at": 1782978446068 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=18790 maxGapMs=8", + "at": 1782978448094 + }, + { + "source": "stderr", + "line": "[55788:0702/034728.941:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=5ae16ac3-2ecb-4d5b-bdc2-3751e9b5c6a8 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=5ae16ac3-2ecb-4d5b-bdc2-3751e9b5c6a8:36d54ebb-a041-4133-b5da-c7d36f570603\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978448941 + }, + { + "source": "stderr", + "line": "[55788:0702/034728.941:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978448941 + }, + { + "source": "stderr", + "line": "[55788:0702/034728.994:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=d13ff0ea-ef9b-4cc0-bc75-de82d29e4c7e restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=d13ff0ea-ef9b-4cc0-bc75-de82d29e4c7e:375f8b17-83a7-4b05-9794-8c7e6daaad79\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978448994 + }, + { + "source": "stderr", + "line": "[55788:0702/034728.994:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978448994 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=20813 maxGapMs=95", + "at": 1782978450118 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@588e69ce total=1879ms preflight=0ms auth=0ms host_env=50ms options=0ms provider_spawn=1829ms daemon=true reattach=false", + "at": 1782978450823 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@15f1dbf7 total=1880ms preflight=0ms auth=0ms host_env=55ms options=0ms provider_spawn=1825ms daemon=true reattach=false", + "at": 1782978450875 + }, + { + "source": "stderr", + "line": "[55788:0702/034731.745:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=2b742216-def2-45d8-a554-afa8b63a7efc restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=2b742216-def2-45d8-a554-afa8b63a7efc:6328b254-57e8-479e-bf53-220dd8fbefed\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978451746 + }, + { + "source": "stderr", + "line": "[55788:0702/034731.745:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978451746 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@1ee97184 total=118ms preflight=0ms auth=0ms host_env=53ms options=0ms provider_spawn=64ms daemon=true reattach=false", + "at": 1782978451864 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=22815 maxGapMs=57", + "at": 1782978452120 + }, + { + "source": "stderr", + "line": "[55788:0702/034732.691:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=a669eb6f-24a3-4e93-9b9d-230b1734818a restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=a669eb6f-24a3-4e93-9b9d-230b1734818a:97eecd6f-f5df-41f6-8d16-0052bd0dd085\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978452692 + }, + { + "source": "stderr", + "line": "[55788:0702/034732.691:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978452692 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@04171bdd total=113ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=65ms daemon=true reattach=false", + "at": 1782978452806 + }, + { + "source": "stderr", + "line": "[55788:0702/034733.549:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=a7f4f0da-28f3-4938-b412-0b5c7d389a1a restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=a7f4f0da-28f3-4938-b412-0b5c7d389a1a:0e97486f-93f7-4de1-a921-d7883bf6742d\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978453549 + }, + { + "source": "stderr", + "line": "[55788:0702/034733.550:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978453549 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@46952210 total=97ms preflight=0ms auth=0ms host_env=46ms options=0ms provider_spawn=51ms daemon=true reattach=false", + "at": 1782978453647 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=24819 maxGapMs=45", + "at": 1782978454124 + }, + { + "source": "stderr", + "line": "[55788:0702/034734.418:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=33f0654c-45be-4686-9f91-934dbd838341 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=33f0654c-45be-4686-9f91-934dbd838341:aa40fd80-39ca-4647-833f-081ff4315faf\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978454418 + }, + { + "source": "stderr", + "line": "[55788:0702/034734.418:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978454419 + }, + { + "source": "stderr", + "line": "[55788:0702/034734.488:INFO:CONSOLE:397] \"Tooltip is changing from controlled to uncontrolled. Components should not switch from controlled to uncontrolled (or vice versa). Decide between using a controlled or uncontrolled value for the lifetime of the component.\", source: http://localhost:5173/@fs/C:/Users/jinwo/orca/workspaces/orca/windows-performance-improvement/node_modules/.vite/deps/chunk-22L5D3A7.js?v=43681c89 (397)", + "at": 1782978454488 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@c7cc0a5b total=98ms preflight=0ms auth=0ms host_env=45ms options=0ms provider_spawn=53ms daemon=true reattach=false", + "at": 1782978454518 + }, + { + "source": "stderr", + "line": "[55788:0702/034735.279:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=acf961b2-58e1-4700-8246-8fbd13aec1e9 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=acf961b2-58e1-4700-8246-8fbd13aec1e9:d19e1bbf-4575-4b3c-b09e-b674244b0126\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978455279 + }, + { + "source": "stderr", + "line": "[55788:0702/034735.279:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978455279 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@07c16de2 total=110ms preflight=0ms auth=0ms host_env=46ms options=0ms provider_spawn=64ms daemon=true reattach=false", + "at": 1782978455389 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=26824 maxGapMs=40", + "at": 1782978456129 + }, + { + "source": "stderr", + "line": "[55788:0702/034736.131:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=6c82b2e6-a836-47cc-9450-d81aab89a3ca restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=6c82b2e6-a836-47cc-9450-d81aab89a3ca:fa45ccbe-b10e-4260-850c-350edc40a8f5\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978456131 + }, + { + "source": "stderr", + "line": "[55788:0702/034736.131:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978456131 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@316fcc20 total=96ms preflight=0ms auth=0ms host_env=44ms options=0ms provider_spawn=52ms daemon=true reattach=false", + "at": 1782978456228 + }, + { + "source": "stderr", + "line": "[55788:0702/034736.986:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=cc574a5e-9ee8-416a-acb3-9042ca35caf1 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=cc574a5e-9ee8-416a-acb3-9042ca35caf1:5e19c1dc-6360-4032-99f6-cb0345e5d7e5\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978456986 + }, + { + "source": "stderr", + "line": "[55788:0702/034736.987:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978456987 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@6993e5e7 total=95ms preflight=0ms auth=0ms host_env=39ms options=0ms provider_spawn=56ms daemon=true reattach=false", + "at": 1782978457082 + }, + { + "source": "stderr", + "line": "[55788:0702/034737.848:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=8d57a052-276e-4882-ade4-dcc5278b822c restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=8d57a052-276e-4882-ade4-dcc5278b822c:e39bef7b-f078-40f5-b29e-83468097b694\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978457848 + }, + { + "source": "stderr", + "line": "[55788:0702/034737.848:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978457848 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@56d1407f total=114ms preflight=0ms auth=0ms host_env=47ms options=1ms provider_spawn=66ms daemon=true reattach=false", + "at": 1782978457962 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=28852 maxGapMs=58", + "at": 1782978458157 + }, + { + "source": "stderr", + "line": "[55788:0702/034738.252:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=d0231601-75d6-4b5e-b6f9-edae135f3beb restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=d0231601-75d6-4b5e-b6f9-edae135f3beb:0ce6efc9-3d20-49df-a9cc-8ee9dec16cc3\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978458252 + }, + { + "source": "stderr", + "line": "[55788:0702/034738.252:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978458252 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@d077bcb8 total=106ms preflight=0ms auth=0ms host_env=43ms options=0ms provider_spawn=63ms daemon=true reattach=false", + "at": 1782978458358 + }, + { + "source": "stderr", + "line": "[55788:0702/034738.803:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=37b9b3da-9361-47ea-b14e-eb9bc33fd17a restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=37b9b3da-9361-47ea-b14e-eb9bc33fd17a:ea9a8021-01d2-440f-a587-809680c60baf\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978458803 + }, + { + "source": "stderr", + "line": "[55788:0702/034738.803:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978458804 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@f465a9a7 total=118ms preflight=1ms auth=0ms host_env=48ms options=0ms provider_spawn=69ms daemon=true reattach=false", + "at": 1782978458921 + }, + { + "source": "stderr", + "line": "[55788:0702/034739.203:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=ac3abf8a-ed89-4c51-83ae-5fca5e395ed5 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=ac3abf8a-ed89-4c51-83ae-5fca5e395ed5:d46a8d0d-69a8-4f14-8186-6f094f152d36\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978459203 + }, + { + "source": "stderr", + "line": "[55788:0702/034739.203:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978459203 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@2223d5a6 total=113ms preflight=0ms auth=0ms host_env=54ms options=0ms provider_spawn=59ms daemon=true reattach=false", + "at": 1782978459316 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=30878 maxGapMs=43", + "at": 1782978460183 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=32902 maxGapMs=12", + "at": 1782978462207 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=34905 maxGapMs=14", + "at": 1782978464210 + }, + { + "source": "stderr", + "line": "[55788:0702/034745.917:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=212a99f7-8de7-4b20-9a0b-c8caba7656e4 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=212a99f7-8de7-4b20-9a0b-c8caba7656e4:dcbaade8-8868-4626-b685-37a662d40aa0\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978465917 + }, + { + "source": "stderr", + "line": "[55788:0702/034745.917:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978465917 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-one@@407494c8 total=108ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=60ms daemon=true reattach=false", + "at": 1782978466025 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=36929 maxGapMs=46", + "at": 1782978466234 + }, + { + "source": "stderr", + "line": "[55788:0702/034746.426:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=5cf37be5-4f5e-4e6f-a00c-5116e3f6127e restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=5cf37be5-4f5e-4e6f-a00c-5116e3f6127e:74365402-0e1f-4d28-b14b-212e48cb4584\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978466425 + }, + { + "source": "stderr", + "line": "[55788:0702/034746.426:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978466426 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-two@@5d681b07 total=109ms preflight=0ms auth=0ms host_env=52ms options=0ms provider_spawn=57ms daemon=true reattach=false", + "at": 1782978466535 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=38934 maxGapMs=40", + "at": 1782978468239 + } + ], + "ptySpawnTimings": [ + "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@588e69ce total=1879ms preflight=0ms auth=0ms host_env=50ms options=0ms provider_spawn=1829ms daemon=true reattach=false", + "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@15f1dbf7 total=1880ms preflight=0ms auth=0ms host_env=55ms options=0ms provider_spawn=1825ms daemon=true reattach=false", + "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@1ee97184 total=118ms preflight=0ms auth=0ms host_env=53ms options=0ms provider_spawn=64ms daemon=true reattach=false", + "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@04171bdd total=113ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=65ms daemon=true reattach=false", + "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@46952210 total=97ms preflight=0ms auth=0ms host_env=46ms options=0ms provider_spawn=51ms daemon=true reattach=false", + "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@c7cc0a5b total=98ms preflight=0ms auth=0ms host_env=45ms options=0ms provider_spawn=53ms daemon=true reattach=false", + "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@07c16de2 total=110ms preflight=0ms auth=0ms host_env=46ms options=0ms provider_spawn=64ms daemon=true reattach=false", + "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@316fcc20 total=96ms preflight=0ms auth=0ms host_env=44ms options=0ms provider_spawn=52ms daemon=true reattach=false", + "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@6993e5e7 total=95ms preflight=0ms auth=0ms host_env=39ms options=0ms provider_spawn=56ms daemon=true reattach=false", + "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@56d1407f total=114ms preflight=0ms auth=0ms host_env=47ms options=1ms provider_spawn=66ms daemon=true reattach=false", + "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@d077bcb8 total=106ms preflight=0ms auth=0ms host_env=43ms options=0ms provider_spawn=63ms daemon=true reattach=false", + "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@f465a9a7 total=118ms preflight=1ms auth=0ms host_env=48ms options=0ms provider_spawn=69ms daemon=true reattach=false", + "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/repo@@2223d5a6 total=113ms preflight=0ms auth=0ms host_env=54ms options=0ms provider_spawn=59ms daemon=true reattach=false", + "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-one@@407494c8 total=108ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=60ms daemon=true reattach=false", + "[pty-spawn-timing] id=62e7f2fd-6fb6-446e-bded-2866c26a968e::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-rrZN7p/wt-two@@5d681b07 total=109ms preflight=0ms auth=0ms host_env=52ms options=0ms provider_spawn=57ms daemon=true reattach=false" + ] +} diff --git a/tools/benchmarks/results/terminal-perf-fix-abd-2026-07-02T07-51-00-936Z.json b/tools/benchmarks/results/terminal-perf-fix-abd-2026-07-02T07-51-00-936Z.json new file mode 100644 index 00000000000..ae913a7ecac --- /dev/null +++ b/tools/benchmarks/results/terminal-perf-fix-abd-2026-07-02T07-51-00-936Z.json @@ -0,0 +1,1061 @@ +{ + "label": "fix-abd", + "startedAt": "2026-07-02T07:51:00.936Z", + "platform": "win32 10.0.26200", + "shell": "default", + "args": { + "iterations": 8, + "switches": 24, + "cycles": 12 + }, + "scenarios": { + "tab-create": { + "samples": [ + { + "index": 0, + "storeCreateMs": 4.199999988079071, + "ptyBindMs": 6850.600000023842, + "firstOutputMs": 6893.800000011921, + "paintSettleMs": 6897.199999988079, + "timedOut": false + }, + { + "index": 1, + "storeCreateMs": 2.299999952316284, + "ptyBindMs": 553.2999999523163, + "firstOutputMs": 576.5999999642372, + "paintSettleMs": 578.0999999642372, + "timedOut": false + }, + { + "index": 2, + "storeCreateMs": 2.699999988079071, + "ptyBindMs": 565.6000000238419, + "firstOutputMs": 584.5, + "paintSettleMs": 585.4000000357628, + "timedOut": false + }, + { + "index": 3, + "storeCreateMs": 2.899999976158142, + "ptyBindMs": 542.8999999761581, + "firstOutputMs": 571.1999999880791, + "paintSettleMs": 573.8000000119209, + "timedOut": false + }, + { + "index": 4, + "storeCreateMs": 2.300000011920929, + "ptyBindMs": 423, + "firstOutputMs": 457.19999998807907, + "paintSettleMs": 464.80000001192093, + "timedOut": false + }, + { + "index": 5, + "storeCreateMs": 2.399999976158142, + "ptyBindMs": 478.5999999642372, + "firstOutputMs": 497, + "paintSettleMs": 498.19999998807907, + "timedOut": false + }, + { + "index": 6, + "storeCreateMs": 2.099999964237213, + "ptyBindMs": 531, + "firstOutputMs": 548.3999999761581, + "paintSettleMs": 549.3999999761581, + "timedOut": false + }, + { + "index": 7, + "storeCreateMs": 3.199999988079071, + "ptyBindMs": 612, + "firstOutputMs": 624.0999999642372, + "paintSettleMs": 629.0999999642372, + "timedOut": false + } + ], + "longtasks": [ + 478, 78, 204, 149, 218, 203, 145, 218, 202, 165, 223, 189, 163, 210, 86, 162, 280, 68, 110, + 323, 66, 76, 345, 130, 114 + ] + }, + "tab-switch": { + "samples": [ + { + "index": 0, + "targetTabId": "87a752ed-2a66-411e-8c00-b7b0b24b5d10", + "storeMs": 2.599999964237213, + "paneVisibleMs": 2.599999964237213, + "paintSettleMs": 158.5999999642372, + "timedOut": false + }, + { + "index": 1, + "targetTabId": "03cc8628-e72d-478c-90f7-3ed264097afb", + "storeMs": 2.399999976158142, + "paneVisibleMs": 2.399999976158142, + "paintSettleMs": 155.5, + "timedOut": false + }, + { + "index": 2, + "targetTabId": "993f77d3-4e18-4b17-b386-adead86c80c8", + "storeMs": 2.699999988079071, + "paneVisibleMs": 2.699999988079071, + "paintSettleMs": 99.39999997615814, + "timedOut": false + }, + { + "index": 3, + "targetTabId": "2e38aeb9-4c66-440e-adbf-de538b4ddcbb", + "storeMs": 2.199999988079071, + "paneVisibleMs": 2.199999988079071, + "paintSettleMs": 66.30000001192093, + "timedOut": false + }, + { + "index": 4, + "targetTabId": "87a752ed-2a66-411e-8c00-b7b0b24b5d10", + "storeMs": 2.5, + "paneVisibleMs": 2.5, + "paintSettleMs": 87.89999997615814, + "timedOut": false + }, + { + "index": 5, + "targetTabId": "03cc8628-e72d-478c-90f7-3ed264097afb", + "storeMs": 2.599999964237213, + "paneVisibleMs": 2.599999964237213, + "paintSettleMs": 158.5, + "timedOut": false + }, + { + "index": 6, + "targetTabId": "993f77d3-4e18-4b17-b386-adead86c80c8", + "storeMs": 2.299999952316284, + "paneVisibleMs": 2.299999952316284, + "paintSettleMs": 150, + "timedOut": false + }, + { + "index": 7, + "targetTabId": "2e38aeb9-4c66-440e-adbf-de538b4ddcbb", + "storeMs": 2.5, + "paneVisibleMs": 2.5, + "paintSettleMs": 70.19999998807907, + "timedOut": false + }, + { + "index": 8, + "targetTabId": "87a752ed-2a66-411e-8c00-b7b0b24b5d10", + "storeMs": 2.300000011920929, + "paneVisibleMs": 2.300000011920929, + "paintSettleMs": 60.19999998807907, + "timedOut": false + }, + { + "index": 9, + "targetTabId": "03cc8628-e72d-478c-90f7-3ed264097afb", + "storeMs": 2.5, + "paneVisibleMs": 2.5, + "paintSettleMs": 155.0999999642372, + "timedOut": false + }, + { + "index": 10, + "targetTabId": "993f77d3-4e18-4b17-b386-adead86c80c8", + "storeMs": 2.099999964237213, + "paneVisibleMs": 2.099999964237213, + "paintSettleMs": 159.5, + "timedOut": false + }, + { + "index": 11, + "targetTabId": "2e38aeb9-4c66-440e-adbf-de538b4ddcbb", + "storeMs": 2.300000011920929, + "paneVisibleMs": 2.300000011920929, + "paintSettleMs": 68.70000004768372, + "timedOut": false + }, + { + "index": 12, + "targetTabId": "87a752ed-2a66-411e-8c00-b7b0b24b5d10", + "storeMs": 1.699999988079071, + "paneVisibleMs": 1.699999988079071, + "paintSettleMs": 66.39999997615814, + "timedOut": false + }, + { + "index": 13, + "targetTabId": "03cc8628-e72d-478c-90f7-3ed264097afb", + "storeMs": 2.5, + "paneVisibleMs": 2.5, + "paintSettleMs": 54.90000003576279, + "timedOut": false + }, + { + "index": 14, + "targetTabId": "993f77d3-4e18-4b17-b386-adead86c80c8", + "storeMs": 2.199999988079071, + "paneVisibleMs": 2.199999988079071, + "paintSettleMs": 149.19999998807907, + "timedOut": false + }, + { + "index": 15, + "targetTabId": "2e38aeb9-4c66-440e-adbf-de538b4ddcbb", + "storeMs": 2.300000011920929, + "paneVisibleMs": 2.400000035762787, + "paintSettleMs": 151.9000000357628, + "timedOut": false + }, + { + "index": 16, + "targetTabId": "87a752ed-2a66-411e-8c00-b7b0b24b5d10", + "storeMs": 2, + "paneVisibleMs": 2, + "paintSettleMs": 90.59999996423721, + "timedOut": false + }, + { + "index": 17, + "targetTabId": "03cc8628-e72d-478c-90f7-3ed264097afb", + "storeMs": 2.300000011920929, + "paneVisibleMs": 2.300000011920929, + "paintSettleMs": 57.30000001192093, + "timedOut": false + }, + { + "index": 18, + "targetTabId": "993f77d3-4e18-4b17-b386-adead86c80c8", + "storeMs": 2.100000023841858, + "paneVisibleMs": 2.100000023841858, + "paintSettleMs": 59.30000001192093, + "timedOut": false + }, + { + "index": 19, + "targetTabId": "2e38aeb9-4c66-440e-adbf-de538b4ddcbb", + "storeMs": 2.099999964237213, + "paneVisibleMs": 2.099999964237213, + "paintSettleMs": 143.19999998807907, + "timedOut": false + }, + { + "index": 20, + "targetTabId": "87a752ed-2a66-411e-8c00-b7b0b24b5d10", + "storeMs": 1, + "paneVisibleMs": 1, + "paintSettleMs": 117.80000001192093, + "timedOut": false + }, + { + "index": 21, + "targetTabId": "03cc8628-e72d-478c-90f7-3ed264097afb", + "storeMs": 2.100000023841858, + "paneVisibleMs": 2.100000023841858, + "paintSettleMs": 94, + "timedOut": false + }, + { + "index": 22, + "targetTabId": "993f77d3-4e18-4b17-b386-adead86c80c8", + "storeMs": 1.5999999642372131, + "paneVisibleMs": 1.5999999642372131, + "paintSettleMs": 56.299999952316284, + "timedOut": false + }, + { + "index": 23, + "targetTabId": "2e38aeb9-4c66-440e-adbf-de538b4ddcbb", + "storeMs": 1.7000000476837158, + "paneVisibleMs": 1.7000000476837158, + "paintSettleMs": 134.10000002384186, + "timedOut": false + } + ], + "longtasks": [ + 145, 146, 89, 54, 80, 151, 138, 62, 53, 143, 151, 60, 59, 53, 142, 141, 83, 50, 129, 112, + 83, 126 + ] + }, + "workspace-switch": { + "samples": [ + { + "index": 0, + "worktreeId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo", + "ptyId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@14096c3d", + "activationMs": 281, + "ptyBindMs": 283, + "paintSettleMs": 293 + }, + { + "index": 1, + "worktreeId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-one", + "ptyId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-one@@0b21e66f", + "activationMs": 344, + "ptyBindMs": 524, + "paintSettleMs": 538 + }, + { + "index": 2, + "worktreeId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-two", + "ptyId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-two@@e63f448f", + "activationMs": 428, + "ptyBindMs": 545, + "paintSettleMs": 559 + }, + { + "index": 3, + "worktreeId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo", + "ptyId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@14096c3d", + "activationMs": 147, + "ptyBindMs": 167, + "paintSettleMs": 180 + }, + { + "index": 4, + "worktreeId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-one", + "ptyId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-one@@0b21e66f", + "activationMs": 320, + "ptyBindMs": 348, + "paintSettleMs": 356 + }, + { + "index": 5, + "worktreeId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-two", + "ptyId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-two@@e63f448f", + "activationMs": 125, + "ptyBindMs": 146, + "paintSettleMs": 155 + }, + { + "index": 6, + "worktreeId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo", + "ptyId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@14096c3d", + "activationMs": 135, + "ptyBindMs": 168, + "paintSettleMs": 175 + }, + { + "index": 7, + "worktreeId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-one", + "ptyId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-one@@0b21e66f", + "activationMs": 129, + "ptyBindMs": 155, + "paintSettleMs": 166 + }, + { + "index": 8, + "worktreeId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-two", + "ptyId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-two@@e63f448f", + "activationMs": 121, + "ptyBindMs": 147, + "paintSettleMs": 154 + }, + { + "index": 9, + "worktreeId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo", + "ptyId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@14096c3d", + "activationMs": 298, + "ptyBindMs": 318, + "paintSettleMs": 328 + }, + { + "index": 10, + "worktreeId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-one", + "ptyId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-one@@0b21e66f", + "activationMs": 113, + "ptyBindMs": 131, + "paintSettleMs": 139 + }, + { + "index": 11, + "worktreeId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-two", + "ptyId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-two@@e63f448f", + "activationMs": 117, + "ptyBindMs": 140, + "paintSettleMs": 150 + } + ] + } + }, + "summaries": [ + { + "name": "tab-create", + "samples": 8, + "timedOut": 0, + "fields": { + "storeCreateMs": { + "count": 8, + "median": 3, + "p95": 4, + "max": 4 + }, + "ptyBindMs": { + "count": 8, + "median": 553, + "p95": 6851, + "max": 6851 + }, + "firstOutputMs": { + "count": 8, + "median": 577, + "p95": 6894, + "max": 6894 + }, + "paintSettleMs": { + "count": 8, + "median": 578, + "p95": 6897, + "max": 6897 + } + }, + "longtaskCount": 25, + "longtaskMaxMs": 478 + }, + { + "name": "tab-switch", + "samples": 24, + "timedOut": 0, + "fields": { + "storeMs": { + "count": 24, + "median": 2, + "p95": 3, + "max": 3 + }, + "paneVisibleMs": { + "count": 24, + "median": 2, + "p95": 3, + "max": 3 + }, + "paintSettleMs": { + "count": 24, + "median": 99, + "p95": 159, + "max": 160 + } + }, + "longtaskCount": 22, + "longtaskMaxMs": 151 + }, + { + "name": "workspace-switch", + "samples": 12, + "timedOut": 0, + "fields": { + "activationMs": { + "count": 12, + "median": 147, + "p95": 344, + "max": 428 + }, + "ptyBindMs": { + "count": 12, + "median": 168, + "p95": 524, + "max": 545 + }, + "paintSettleMs": { + "count": 12, + "median": 180, + "p95": 538, + "max": 559 + } + }, + "longtaskCount": 0, + "longtaskMaxMs": 0 + } + ], + "finalDiagnostics": { + "hasStore": true, + "workspaceSessionReady": true, + "hydrationSucceeded": true, + "activeView": "terminal", + "activeRepoId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d", + "activeWorktreeId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-two", + "activeTabType": "terminal", + "activeTabId": "e1284ad8-dc0b-4bb7-bcd0-f2b24b8f4083", + "terminalGpuAcceleration": "auto", + "repoCount": 1, + "worktreeCountsByRepo": { + "f5b2567a-69b4-41a3-ba29-8c4bd357042d": 3 + }, + "ptyIdsByTabId": [ + "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-two@@e63f448f" + ], + "paneManagerCount": 3, + "activePane": { + "id": 1, + "leafId": "6aaaf90a-6cb7-48f9-b7c9-6869278038a7", + "ptyId": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-two@@e63f448f", + "cols": 175, + "rows": 73, + "buffer": { + "baseY": 0, + "viewportY": 0, + "cursorY": 0, + "length": 73 + } + }, + "renderingDiagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": false, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": true + } + ], + "allPaneManagersDiagnostics": [ + { + "tabId": "93e4a957-3325-4987-a180-5dac10bebbc0", + "diagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": true, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": true + } + ] + }, + { + "tabId": "6f6ad43f-ecf2-47c1-b6dd-816bfc4638d7", + "diagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": true, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": true + } + ] + }, + { + "tabId": "e1284ad8-dc0b-4bb7-bcd0-f2b24b8f4083", + "diagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": false, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": true + } + ] + } + ], + "webglContextCounts": { + "attachedWebglCount": 3, + "deferredWebglCount": 2, + "managerCount": 3, + "paneCount": 3 + }, + "webglIdentity": { + "available": true, + "vendor": "Google Inc. (Intel)", + "renderer": "ANGLE (Intel, Intel(R) Arc(TM) Graphics (0x00007D55) Direct3D11 vs_5_0 ps_5_0, D3D11)" + }, + "rendererProbe": { + "intervalMs": 50, + "last": 39444.30000001192, + "maxDriftMs": 504, + "samples": 351, + "startedAt": 11431.800000011921, + "lastTickAt": 39444.30000001192 + }, + "ptySessions": { + "value": [ + { + "id": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@14096c3d", + "cwd": "", + "title": "shell" + }, + { + "id": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-one@@0b21e66f", + "cwd": "", + "title": "shell" + }, + { + "id": "f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-two@@e63f448f", + "cwd": "", + "title": "shell" + } + ] + }, + "rendererDeliveryDebug": { + "value": { + "pendingPtyCount": 0, + "pendingChars": 0, + "maxPendingCharsByPty": 0, + "rendererInFlightPtyCount": 0, + "rendererInFlightChars": 0, + "maxRendererInFlightCharsByPty": 0, + "activeRendererPtyCount": 1, + "flushScheduled": false, + "peakPendingChars": 82, + "peakMaxPendingCharsByPty": 82, + "peakRendererInFlightChars": 82, + "peakMaxRendererInFlightCharsByPty": 82, + "ackGatedFlushSkipCount": 0 + } + } + }, + "cleanupErrors": [], + "elapsedMs": 53646, + "appLogsTail": [ + { + "source": "stderr", + "line": "[startup] renderer-fetch-folder-workspaces-done t=13271 rendererT=10691 durationMs=32", + "at": 1782978685796 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-worktrees-done t=13272 rendererT=10691 durationMs=0", + "at": 1782978685797 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-worktree-lineage-done t=13288 rendererT=10708 durationMs=17", + "at": 1782978685813 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-worktree-lineage-done t=13290 rendererT=10709 durationMs=19", + "at": 1782978685815 + }, + { + "source": "stderr", + "line": "[startup] renderer-ui-get-done t=13303 rendererT=10723 durationMs=14", + "at": 1782978685828 + }, + { + "source": "stderr", + "line": "[startup] renderer-hydrate-persisted-ui-done t=13303 rendererT=10723 durationMs=0", + "at": 1782978685828 + }, + { + "source": "stderr", + "line": "[startup] renderer-ui-get-done t=13305 rendererT=10725 durationMs=15", + "at": 1782978685830 + }, + { + "source": "stderr", + "line": "[startup] renderer-hydrate-persisted-ui-done t=13305 rendererT=10725 durationMs=0", + "at": 1782978685830 + }, + { + "source": "stderr", + "line": "[startup] renderer-session-get-done t=13313 rendererT=10733 durationMs=10", + "at": 1782978685838 + }, + { + "source": "stderr", + "line": "[startup] renderer-session-get-done t=13314 rendererT=10734 durationMs=8", + "at": 1782978685839 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-keybindings-done t=13828 rendererT=10799 durationMs=66", + "at": 1782978686354 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-keybindings-done t=13829 rendererT=10833 durationMs=99", + "at": 1782978686354 + }, + { + "source": "stderr", + "line": "[startup] renderer-hydrate-session-stores-done t=13829 rendererT=10837 durationMs=4", + "at": 1782978686354 + }, + { + "source": "stderr", + "line": "[startup] renderer-visit-timestamp-prune-done t=13829 rendererT=10838 durationMs=1", + "at": 1782978686354 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-browser-session-profiles-done t=13840 rendererT=11257 durationMs=418", + "at": 1782978686365 + }, + { + "source": "stderr", + "line": "[startup] renderer-onboarding-get-done t=13842 rendererT=11260 durationMs=4", + "at": 1782978686367 + }, + { + "source": "stderr", + "line": "[startup] renderer-ssh-reconnect-skipped t=13842 rendererT=11261 connectionIds=0", + "at": 1782978686367 + }, + { + "source": "stderr", + "line": "[startup] renderer-first-window-services-await-done t=13880 rendererT=11295 durationMs=34", + "at": 1782978686405 + }, + { + "source": "stderr", + "line": "[startup] renderer-reconnect-terminals-done t=13914 rendererT=11334 durationMs=39", + "at": 1782978686439 + }, + { + "source": "stderr", + "line": "[startup] renderer-startup-hydration-done t=13916 rendererT=11335 durationMs=1709", + "at": 1782978686441 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=14429 maxGapMs=446", + "at": 1782978686954 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=16441 maxGapMs=409", + "at": 1782978688966 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=18453 maxGapMs=7", + "at": 1782978690978 + }, + { + "source": "stderr", + "line": "[48724:0702/035132.336:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=93e4a957-3325-4987-a180-5dac10bebbc0 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=93e4a957-3325-4987-a180-5dac10bebbc0:d285da80-e34b-422f-a0a4-14a68a6c5cb5\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978692336 + }, + { + "source": "stderr", + "line": "[48724:0702/035132.336:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978692336 + }, + { + "source": "stderr", + "line": "[48724:0702/035132.387:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=766de809-5863-49f7-a603-3faa5fdf4693 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=766de809-5863-49f7-a603-3faa5fdf4693:3747de82-0192-46c3-a130-e114e78bb00d\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978692387 + }, + { + "source": "stderr", + "line": "[48724:0702/035132.387:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978692387 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=20469 maxGapMs=30", + "at": 1782978692994 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=22492 maxGapMs=7", + "at": 1782978695018 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@14096c3d total=2796ms preflight=0ms auth=0ms host_env=47ms options=0ms provider_spawn=2749ms daemon=true reattach=false", + "at": 1782978695134 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@ccc87346 total=2805ms preflight=0ms auth=0ms host_env=52ms options=0ms provider_spawn=2753ms daemon=true reattach=false", + "at": 1782978695192 + }, + { + "source": "stderr", + "line": "[48724:0702/035136.133:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=9efaa7fd-442b-4efe-bf23-52d7143a0219 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=9efaa7fd-442b-4efe-bf23-52d7143a0219:df4d3047-f992-48ce-b5f1-6d0637523a9e\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978696133 + }, + { + "source": "stderr", + "line": "[48724:0702/035136.133:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978696133 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@ec755bef total=116ms preflight=0ms auth=0ms host_env=51ms options=0ms provider_spawn=65ms daemon=true reattach=false", + "at": 1782978696250 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=24506 maxGapMs=36", + "at": 1782978697031 + }, + { + "source": "stderr", + "line": "[48724:0702/035137.119:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=694f725c-29d1-4107-8648-81d285f6ab4b restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=694f725c-29d1-4107-8648-81d285f6ab4b:90413477-6d54-4dda-8c37-59e718741517\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978697120 + }, + { + "source": "stderr", + "line": "[48724:0702/035137.120:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978697120 + }, + { + "source": "stderr", + "line": "[48724:0702/035137.171:INFO:CONSOLE:397] \"Tooltip is changing from controlled to uncontrolled. Components should not switch from controlled to uncontrolled (or vice versa). Decide between using a controlled or uncontrolled value for the lifetime of the component.\", source: http://localhost:5173/@fs/C:/Users/jinwo/orca/workspaces/orca/windows-performance-improvement/node_modules/.vite/deps/chunk-22L5D3A7.js?v=43681c89 (397)", + "at": 1782978697172 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@90e4e78e total=113ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=65ms daemon=true reattach=false", + "at": 1782978697234 + }, + { + "source": "stderr", + "line": "[48724:0702/035138.128:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=adc6529c-4b75-45a8-8ea2-1bcf637da544 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=adc6529c-4b75-45a8-8ea2-1bcf637da544:91dc16c6-f567-4e05-994c-94ae19376ee7\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978698128 + }, + { + "source": "stderr", + "line": "[48724:0702/035138.128:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978698128 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@b172fe7e total=118ms preflight=0ms auth=0ms host_env=54ms options=0ms provider_spawn=64ms daemon=true reattach=false", + "at": 1782978698247 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=26510 maxGapMs=51", + "at": 1782978699035 + }, + { + "source": "stderr", + "line": "[48724:0702/035139.097:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=f891d153-855c-43cb-9fdb-fd8682070724 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=f891d153-855c-43cb-9fdb-fd8682070724:5d0971c2-c663-4e44-ae1c-825dc948489a\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978699097 + }, + { + "source": "stderr", + "line": "[48724:0702/035139.097:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978699097 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@2ff2b79f total=113ms preflight=0ms auth=0ms host_env=49ms options=0ms provider_spawn=64ms daemon=true reattach=false", + "at": 1782978699211 + }, + { + "source": "stderr", + "line": "[48724:0702/035140.045:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=939ae529-7ad7-4d46-b4c0-4b3997ab6b98 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=939ae529-7ad7-4d46-b4c0-4b3997ab6b98:b3224a5c-19ae-42b8-b756-965f04806e49\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978700046 + }, + { + "source": "stderr", + "line": "[48724:0702/035140.046:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978700046 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@0965fd8c total=114ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=66ms daemon=true reattach=false", + "at": 1782978700161 + }, + { + "source": "stderr", + "line": "[48724:0702/035141.002:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=5749668e-dbd3-41ff-bbe5-c93c12925763 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=5749668e-dbd3-41ff-bbe5-c93c12925763:e3a00c11-d53b-4747-b215-ce9f19bb0da0\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978701002 + }, + { + "source": "stderr", + "line": "[48724:0702/035141.002:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978701002 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=28532 maxGapMs=50", + "at": 1782978701057 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@9c46ce32 total=124ms preflight=0ms auth=0ms host_env=54ms options=0ms provider_spawn=70ms daemon=true reattach=false", + "at": 1782978701126 + }, + { + "source": "stderr", + "line": "[48724:0702/035141.986:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=2546b0b6-5198-4067-b59b-698e914160d0 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=2546b0b6-5198-4067-b59b-698e914160d0:fecf7693-17a1-4cdc-8d35-1ba415e0760d\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978701986 + }, + { + "source": "stderr", + "line": "[48724:0702/035141.986:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978701986 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@ea5607d8 total=116ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=68ms daemon=true reattach=false", + "at": 1782978702102 + }, + { + "source": "stderr", + "line": "[48724:0702/035143.024:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=87a752ed-2a66-411e-8c00-b7b0b24b5d10 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=87a752ed-2a66-411e-8c00-b7b0b24b5d10:9258e63f-4dee-455c-8f8f-543569e4ce24\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978703024 + }, + { + "source": "stderr", + "line": "[48724:0702/035143.024:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978703025 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=30557 maxGapMs=47", + "at": 1782978703082 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@10666ccc total=116ms preflight=0ms auth=0ms host_env=53ms options=0ms provider_spawn=63ms daemon=true reattach=false", + "at": 1782978703141 + }, + { + "source": "stderr", + "line": "[48724:0702/035143.510:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=03cc8628-e72d-478c-90f7-3ed264097afb restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=03cc8628-e72d-478c-90f7-3ed264097afb:b30207cc-752f-4836-8ea5-6f095e97f32d\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978703510 + }, + { + "source": "stderr", + "line": "[48724:0702/035143.510:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978703510 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@566d348e total=120ms preflight=0ms auth=0ms host_env=52ms options=0ms provider_spawn=68ms daemon=true reattach=false", + "at": 1782978703630 + }, + { + "source": "stderr", + "line": "[48724:0702/035144.065:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=993f77d3-4e18-4b17-b386-adead86c80c8 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=993f77d3-4e18-4b17-b386-adead86c80c8:ee549cc0-14c4-4211-922b-918f0810da85\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978704066 + }, + { + "source": "stderr", + "line": "[48724:0702/035144.066:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978704066 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@aedde862 total=122ms preflight=0ms auth=0ms host_env=51ms options=0ms provider_spawn=71ms daemon=true reattach=false", + "at": 1782978704188 + }, + { + "source": "stderr", + "line": "[48724:0702/035144.838:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=2e38aeb9-4c66-440e-adbf-de538b4ddcbb restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=2e38aeb9-4c66-440e-adbf-de538b4ddcbb:26a39262-f10c-4e3a-a246-8646617d0b05\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978704838 + }, + { + "source": "stderr", + "line": "[48724:0702/035144.838:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978704838 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@dd291490 total=147ms preflight=0ms auth=0ms host_env=54ms options=0ms provider_spawn=93ms daemon=true reattach=false", + "at": 1782978704985 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=32571 maxGapMs=116", + "at": 1782978705096 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=34594 maxGapMs=18", + "at": 1782978707119 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=36594 maxGapMs=13", + "at": 1782978709119 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=38594 maxGapMs=11", + "at": 1782978711119 + }, + { + "source": "stderr", + "line": "[48724:0702/035151.953:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=6f6ad43f-ecf2-47c1-b6dd-816bfc4638d7 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=6f6ad43f-ecf2-47c1-b6dd-816bfc4638d7:9002711a-d8da-4d46-a938-d1ab106322eb\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978711953 + }, + { + "source": "stderr", + "line": "[48724:0702/035151.953:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978711953 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-one@@0b21e66f total=115ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=67ms daemon=true reattach=false", + "at": 1782978712070 + }, + { + "source": "stderr", + "line": "[48724:0702/035152.571:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=e1284ad8-dc0b-4bb7-bcd0-f2b24b8f4083 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=e1284ad8-dc0b-4bb7-bcd0-f2b24b8f4083:6aaaf90a-6cb7-48f9-b7c9-6869278038a7\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978712571 + }, + { + "source": "stderr", + "line": "[48724:0702/035152.571:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782978712571 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-two@@e63f448f total=118ms preflight=0ms auth=1ms host_env=54ms options=0ms provider_spawn=63ms daemon=true reattach=false", + "at": 1782978712689 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=40618 maxGapMs=40", + "at": 1782978713143 + } + ], + "ptySpawnTimings": [ + "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@14096c3d total=2796ms preflight=0ms auth=0ms host_env=47ms options=0ms provider_spawn=2749ms daemon=true reattach=false", + "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@ccc87346 total=2805ms preflight=0ms auth=0ms host_env=52ms options=0ms provider_spawn=2753ms daemon=true reattach=false", + "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@ec755bef total=116ms preflight=0ms auth=0ms host_env=51ms options=0ms provider_spawn=65ms daemon=true reattach=false", + "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@90e4e78e total=113ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=65ms daemon=true reattach=false", + "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@b172fe7e total=118ms preflight=0ms auth=0ms host_env=54ms options=0ms provider_spawn=64ms daemon=true reattach=false", + "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@2ff2b79f total=113ms preflight=0ms auth=0ms host_env=49ms options=0ms provider_spawn=64ms daemon=true reattach=false", + "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@0965fd8c total=114ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=66ms daemon=true reattach=false", + "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@9c46ce32 total=124ms preflight=0ms auth=0ms host_env=54ms options=0ms provider_spawn=70ms daemon=true reattach=false", + "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@ea5607d8 total=116ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=68ms daemon=true reattach=false", + "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@10666ccc total=116ms preflight=0ms auth=0ms host_env=53ms options=0ms provider_spawn=63ms daemon=true reattach=false", + "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@566d348e total=120ms preflight=0ms auth=0ms host_env=52ms options=0ms provider_spawn=68ms daemon=true reattach=false", + "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@aedde862 total=122ms preflight=0ms auth=0ms host_env=51ms options=0ms provider_spawn=71ms daemon=true reattach=false", + "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/repo@@dd291490 total=147ms preflight=0ms auth=0ms host_env=54ms options=0ms provider_spawn=93ms daemon=true reattach=false", + "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-one@@0b21e66f total=115ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=67ms daemon=true reattach=false", + "[pty-spawn-timing] id=f5b2567a-69b4-41a3-ba29-8c4bd357042d::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-J3tKUm/wt-two@@e63f448f total=118ms preflight=0ms auth=1ms host_env=54ms options=0ms provider_spawn=63ms daemon=true reattach=false" + ] +} diff --git a/tools/benchmarks/results/terminal-perf-fix-abdf-2026-07-02T07-59-11-820Z.json b/tools/benchmarks/results/terminal-perf-fix-abdf-2026-07-02T07-59-11-820Z.json new file mode 100644 index 00000000000..c4e2c07b18f --- /dev/null +++ b/tools/benchmarks/results/terminal-perf-fix-abdf-2026-07-02T07-59-11-820Z.json @@ -0,0 +1,1058 @@ +{ + "label": "fix-abdf", + "startedAt": "2026-07-02T07:59:11.820Z", + "platform": "win32 10.0.26200", + "shell": "default", + "args": { + "iterations": 8, + "switches": 24, + "cycles": 12 + }, + "scenarios": { + "tab-create": { + "samples": [ + { + "index": 0, + "storeCreateMs": 3.299999952316284, + "ptyBindMs": 4816.899999976158, + "firstOutputMs": 4856.899999976158, + "paintSettleMs": 4864, + "timedOut": false + }, + { + "index": 1, + "storeCreateMs": 2.800000011920929, + "ptyBindMs": 529, + "firstOutputMs": 554.5, + "paintSettleMs": 556.8000000119209, + "timedOut": false + }, + { + "index": 2, + "storeCreateMs": 2.400000035762787, + "ptyBindMs": 423.10000002384186, + "firstOutputMs": 464.80000001192093, + "paintSettleMs": 470, + "timedOut": false + }, + { + "index": 3, + "storeCreateMs": 1.800000011920929, + "ptyBindMs": 438.4000000357628, + "firstOutputMs": 453.69999998807907, + "paintSettleMs": 455.80000001192093, + "timedOut": false + }, + { + "index": 4, + "storeCreateMs": 1.600000023841858, + "ptyBindMs": 497.10000002384186, + "firstOutputMs": 508.80000001192093, + "paintSettleMs": 515.8999999761581, + "timedOut": false + }, + { + "index": 5, + "storeCreateMs": 1.9000000357627869, + "ptyBindMs": 471.80000001192093, + "firstOutputMs": 488.10000002384186, + "paintSettleMs": 491.30000001192093, + "timedOut": false + }, + { + "index": 6, + "storeCreateMs": 1.199999988079071, + "ptyBindMs": 512.6999999880791, + "firstOutputMs": 524.2999999523163, + "paintSettleMs": 531.6999999880791, + "timedOut": false + }, + { + "index": 7, + "storeCreateMs": 1.5, + "ptyBindMs": 468.10000002384186, + "firstOutputMs": 483.19999998807907, + "paintSettleMs": 484.19999998807907, + "timedOut": false + } + ], + "longtasks": [ + 52, 397, 67, 187, 133, 214, 192, 152, 196, 90, 154, 255, 61, 71, 296, 76, 68, 258, 92, 68, + 244, 161, 63, 176, 178, 137 + ] + }, + "tab-switch": { + "samples": [ + { + "index": 0, + "targetTabId": "aa62c8e8-2ef4-4aa3-ac4d-b9063cfa527e", + "storeMs": 1.9000000357627869, + "paneVisibleMs": 1.9000000357627869, + "paintSettleMs": 76.20000004768372, + "timedOut": false + }, + { + "index": 1, + "targetTabId": "66083463-228e-4fb0-90c5-231dc75e8da6", + "storeMs": 1.0999999642372131, + "paneVisibleMs": 1.0999999642372131, + "paintSettleMs": 113.19999998807907, + "timedOut": false + }, + { + "index": 2, + "targetTabId": "b99bb441-7027-40a9-beb2-a558ece6f979", + "storeMs": 2.5, + "paneVisibleMs": 2.5, + "paintSettleMs": 130.9000000357628, + "timedOut": false + }, + { + "index": 3, + "targetTabId": "490084ae-1bdb-4359-bb86-097e26902f98", + "storeMs": 2.099999964237213, + "paneVisibleMs": 2.099999964237213, + "paintSettleMs": 70.09999996423721, + "timedOut": false + }, + { + "index": 4, + "targetTabId": "aa62c8e8-2ef4-4aa3-ac4d-b9063cfa527e", + "storeMs": 1.899999976158142, + "paneVisibleMs": 1.899999976158142, + "paintSettleMs": 54.19999998807907, + "timedOut": false + }, + { + "index": 5, + "targetTabId": "66083463-228e-4fb0-90c5-231dc75e8da6", + "storeMs": 2.5, + "paneVisibleMs": 2.5, + "paintSettleMs": 52.69999998807907, + "timedOut": false + }, + { + "index": 6, + "targetTabId": "b99bb441-7027-40a9-beb2-a558ece6f979", + "storeMs": 1.699999988079071, + "paneVisibleMs": 1.699999988079071, + "paintSettleMs": 119.30000001192093, + "timedOut": false + }, + { + "index": 7, + "targetTabId": "490084ae-1bdb-4359-bb86-097e26902f98", + "storeMs": 1.399999976158142, + "paneVisibleMs": 1.399999976158142, + "paintSettleMs": 104.29999995231628, + "timedOut": false + }, + { + "index": 8, + "targetTabId": "aa62c8e8-2ef4-4aa3-ac4d-b9063cfa527e", + "storeMs": 2.399999976158142, + "paneVisibleMs": 2.5, + "paintSettleMs": 78.39999997615814, + "timedOut": false + }, + { + "index": 9, + "targetTabId": "66083463-228e-4fb0-90c5-231dc75e8da6", + "storeMs": 1.699999988079071, + "paneVisibleMs": 1.699999988079071, + "paintSettleMs": 49.59999996423721, + "timedOut": false + }, + { + "index": 10, + "targetTabId": "b99bb441-7027-40a9-beb2-a558ece6f979", + "storeMs": 1.300000011920929, + "paneVisibleMs": 1.300000011920929, + "paintSettleMs": 47.10000002384186, + "timedOut": false + }, + { + "index": 11, + "targetTabId": "490084ae-1bdb-4359-bb86-097e26902f98", + "storeMs": 3.100000023841858, + "paneVisibleMs": 3.100000023841858, + "paintSettleMs": 120.30000001192093, + "timedOut": false + }, + { + "index": 12, + "targetTabId": "aa62c8e8-2ef4-4aa3-ac4d-b9063cfa527e", + "storeMs": 1.600000023841858, + "paneVisibleMs": 1.699999988079071, + "paintSettleMs": 108.19999998807907, + "timedOut": false + }, + { + "index": 13, + "targetTabId": "66083463-228e-4fb0-90c5-231dc75e8da6", + "storeMs": 1.699999988079071, + "paneVisibleMs": 1.699999988079071, + "paintSettleMs": 51.69999998807907, + "timedOut": false + }, + { + "index": 14, + "targetTabId": "b99bb441-7027-40a9-beb2-a558ece6f979", + "storeMs": 1.600000023841858, + "paneVisibleMs": 1.600000023841858, + "paintSettleMs": 45.30000001192093, + "timedOut": false + }, + { + "index": 15, + "targetTabId": "490084ae-1bdb-4359-bb86-097e26902f98", + "storeMs": 1.699999988079071, + "paneVisibleMs": 1.699999988079071, + "paintSettleMs": 44.69999998807907, + "timedOut": false + }, + { + "index": 16, + "targetTabId": "aa62c8e8-2ef4-4aa3-ac4d-b9063cfa527e", + "storeMs": 1.399999976158142, + "paneVisibleMs": 1.399999976158142, + "paintSettleMs": 58.69999998807907, + "timedOut": false + }, + { + "index": 17, + "targetTabId": "66083463-228e-4fb0-90c5-231dc75e8da6", + "storeMs": 1.899999976158142, + "paneVisibleMs": 1.899999976158142, + "paintSettleMs": 136.5999999642372, + "timedOut": false + }, + { + "index": 18, + "targetTabId": "b99bb441-7027-40a9-beb2-a558ece6f979", + "storeMs": 2, + "paneVisibleMs": 2, + "paintSettleMs": 136.0999999642372, + "timedOut": false + }, + { + "index": 19, + "targetTabId": "490084ae-1bdb-4359-bb86-097e26902f98", + "storeMs": 1.600000023841858, + "paneVisibleMs": 1.600000023841858, + "paintSettleMs": 53.80000001192093, + "timedOut": false + }, + { + "index": 20, + "targetTabId": "aa62c8e8-2ef4-4aa3-ac4d-b9063cfa527e", + "storeMs": 2.100000023841858, + "paneVisibleMs": 2.100000023841858, + "paintSettleMs": 51.5, + "timedOut": false + }, + { + "index": 21, + "targetTabId": "66083463-228e-4fb0-90c5-231dc75e8da6", + "storeMs": 1.800000011920929, + "paneVisibleMs": 1.800000011920929, + "paintSettleMs": 122.40000003576279, + "timedOut": false + }, + { + "index": 22, + "targetTabId": "b99bb441-7027-40a9-beb2-a558ece6f979", + "storeMs": 1.699999988079071, + "paneVisibleMs": 1.699999988079071, + "paintSettleMs": 118.5, + "timedOut": false + }, + { + "index": 23, + "targetTabId": "490084ae-1bdb-4359-bb86-097e26902f98", + "storeMs": 2.300000011920929, + "paneVisibleMs": 2.300000011920929, + "paintSettleMs": 62.69999998807907, + "timedOut": false + } + ], + "longtasks": [66, 102, 123, 64, 115, 96, 74, 113, 102, 52, 125, 130, 117, 110, 53] + }, + "workspace-switch": { + "samples": [ + { + "index": 0, + "worktreeId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo", + "ptyId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@5810ae88", + "activationMs": 217, + "ptyBindMs": 219, + "paintSettleMs": 233 + }, + { + "index": 1, + "worktreeId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-one", + "ptyId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-one@@c450cf26", + "activationMs": 480, + "ptyBindMs": 575, + "paintSettleMs": 601 + }, + { + "index": 2, + "worktreeId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-two", + "ptyId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-two@@ae6fdb06", + "activationMs": 382, + "ptyBindMs": 557, + "paintSettleMs": 573 + }, + { + "index": 3, + "worktreeId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo", + "ptyId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@5810ae88", + "activationMs": 268, + "ptyBindMs": 295, + "paintSettleMs": 299 + }, + { + "index": 4, + "worktreeId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-one", + "ptyId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-one@@c450cf26", + "activationMs": 154, + "ptyBindMs": 178, + "paintSettleMs": 184 + }, + { + "index": 5, + "worktreeId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-two", + "ptyId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-two@@ae6fdb06", + "activationMs": 145, + "ptyBindMs": 166, + "paintSettleMs": 178 + }, + { + "index": 6, + "worktreeId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo", + "ptyId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@5810ae88", + "activationMs": 159, + "ptyBindMs": 184, + "paintSettleMs": 192 + }, + { + "index": 7, + "worktreeId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-one", + "ptyId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-one@@c450cf26", + "activationMs": 328, + "ptyBindMs": 349, + "paintSettleMs": 354 + }, + { + "index": 8, + "worktreeId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-two", + "ptyId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-two@@ae6fdb06", + "activationMs": 139, + "ptyBindMs": 158, + "paintSettleMs": 167 + }, + { + "index": 9, + "worktreeId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo", + "ptyId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@5810ae88", + "activationMs": 150, + "ptyBindMs": 177, + "paintSettleMs": 183 + }, + { + "index": 10, + "worktreeId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-one", + "ptyId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-one@@c450cf26", + "activationMs": 150, + "ptyBindMs": 169, + "paintSettleMs": 179 + }, + { + "index": 11, + "worktreeId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-two", + "ptyId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-two@@ae6fdb06", + "activationMs": 134, + "ptyBindMs": 166, + "paintSettleMs": 182 + } + ] + } + }, + "summaries": [ + { + "name": "tab-create", + "samples": 8, + "timedOut": 0, + "fields": { + "storeCreateMs": { + "count": 8, + "median": 2, + "p95": 3, + "max": 3 + }, + "ptyBindMs": { + "count": 8, + "median": 497, + "p95": 4817, + "max": 4817 + }, + "firstOutputMs": { + "count": 8, + "median": 509, + "p95": 4857, + "max": 4857 + }, + "paintSettleMs": { + "count": 8, + "median": 516, + "p95": 4864, + "max": 4864 + } + }, + "longtaskCount": 26, + "longtaskMaxMs": 397 + }, + { + "name": "tab-switch", + "samples": 24, + "timedOut": 0, + "fields": { + "storeMs": { + "count": 24, + "median": 2, + "p95": 3, + "max": 3 + }, + "paneVisibleMs": { + "count": 24, + "median": 2, + "p95": 3, + "max": 3 + }, + "paintSettleMs": { + "count": 24, + "median": 76, + "p95": 136, + "max": 137 + } + }, + "longtaskCount": 15, + "longtaskMaxMs": 130 + }, + { + "name": "workspace-switch", + "samples": 12, + "timedOut": 0, + "fields": { + "activationMs": { + "count": 12, + "median": 159, + "p95": 382, + "max": 480 + }, + "ptyBindMs": { + "count": 12, + "median": 184, + "p95": 557, + "max": 575 + }, + "paintSettleMs": { + "count": 12, + "median": 192, + "p95": 573, + "max": 601 + } + }, + "longtaskCount": 0, + "longtaskMaxMs": 0 + } + ], + "finalDiagnostics": { + "hasStore": true, + "workspaceSessionReady": true, + "hydrationSucceeded": true, + "activeView": "terminal", + "activeRepoId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b", + "activeWorktreeId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-two", + "activeTabType": "terminal", + "activeTabId": "15055907-3232-4ca1-8814-e5484ba78a5b", + "terminalGpuAcceleration": "auto", + "repoCount": 1, + "worktreeCountsByRepo": { + "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b": 3 + }, + "ptyIdsByTabId": [ + "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-two@@ae6fdb06" + ], + "paneManagerCount": 3, + "activePane": { + "id": 1, + "leafId": "78b7478c-39a9-4111-aed8-1e2ddf568c24", + "ptyId": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-two@@ae6fdb06", + "cols": 175, + "rows": 73, + "buffer": { + "baseY": 0, + "viewportY": 0, + "cursorY": 0, + "length": 73 + } + }, + "renderingDiagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": false, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": true + } + ], + "allPaneManagersDiagnostics": [ + { + "tabId": "c46e04e4-af5f-4674-9a38-72253480baef", + "diagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": true, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": true + } + ] + }, + { + "tabId": "769013c6-b5b8-4be9-b188-4444a6094567", + "diagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": true, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": true + } + ] + }, + { + "tabId": "15055907-3232-4ca1-8814-e5484ba78a5b", + "diagnostics": [ + { + "paneId": 1, + "terminalGpuAcceleration": "auto", + "gpuRenderingEnabled": true, + "webglAttachmentDeferred": false, + "webglDisabledAfterContextLoss": false, + "hasComplexScriptOutput": false, + "terminalWebglAutoDecision": { + "allowWebgl": true, + "reason": "non-linux", + "renderer": null, + "vendor": null + }, + "hasWebgl": true + } + ] + } + ], + "webglContextCounts": { + "attachedWebglCount": 3, + "deferredWebglCount": 2, + "managerCount": 3, + "paneCount": 3 + }, + "webglIdentity": { + "available": true, + "vendor": "Google Inc. (Intel)", + "renderer": "ANGLE (Intel, Intel(R) Arc(TM) Graphics (0x00007D55) Direct3D11 vs_5_0 ps_5_0, D3D11)" + }, + "rendererProbe": { + "intervalMs": 50, + "last": 35644.5, + "maxDriftMs": 413.89999997615814, + "samples": 309, + "startedAt": 11829.5, + "lastTickAt": 35644.5 + }, + "ptySessions": { + "value": [ + { + "id": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@5810ae88", + "cwd": "", + "title": "shell" + }, + { + "id": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-one@@c450cf26", + "cwd": "", + "title": "shell" + }, + { + "id": "cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-two@@ae6fdb06", + "cwd": "", + "title": "shell" + } + ] + }, + "rendererDeliveryDebug": { + "value": { + "pendingPtyCount": 0, + "pendingChars": 0, + "maxPendingCharsByPty": 0, + "rendererInFlightPtyCount": 0, + "rendererInFlightChars": 0, + "maxRendererInFlightCharsByPty": 0, + "activeRendererPtyCount": 1, + "flushScheduled": false, + "peakPendingChars": 82, + "peakMaxPendingCharsByPty": 82, + "peakRendererInFlightChars": 82, + "peakMaxRendererInFlightCharsByPty": 82, + "ackGatedFlushSkipCount": 0 + } + } + }, + "cleanupErrors": [], + "elapsedMs": 50234, + "appLogsTail": [ + { + "source": "stderr", + "line": "[startup] renderer-fetch-folder-workspaces-done t=13833 rendererT=11239 durationMs=53", + "at": 1782979177594 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-worktrees-done t=13833 rendererT=11240 durationMs=1", + "at": 1782979177594 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-folder-workspaces-done t=13835 rendererT=11241 durationMs=55", + "at": 1782979177596 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-worktrees-done t=13835 rendererT=11242 durationMs=0", + "at": 1782979177596 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-worktree-lineage-done t=13838 rendererT=11246 durationMs=6", + "at": 1782979177599 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-worktree-lineage-done t=13841 rendererT=11249 durationMs=8", + "at": 1782979177602 + }, + { + "source": "stderr", + "line": "[startup] renderer-ui-get-done t=13842 rendererT=11250 durationMs=4", + "at": 1782979177603 + }, + { + "source": "stderr", + "line": "[startup] renderer-hydrate-persisted-ui-done t=13842 rendererT=11250 durationMs=0", + "at": 1782979177603 + }, + { + "source": "stderr", + "line": "[startup] renderer-ui-get-done t=13844 rendererT=11251 durationMs=2", + "at": 1782979177605 + }, + { + "source": "stderr", + "line": "[startup] renderer-hydrate-persisted-ui-done t=13844 rendererT=11251 durationMs=0", + "at": 1782979177605 + }, + { + "source": "stderr", + "line": "[startup] renderer-session-get-done t=13844 rendererT=11252 durationMs=2", + "at": 1782979177605 + }, + { + "source": "stderr", + "line": "[startup] renderer-session-get-done t=13846 rendererT=11254 durationMs=3", + "at": 1782979177607 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-keybindings-done t=14263 rendererT=11346 durationMs=94", + "at": 1782979178024 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-keybindings-done t=14263 rendererT=11426 durationMs=172", + "at": 1782979178024 + }, + { + "source": "stderr", + "line": "[startup] renderer-hydrate-session-stores-done t=14263 rendererT=11430 durationMs=4", + "at": 1782979178024 + }, + { + "source": "stderr", + "line": "[startup] renderer-visit-timestamp-prune-done t=14263 rendererT=11431 durationMs=1", + "at": 1782979178024 + }, + { + "source": "stderr", + "line": "[startup] renderer-fetch-browser-session-profiles-done t=14277 rendererT=11685 durationMs=255", + "at": 1782979178038 + }, + { + "source": "stderr", + "line": "[startup] renderer-onboarding-get-done t=14282 rendererT=11691 durationMs=5", + "at": 1782979178043 + }, + { + "source": "stderr", + "line": "[startup] renderer-ssh-reconnect-skipped t=14283 rendererT=11691 connectionIds=0", + "at": 1782979178044 + }, + { + "source": "stderr", + "line": "[startup] renderer-first-window-services-await-done t=14324 rendererT=11732 durationMs=41", + "at": 1782979178085 + }, + { + "source": "stderr", + "line": "[startup] renderer-reconnect-terminals-done t=14366 rendererT=11774 durationMs=42", + "at": 1782979178127 + }, + { + "source": "stderr", + "line": "[startup] renderer-startup-hydration-done t=14367 rendererT=11775 durationMs=1992", + "at": 1782979178128 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=14580 maxGapMs=453", + "at": 1782979178342 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=16597 maxGapMs=336", + "at": 1782979180358 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=18612 maxGapMs=15", + "at": 1782979182373 + }, + { + "source": "stderr", + "line": "[47624:0702/035942.829:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=c46e04e4-af5f-4674-9a38-72253480baef restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=c46e04e4-af5f-4674-9a38-72253480baef:8089e7c5-e739-4785-b3b4-6844f99c0a86\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979182829 + }, + { + "source": "stderr", + "line": "[47624:0702/035942.829:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979182829 + }, + { + "source": "stderr", + "line": "[47624:0702/035942.876:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=e62346f1-a808-4397-8be7-422efa0aaebb restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=e62346f1-a808-4397-8be7-422efa0aaebb:e9b9d9ce-241c-458b-be93-01a657de7fd7\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979182877 + }, + { + "source": "stderr", + "line": "[47624:0702/035942.876:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979182877 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@5810ae88 total=1300ms preflight=0ms auth=1ms host_env=44ms options=0ms provider_spawn=1255ms daemon=true reattach=false", + "at": 1782979184130 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@2c485ebc total=1308ms preflight=0ms auth=0ms host_env=49ms options=0ms provider_spawn=1259ms daemon=true reattach=false", + "at": 1782979184185 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=20628 maxGapMs=48", + "at": 1782979184389 + }, + { + "source": "stderr", + "line": "[47624:0702/035945.089:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=894d9793-e2ae-40bf-864e-eef357f271c1 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=894d9793-e2ae-40bf-864e-eef357f271c1:1b683a77-2230-4238-96eb-5118ea595604\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979185089 + }, + { + "source": "stderr", + "line": "[47624:0702/035945.089:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979185089 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@c3018b1f total=108ms preflight=0ms auth=0ms host_env=47ms options=0ms provider_spawn=61ms daemon=true reattach=false", + "at": 1782979185197 + }, + { + "source": "stderr", + "line": "[47624:0702/035946.036:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=8fd459b0-a0a8-416d-b698-521da990ace3 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=8fd459b0-a0a8-416d-b698-521da990ace3:74040bad-c0d6-42c6-af48-21150176be1c\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979186035 + }, + { + "source": "stderr", + "line": "[47624:0702/035946.036:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979186035 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@59074e83 total=123ms preflight=0ms auth=0ms host_env=50ms options=0ms provider_spawn=73ms daemon=true reattach=false", + "at": 1782979186159 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=22635 maxGapMs=34", + "at": 1782979186396 + }, + { + "source": "stderr", + "line": "[47624:0702/035946.971:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=6559b19f-a2e8-4691-8057-2efd9ba67d0e restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=6559b19f-a2e8-4691-8057-2efd9ba67d0e:8b41e717-29fe-49fb-a710-c96e41a1d112\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979186971 + }, + { + "source": "stderr", + "line": "[47624:0702/035946.971:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979186971 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@562113d2 total=111ms preflight=0ms auth=0ms host_env=51ms options=0ms provider_spawn=60ms daemon=true reattach=false", + "at": 1782979187082 + }, + { + "source": "stderr", + "line": "[47624:0702/035947.872:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=04620d6d-4629-40b3-8150-c7eec447d262 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=04620d6d-4629-40b3-8150-c7eec447d262:5992fb07-187a-43c3-9434-d424f1312155\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979187872 + }, + { + "source": "stderr", + "line": "[47624:0702/035947.873:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979187873 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@b5230dba total=105ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=57ms daemon=true reattach=false", + "at": 1782979187978 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=24659 maxGapMs=46", + "at": 1782979188420 + }, + { + "source": "stderr", + "line": "[47624:0702/035948.434:INFO:CONSOLE:397] \"Tooltip is changing from controlled to uncontrolled. Components should not switch from controlled to uncontrolled (or vice versa). Decide between using a controlled or uncontrolled value for the lifetime of the component.\", source: http://localhost:5173/@fs/C:/Users/jinwo/orca/workspaces/orca/windows-performance-improvement/node_modules/.vite/deps/chunk-22L5D3A7.js?v=43681c89 (397)", + "at": 1782979188434 + }, + { + "source": "stderr", + "line": "[47624:0702/035948.761:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=bc4db6bc-b408-472a-9c15-77a428f98d99 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=bc4db6bc-b408-472a-9c15-77a428f98d99:11ff82bf-57d3-4e85-9293-77c0935f7d08\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979188761 + }, + { + "source": "stderr", + "line": "[47624:0702/035948.762:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979188761 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@95bb2f74 total=101ms preflight=0ms auth=0ms host_env=47ms options=0ms provider_spawn=54ms daemon=true reattach=false", + "at": 1782979188862 + }, + { + "source": "stderr", + "line": "[47624:0702/035949.641:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=57b2a6d7-7fd2-41b2-9772-44e39bc9cee6 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=57b2a6d7-7fd2-41b2-9772-44e39bc9cee6:d186da0c-c1c4-45ca-90f1-e1f3b8fa4505\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979189640 + }, + { + "source": "stderr", + "line": "[47624:0702/035949.641:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979189640 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@09fc3731 total=94ms preflight=0ms auth=0ms host_env=41ms options=0ms provider_spawn=53ms daemon=true reattach=false", + "at": 1782979189734 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=26688 maxGapMs=39", + "at": 1782979190449 + }, + { + "source": "stderr", + "line": "[47624:0702/035950.510:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=6883a556-63dd-4c0a-87fa-9682db6f4cf0 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=6883a556-63dd-4c0a-87fa-9682db6f4cf0:e0e844db-da79-459b-b791-52ea58bab4da\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979190509 + }, + { + "source": "stderr", + "line": "[47624:0702/035950.510:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979190510 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@fceb8b39 total=91ms preflight=0ms auth=0ms host_env=39ms options=1ms provider_spawn=51ms daemon=true reattach=false", + "at": 1782979190601 + }, + { + "source": "stderr", + "line": "[47624:0702/035951.407:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=aa62c8e8-2ef4-4aa3-ac4d-b9063cfa527e restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=aa62c8e8-2ef4-4aa3-ac4d-b9063cfa527e:c2c02ac2-e421-4c29-a8b6-4c97c11bf1dc\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979191407 + }, + { + "source": "stderr", + "line": "[47624:0702/035951.407:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979191407 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@badd8d38 total=104ms preflight=0ms auth=0ms host_env=47ms options=0ms provider_spawn=56ms daemon=true reattach=false", + "at": 1782979191511 + }, + { + "source": "stderr", + "line": "[47624:0702/035951.824:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=66083463-228e-4fb0-90c5-231dc75e8da6 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=66083463-228e-4fb0-90c5-231dc75e8da6:e4fc0e83-57a8-44f0-9732-936e2914292b\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979191824 + }, + { + "source": "stderr", + "line": "[47624:0702/035951.824:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979191824 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@ad09f3fe total=102ms preflight=0ms auth=0ms host_env=38ms options=0ms provider_spawn=64ms daemon=true reattach=false", + "at": 1782979191926 + }, + { + "source": "stderr", + "line": "[47624:0702/035952.310:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=b99bb441-7027-40a9-beb2-a558ece6f979 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=b99bb441-7027-40a9-beb2-a558ece6f979:f5f1c1e2-72ac-4152-958b-b0b80e1213cc\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979192310 + }, + { + "source": "stderr", + "line": "[47624:0702/035952.311:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979192310 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@0bb8c245 total=112ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=64ms daemon=true reattach=false", + "at": 1782979192422 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=28705 maxGapMs=52", + "at": 1782979192466 + }, + { + "source": "stderr", + "line": "[47624:0702/035952.723:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=490084ae-1bdb-4359-bb86-097e26902f98 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=490084ae-1bdb-4359-bb86-097e26902f98:c3647cbf-2f6e-4b4b-89c1-8bea8b949d66\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979192723 + }, + { + "source": "stderr", + "line": "[47624:0702/035952.723:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979192723 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@3ef141e6 total=152ms preflight=0ms auth=0ms host_env=41ms options=0ms provider_spawn=111ms daemon=true reattach=false", + "at": 1782979192875 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=30719 maxGapMs=43", + "at": 1782979194480 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=32744 maxGapMs=12", + "at": 1782979196505 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=34767 maxGapMs=14", + "at": 1782979198528 + }, + { + "source": "stderr", + "line": "[47624:0702/035959.350:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=769013c6-b5b8-4be9-b188-4444a6094567 restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=769013c6-b5b8-4be9-b188-4444a6094567:2a72038c-911c-4095-9fe8-0cd8c7f4978b\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979199349 + }, + { + "source": "stderr", + "line": "[47624:0702/035959.350:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979199349 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-one@@c450cf26 total=113ms preflight=0ms auth=0ms host_env=47ms options=0ms provider_spawn=66ms daemon=true reattach=false", + "at": 1782979199463 + }, + { + "source": "stderr", + "line": "[47624:0702/035959.852:INFO:CONSOLE:479] \"[pty-connect] pane=1 tab=15055907-3232-4ca1-8814-e5484ba78a5b restored=null existing=null detached=null reattach=null hasTransport=false pendingKey=15055907-3232-4ca1-8814-e5484ba78a5b:78b7478c-39a9-4111-aed8-1e2ddf568c24\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979199852 + }, + { + "source": "stderr", + "line": "[47624:0702/035959.853:INFO:CONSOLE:479] \"[pty-connect] pane=1 -> FRESH SPAWN\", source: http://localhost:5173/src/components/terminal-pane/pty-connection.ts (479)", + "at": 1782979199853 + }, + { + "source": "stdout", + "line": "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-two@@ae6fdb06 total=104ms preflight=0ms auth=0ms host_env=43ms options=0ms provider_spawn=61ms daemon=true reattach=false", + "at": 1782979199957 + }, + { + "source": "stderr", + "line": "[startup] event-loop-stall t=36789 maxGapMs=49", + "at": 1782979200550 + } + ], + "ptySpawnTimings": [ + "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@5810ae88 total=1300ms preflight=0ms auth=1ms host_env=44ms options=0ms provider_spawn=1255ms daemon=true reattach=false", + "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@2c485ebc total=1308ms preflight=0ms auth=0ms host_env=49ms options=0ms provider_spawn=1259ms daemon=true reattach=false", + "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@c3018b1f total=108ms preflight=0ms auth=0ms host_env=47ms options=0ms provider_spawn=61ms daemon=true reattach=false", + "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@59074e83 total=123ms preflight=0ms auth=0ms host_env=50ms options=0ms provider_spawn=73ms daemon=true reattach=false", + "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@562113d2 total=111ms preflight=0ms auth=0ms host_env=51ms options=0ms provider_spawn=60ms daemon=true reattach=false", + "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@b5230dba total=105ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=57ms daemon=true reattach=false", + "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@95bb2f74 total=101ms preflight=0ms auth=0ms host_env=47ms options=0ms provider_spawn=54ms daemon=true reattach=false", + "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@09fc3731 total=94ms preflight=0ms auth=0ms host_env=41ms options=0ms provider_spawn=53ms daemon=true reattach=false", + "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@fceb8b39 total=91ms preflight=0ms auth=0ms host_env=39ms options=1ms provider_spawn=51ms daemon=true reattach=false", + "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@badd8d38 total=104ms preflight=0ms auth=0ms host_env=47ms options=0ms provider_spawn=56ms daemon=true reattach=false", + "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@ad09f3fe total=102ms preflight=0ms auth=0ms host_env=38ms options=0ms provider_spawn=64ms daemon=true reattach=false", + "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@0bb8c245 total=112ms preflight=0ms auth=0ms host_env=48ms options=0ms provider_spawn=64ms daemon=true reattach=false", + "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/repo@@3ef141e6 total=152ms preflight=0ms auth=0ms host_env=41ms options=0ms provider_spawn=111ms daemon=true reattach=false", + "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-one@@c450cf26 total=113ms preflight=0ms auth=0ms host_env=47ms options=0ms provider_spawn=66ms daemon=true reattach=false", + "[pty-spawn-timing] id=cd78ddb3-9689-4d21-b87b-8e38f92d2e7b::C:/Users/jinwo/AppData/Local/Temp/orca-termperf-IyoYoT/wt-two@@ae6fdb06 total=104ms preflight=0ms auth=0ms host_env=43ms options=0ms provider_spawn=61ms daemon=true reattach=false" + ] +} diff --git a/tools/benchmarks/startup-time-bench.mjs b/tools/benchmarks/startup-time-bench.mjs index fd90221172c..905ae47474a 100644 --- a/tools/benchmarks/startup-time-bench.mjs +++ b/tools/benchmarks/startup-time-bench.mjs @@ -12,9 +12,17 @@ * node tools/benchmarks/startup-time-bench.mjs --label baseline * [--iterations 5] [--files 28000] [--fixture-dir ] * [--state-profile none|restored-local-tabs] [--session-tabs 200] + * [--github-repos 3] [--gh-hang-ms 30000] * [--wait-for-event renderer-startup-hydration-done] * [--exe ] [--timeout-ms 240000] * + * Issue #7225 freeze reproduction: `--github-repos N` seeds N git repos with + * GitHub remotes and no configured username, so repo hydration reaches the + * `gh` login probe; `--gh-hang-ms` puts a fake `gh` on PATH that hangs like a + * blackholed api.github.com. The child it spawns survives the probe's timeout + * kill while holding the inherited stdio pipe — the exact mechanism that + * turns a 2.5s execSync timeout into a minutes-long main-thread stall. + * * Prereq (when not using --exe): `pnpm build:electron-vite` so out/ exists. * Results: tools/benchmarks/results/startup-